@@ -397,6 +397,13 @@ describe('export parity — SQL vs GraphQL (integration)', () => {
397397 . toEqual ( { path : relPath , content : gqlFiltered [ relPath ] } ) ;
398398 }
399399
400+ // Verify the ORM was called with PostGraphile-style filter syntax
401+ expect ( mockFindMany ) . toHaveBeenCalledWith (
402+ expect . objectContaining ( {
403+ where : { databaseId : { equalTo : DATABASE_ID } }
404+ } )
405+ ) ;
406+
400407 // Sanity checks
401408 expect ( sqlPaths . some ( p => p . includes ( EXTENSION_NAME ) && p . endsWith ( 'pgpm.plan' ) ) ) . toBe ( true ) ;
402409 expect ( sqlPaths . some ( p => p . includes ( META_EXTENSION_NAME ) && p . endsWith ( 'pgpm.plan' ) ) ) . toBe ( true ) ;
@@ -461,4 +468,217 @@ describe('export parity — SQL vs GraphQL (integration)', () => {
461468 // Database module should NOT exist (no sql_actions, no migrateEndpoint)
462469 expect ( fs . existsSync ( path . join ( pkgsDir , EXTENSION_NAME , 'deploy' ) ) ) . toBe ( false ) ;
463470 } ) ;
471+
472+ // =========================================================================
473+ // Cursor-based pagination with >100 rows (multi-page + output parity)
474+ // =========================================================================
475+
476+ it ( 'should paginate through >100 sql_actions and produce output identical to single-page' , async ( ) => {
477+ mockFindMany . mockReset ( ) ;
478+
479+ // Generate 105 unique sql_actions — enough to exceed PAGE_SIZE (100)
480+ const PAGINATION_DATABASE_ID = 'a1b2c3d4-e5f6-4708-b250-000000000099' ;
481+ const TOTAL_ACTIONS = 105 ;
482+
483+ // Seed >100 sql_actions into the real test DB
484+ const insertValues : string [ ] = [ ] ;
485+ for ( let i = 0 ; i < TOTAL_ACTIONS ; i ++ ) {
486+ const padded = String ( i ) . padStart ( 3 , '0' ) ;
487+ const schemaPrefix = i < 50 ? 'pets_public' : 'pets_private' ;
488+ const prevSchemaPrefix = ( i - 1 ) < 50 ? 'pets_public' : 'pets_private' ;
489+ const depClause = i === 0
490+ ? `ARRAY[]::text[]`
491+ : `ARRAY['schemas/${ prevSchemaPrefix } /tables/tbl_${ String ( i - 1 ) . padStart ( 3 , '0' ) } /table']::text[]` ;
492+ insertValues . push (
493+ `('Create table tbl_${ padded } ', '${ PAGINATION_DATABASE_ID } ', 'schemas/${ schemaPrefix } /tables/tbl_${ padded } /table', ` +
494+ `${ depClause } , ` +
495+ `'CREATE TABLE ${ schemaPrefix } .tbl_${ padded } (id uuid PRIMARY KEY);', ` +
496+ `'DROP TABLE ${ schemaPrefix } .tbl_${ padded } ;', ` +
497+ `$$SELECT 1 FROM information_schema.tables WHERE table_schema = ''${ schemaPrefix } '' AND table_name = ''tbl_${ padded } '';$$)`
498+ ) ;
499+ }
500+
501+ // Use a separate pgsql-test connection for the large-dataset pagination test
502+ const { teardown : pagTeardown } = await getConnections ( { } , [
503+ seed . fn ( async ( { pg : pgClient , config } : any ) => {
504+ const migrate = new PgpmMigrate ( config ) ;
505+ await migrate . initialize ( ) ;
506+
507+ await pgClient . query ( SCHEMA_SHIMS_SQL ) ;
508+
509+ // Seed the database row + schemas (required by exportMigrations)
510+ await pgClient . query ( `
511+ INSERT INTO metaschema_public.database (id, owner_id, name, hash) VALUES
512+ ('${ PAGINATION_DATABASE_ID } ', '00000000-0000-0000-0000-000000000001', '${ DATABASE_NAME } ', 'f1e2d3c4-b5a6-5c2e-9a07-000000000099');
513+ INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description) VALUES
514+ ('aaaa0001-0000-0000-0000-000000000099', '${ PAGINATION_DATABASE_ID } ', 'public', 'pets_public', 'Public-facing tables'),
515+ ('aaaa0002-0000-0000-0000-000000000099', '${ PAGINATION_DATABASE_ID } ', 'private', 'pets_private', 'Internal tables');
516+ ` ) ;
517+
518+ // Seed >100 sql_actions
519+ await pgClient . query (
520+ `INSERT INTO db_migrate.sql_actions (name, database_id, deploy, deps, content, revert, verify) VALUES\n` +
521+ insertValues . join ( ',\n' )
522+ ) ;
523+
524+ // Fetch the camelCase rows for the mock
525+ const sqlActionsResult = await pgClient . query (
526+ 'SELECT * FROM db_migrate.sql_actions WHERE database_id = $1 ORDER BY id' ,
527+ [ PAGINATION_DATABASE_ID ]
528+ ) ;
529+ const pagCamelActions = sqlActionsResult . rows . map ( pgRowToCamel ) ;
530+ expect ( pagCamelActions . length ) . toBe ( TOTAL_ACTIONS ) ;
531+
532+ const pagSchemas = [
533+ { name : 'public' , schema_name : 'pets_public' } ,
534+ { name : 'private' , schema_name : 'pets_private' }
535+ ] ;
536+ const pagSchemaNames = pagSchemas . map ( s => s . schema_name ) ;
537+
538+ // Fetch meta result from real DB
539+ const pagMetaResult = await exportMeta ( { opts : { pg : config } , dbname : config . database , database_id : PAGINATION_DATABASE_ID } ) ;
540+
541+ // ---- Create two workspaces: SQL baseline and paginated GraphQL ----
542+ const pagTempDir = fs . mkdtempSync ( path . join ( require ( 'os' ) . tmpdir ( ) , 'pgpm-pagination-' ) ) ;
543+ const createWorkspace = ( label : string ) => {
544+ const wsDir = path . join ( pagTempDir , label ) ;
545+ const pkgsDir = path . join ( wsDir , 'packages' ) ;
546+ fs . mkdirSync ( path . join ( wsDir , 'extensions' ) , { recursive : true } ) ;
547+ fs . mkdirSync ( pkgsDir , { recursive : true } ) ;
548+ fs . writeFileSync ( path . join ( wsDir , 'pgpm.json' ) , JSON . stringify ( { packages : [ 'packages/*' , 'extensions/*' ] } ) ) ;
549+ fs . writeFileSync ( path . join ( wsDir , 'package.json' ) , JSON . stringify ( { name : label , version : '1.0.0' , private : true } ) ) ;
550+
551+ for ( const mod of [ EXTENSION_NAME , META_EXTENSION_NAME ] ) {
552+ const modDir = path . join ( pkgsDir , mod ) ;
553+ fs . mkdirSync ( modDir , { recursive : true } ) ;
554+ fs . writeFileSync ( path . join ( modDir , 'package.json' ) , JSON . stringify ( { name : mod , version : '1.0.0' } ) ) ;
555+ fs . writeFileSync ( path . join ( modDir , `${ mod } .control` ) , `comment='test'\ndefault_version='1.0.0'\n` ) ;
556+ fs . writeFileSync ( path . join ( modDir , 'pgpm.plan' ) , `%syntax-version=1.0.0\n%project=${ mod } \n%uri=${ mod } \n` ) ;
557+ }
558+ return wsDir ;
559+ } ;
560+
561+ const sqlWs = createWorkspace ( 'sql-baseline' ) ;
562+ const gqlWs = createWorkspace ( 'gql-paginated' ) ;
563+
564+ // ---- SQL flow (single query, no pagination) ----
565+ const sqlProject = new PgpmPackage ( sqlWs ) ;
566+ await exportMigrations ( {
567+ project : sqlProject ,
568+ options : { pg : config } ,
569+ dbInfo : {
570+ dbname : config . database ,
571+ databaseName : DATABASE_NAME ,
572+ database_ids : [ PAGINATION_DATABASE_ID ]
573+ } ,
574+ schema_names : pagSchemaNames ,
575+ author : 'test <test@test.local>' ,
576+ outdir : path . join ( sqlWs , 'packages' ) ,
577+ extensionName : EXTENSION_NAME ,
578+ metaExtensionName : META_EXTENSION_NAME
579+ } ) ;
580+
581+ // ---- GraphQL flow (paginated mock returning pages of 100) ----
582+ const PAGE_SIZE = 100 ;
583+ const pages : Record < string , unknown > [ ] [ ] = [ ] ;
584+ for ( let offset = 0 ; offset < pagCamelActions . length ; offset += PAGE_SIZE ) {
585+ pages . push ( pagCamelActions . slice ( offset , offset + PAGE_SIZE ) ) ;
586+ }
587+ // Should be 2 pages: [100, 5]
588+ expect ( pages . length ) . toBe ( 2 ) ;
589+ expect ( pages [ 0 ] . length ) . toBe ( PAGE_SIZE ) ;
590+ expect ( pages [ 1 ] . length ) . toBe ( TOTAL_ACTIONS - PAGE_SIZE ) ;
591+
592+ // Wire up mockFindMany to return each page with proper cursor info
593+ for ( let i = 0 ; i < pages . length ; i ++ ) {
594+ const isLast = i === pages . length - 1 ;
595+ mockFindMany . mockReturnValueOnce ( {
596+ unwrap : jest . fn ( ) . mockResolvedValue ( {
597+ sqlActions : {
598+ nodes : pages [ i ] ,
599+ totalCount : pagCamelActions . length ,
600+ pageInfo : {
601+ hasNextPage : ! isLast ,
602+ hasPreviousPage : i > 0 ,
603+ endCursor : isLast ? null : `cursor-after-page-${ i + 1 } `
604+ }
605+ }
606+ } )
607+ } ) ;
608+ }
609+
610+ ( exportGraphQLMeta as any ) . mockImplementation ( async ( ) => pagMetaResult ) ;
611+
612+ const gqlProject = new PgpmPackage ( gqlWs ) ;
613+ await exportGraphQL ( {
614+ project : gqlProject ,
615+ metaEndpoint : 'http://localhost:3002/graphql' ,
616+ migrateEndpoint : 'http://db_migrate.localhost:3000/graphql' ,
617+ token : undefined ,
618+ headers : { 'X-Meta-Schema' : 'true' } ,
619+ databaseId : PAGINATION_DATABASE_ID ,
620+ databaseName : DATABASE_NAME ,
621+ schema_names : pagSchemaNames ,
622+ schemas : pagSchemas ,
623+ author : 'test <test@test.local>' ,
624+ outdir : path . join ( gqlWs , 'packages' ) ,
625+ extensionName : EXTENSION_NAME ,
626+ metaExtensionName : META_EXTENSION_NAME
627+ } ) ;
628+
629+ // ---- Assertions ----
630+
631+ // findMany must have been called exactly twice (page 1 + page 2)
632+ expect ( mockFindMany ) . toHaveBeenCalledTimes ( 2 ) ;
633+
634+ // First call: no cursor
635+ expect ( mockFindMany . mock . calls [ 0 ] [ 0 ] ) . not . toHaveProperty ( 'after' ) ;
636+
637+ // Second call: cursor from page 1
638+ expect ( mockFindMany . mock . calls [ 1 ] [ 0 ] ) . toHaveProperty ( 'after' , 'cursor-after-page-1' ) ;
639+
640+ // Both calls use the correct filter
641+ for ( const call of mockFindMany . mock . calls ) {
642+ expect ( call [ 0 ] ) . toMatchObject ( {
643+ where : { databaseId : { equalTo : PAGINATION_DATABASE_ID } }
644+ } ) ;
645+ }
646+
647+ // ---- Output parity: paginated GraphQL must match SQL baseline ----
648+ const isScaffold = ( p : string ) =>
649+ p . endsWith ( 'package.json' ) || p . endsWith ( '.control' ) ;
650+ const filterScaffold = ( files : Record < string , string > ) => {
651+ const out : Record < string , string > = { } ;
652+ for ( const [ k , v ] of Object . entries ( files ) ) {
653+ if ( ! isScaffold ( k ) ) out [ k ] = v ;
654+ }
655+ return out ;
656+ } ;
657+
658+ const sqlFiles = filterScaffold ( collectFiles ( path . join ( sqlWs , 'packages' ) ) ) ;
659+ const gqlFiles = filterScaffold ( collectFiles ( path . join ( gqlWs , 'packages' ) ) ) ;
660+
661+ const sqlPaths = Object . keys ( sqlFiles ) . sort ( ) ;
662+ const gqlPaths = Object . keys ( gqlFiles ) . sort ( ) ;
663+
664+ // Same set of files
665+ expect ( gqlPaths ) . toEqual ( sqlPaths ) ;
666+
667+ // Identical content for every file
668+ for ( const relPath of sqlPaths ) {
669+ expect ( { path : relPath , content : gqlFiles [ relPath ] } )
670+ . toEqual ( { path : relPath , content : sqlFiles [ relPath ] } ) ;
671+ }
672+
673+ // Sanity: at least 105 deploy files were produced (one per sql_action)
674+ const deployFiles = sqlPaths . filter ( p => p . includes ( EXTENSION_NAME ) && p . includes ( '/deploy/' ) ) ;
675+ expect ( deployFiles . length ) . toBe ( TOTAL_ACTIONS ) ;
676+
677+ // Cleanup
678+ fs . rmSync ( pagTempDir , { recursive : true , force : true } ) ;
679+ } )
680+ ] ) ;
681+
682+ await pagTeardown ( ) ;
683+ } ) ;
464684} ) ;
0 commit comments