@@ -98,6 +98,8 @@ vi.mock('@/connectors/registry.server', () => ({
9898 listDocuments : mocks . list ,
9999 getDocument : mocks . get ,
100100 listAccessibleScopes : mocks . scopes ,
101+ isListingCursorInvalidError : ( error : unknown ) =>
102+ error instanceof Error && error . message === 'cursor expired' ,
101103 } ,
102104 drive : {
103105 id : 'drive' ,
@@ -166,6 +168,7 @@ function arrange(
166168 organizationId ?: string
167169 memberCheckpoint ?: Record < string , unknown >
168170 scopeRenewedAt ?: Date
171+ scopeRenewal ?: { cursor : string ; startedAt : Date }
169172 } = { }
170173) {
171174 const connector = {
@@ -213,6 +216,8 @@ function arrange(
213216 : {
214217 ...member ,
215218 scopeRenewedAt : options . scopeRenewedAt ?? null ,
219+ scopeRenewalCursor : options . scopeRenewal ?. cursor ?? null ,
220+ scopeRenewalStartedAt : options . scopeRenewal ?. startedAt ?? null ,
216221 ...( options . memberCheckpoint ? { listingCheckpoint : options . memberCheckpoint } : { } ) ,
217222 }
218223 const claims = options . members && ! options . noDueMembers ? [ [ memberRow ] , [ ] ] : [ [ ] ]
@@ -358,6 +363,8 @@ describe('member engine with a dedicated content credential', () => {
358363 memberSyncedThrough : null ,
359364 lastCompleteListingAt : null ,
360365 scopeRenewedAt : null ,
366+ scopeRenewalCursor : null ,
367+ scopeRenewalStartedAt : null ,
361368 listingCheckpoint : { kind : 'membership' , cursor : null , removeMember : false } ,
362369 changeCursor : null ,
363370 nextAttemptAt : expect . any ( Date ) ,
@@ -706,6 +713,8 @@ describe('member engine with a dedicated content credential', () => {
706713 memberSyncedThrough : null ,
707714 lastCompleteListingAt : null ,
708715 scopeRenewedAt : null ,
716+ scopeRenewalCursor : null ,
717+ scopeRenewalStartedAt : null ,
709718 listingCheckpoint : { kind : 'membership' , cursor : null , removeMember : false } ,
710719 } )
711720 )
@@ -826,11 +835,13 @@ describe('member engine with a dedicated content credential', () => {
826835 } )
827836
828837 const scopeRenewal = ( ) =>
829- dbChainMockFns . set . mock . calls . find ( ( [ value ] ) => 'scopeRenewedAt' in value ) ?. [ 0 ]
838+ dbChainMockFns . set . mock . calls . find (
839+ ( [ value ] ) => 'scopeRenewedAt' in value || 'scopeRenewalCursor' in value
840+ ) ?. [ 0 ]
830841
831842 it ( 'renews member access by scope before listing and records when it finished' , async ( ) => {
832843 const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
833- mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] , complete : true } )
844+ mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] } )
834845 mocks . renew . mockResolvedValue ( { renewed : 3 , finished : true } )
835846 const result = await run ( )
836847 expect ( result . error ) . toBeUndefined ( )
@@ -842,7 +853,26 @@ describe('member engine with a dedicated content credential', () => {
842853 expect ( mocks . renew . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
843854 mocks . list . mock . invocationCallOrder [ memberListing ]
844855 )
845- expect ( scopeRenewal ( ) ) . toEqual ( { scopeRenewedAt : expect . any ( Date ) } )
856+ expect ( scopeRenewal ( ) ) . toEqual ( {
857+ scopeRenewedAt : expect . any ( Date ) ,
858+ scopeRenewalCursor : null ,
859+ scopeRenewalStartedAt : null ,
860+ } )
861+ } )
862+
863+ it ( 'renews every page of scopes before recording the renewal' , async ( ) => {
864+ const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
865+ mocks . scopes
866+ . mockResolvedValueOnce ( { prefixes : [ 'source:container-a:' ] , nextCursor : 'page-2' } )
867+ . mockResolvedValueOnce ( { prefixes : [ 'source:container-b:' ] } )
868+ mocks . renew . mockResolvedValue ( { renewed : 2 , finished : true } )
869+ expect ( ( await run ( ) ) . observationsRenewed ) . toBe ( 4 )
870+ expect ( mocks . scopes . mock . calls . map ( ( call ) => call [ 2 ] ) ) . toEqual ( [ undefined , 'page-2' ] )
871+ expect ( mocks . renew . mock . calls . map ( ( [ call ] ) => call . scopePrefixes ) ) . toEqual ( [
872+ [ 'source:container-a:' ] ,
873+ [ 'source:container-b:' ] ,
874+ ] )
875+ expect ( scopeRenewal ( ) ) . toMatchObject ( { scopeRenewedAt : expect . any ( Date ) } )
846876 } )
847877
848878 it ( 'does not renew again while the last renewal is recent' , async ( ) => {
@@ -857,23 +887,56 @@ describe('member engine with a dedicated content credential', () => {
857887 expect ( mocks . renew ) . not . toHaveBeenCalled ( )
858888 } )
859889
860- it ( 'leaves an unfinished renewal due for the next run' , async ( ) => {
861- const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
862- mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] , complete : true } )
890+ it ( 'stores the cursor of an unfinished page so the next run reads it again' , async ( ) => {
891+ const run = arrange ( {
892+ connectorType : 'scoped_listing' ,
893+ members : true ,
894+ contentFresh : true ,
895+ scopeRenewal : { cursor : 'page-7' , startedAt : new Date ( '2026-09-01T00:00:00Z' ) } ,
896+ } )
897+ mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] , nextCursor : 'page-8' } )
863898 mocks . renew . mockResolvedValue ( { renewed : 1000 , finished : false } )
864899 expect ( ( await run ( ) ) . observationsRenewed ) . toBe ( 1000 )
865- expect ( scopeRenewal ( ) ) . toBeUndefined ( )
900+ expect ( mocks . scopes . mock . calls [ 0 ] ?. [ 2 ] ) . toBe ( 'page-7' )
901+ expect ( scopeRenewal ( ) ) . toEqual ( {
902+ scopeRenewalCursor : 'page-7' ,
903+ scopeRenewalStartedAt : new Date ( '2026-09-01T00:00:00Z' ) ,
904+ } )
866905 } )
867906
868- it ( 'leaves renewal due when the source stopped before listing every scope' , async ( ) => {
869- const run = arrange ( { connectorType : 'scoped_listing' , members : true , contentFresh : true } )
870- mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-a:' ] , complete : false } )
871- mocks . renew . mockResolvedValue ( { renewed : 3 , finished : true } )
872- expect ( ( await run ( ) ) . observationsRenewed ) . toBe ( 3 )
873- expect ( mocks . renew ) . toHaveBeenCalledWith (
874- expect . objectContaining ( { scopePrefixes : [ 'source:container-a:' ] } )
875- )
876- expect ( scopeRenewal ( ) ) . toBeUndefined ( )
907+ it ( 'resumes a stored pass and records its original start once every page is renewed' , async ( ) => {
908+ const startedAt = new Date ( '2026-09-01T00:00:00Z' )
909+ const run = arrange ( {
910+ connectorType : 'scoped_listing' ,
911+ members : true ,
912+ contentFresh : true ,
913+ scopeRenewal : { cursor : 'page-7' , startedAt } ,
914+ } )
915+ mocks . scopes . mockResolvedValue ( { prefixes : [ 'source:container-z:' ] } )
916+ mocks . renew . mockResolvedValue ( { renewed : 1 , finished : true } )
917+ await run ( )
918+ expect ( mocks . scopes . mock . calls . map ( ( call ) => call [ 2 ] ) ) . toEqual ( [ 'page-7' ] )
919+ expect ( scopeRenewal ( ) ) . toEqual ( {
920+ scopeRenewedAt : startedAt ,
921+ scopeRenewalCursor : null ,
922+ scopeRenewalStartedAt : null ,
923+ } )
924+ } )
925+
926+ it ( 'restarts a pass whose stored cursor expired' , async ( ) => {
927+ const run = arrange ( {
928+ connectorType : 'scoped_listing' ,
929+ members : true ,
930+ contentFresh : true ,
931+ scopeRenewal : { cursor : 'page-7' , startedAt : new Date ( '2026-09-01T00:00:00Z' ) } ,
932+ } )
933+ mocks . scopes
934+ . mockRejectedValueOnce ( new Error ( 'cursor expired' ) )
935+ . mockResolvedValueOnce ( { prefixes : [ 'source:container-a:' ] } )
936+ mocks . renew . mockResolvedValue ( { renewed : 1 , finished : true } )
937+ await run ( )
938+ expect ( mocks . scopes . mock . calls . map ( ( call ) => call [ 2 ] ) ) . toEqual ( [ 'page-7' , undefined ] )
939+ expect ( scopeRenewal ( ) ) . toMatchObject ( { scopeRenewalCursor : null } )
877940 } )
878941
879942 it ( 'still lists the member when their access scopes cannot be read' , async ( ) => {
0 commit comments