@@ -308,6 +308,66 @@ export class OfflineSyncService {
308308 } ) ;
309309 }
310310
311+ /**
312+ * Atomically rematerializes every unresolved intent for one aggregate.
313+ *
314+ * This is the conflict-recovery boundary for ordered intent chains: the old
315+ * chain remains durable until every replacement has been prepared and the
316+ * complete chain can be committed in one replica transaction.
317+ */
318+ replacePreparedAggregate < T > (
319+ commandId : string ,
320+ prepare : (
321+ repository : OfflineRepository ,
322+ commands : readonly OfflineCommand [ ] ,
323+ ) => Promise < readonly PreparedOfflineCommand < T > [ ] > ,
324+ options : { flush ?: boolean } = { } ,
325+ ) : Promise < readonly string [ ] > {
326+ const generation = this . #generation;
327+ return this . #serializeReplicaMutation( async ( ) => {
328+ await this . initialize ( ) ;
329+ if ( ! this . #isCurrent( generation ) ) throw new Error ( 'Offline session changed before prepared aggregate replacement.' ) ;
330+ const knownCommands = await this . #readKnownCommands( ) ;
331+ const selected = knownCommands . find ( ( command ) => command . commandId === commandId ) ;
332+ if ( ! selected ) throw new Error ( `Offline command ${ commandId } no longer exists.` ) ;
333+ const aggregateKey = this . #aggregateKey( selected ) ;
334+ const replaced = knownCommands . filter ( ( command ) => this . #aggregateKey( command ) === aggregateKey ) ;
335+ this . #assertDiscardable( replaced ) ;
336+ const prepared = await prepare ( this . #repository, replaced ) ;
337+ if ( prepared . length !== replaced . length ) {
338+ throw new Error ( 'Offline aggregate replacement must preserve the ordered intent count.' ) ;
339+ }
340+ const session = await this . #beginEnqueueSession( generation ) ;
341+ const materializations : MaterializedOfflineEnqueue [ ] = [ ] ;
342+ for ( const [ index , entry ] of prepared . entries ( ) ) {
343+ this . #assertEnqueueScope( session , entry . request . scopeId ) ;
344+ materializations . push (
345+ await this . #materializeEnqueue(
346+ session . userId ,
347+ entry . request ,
348+ entry . replicaTransaction ,
349+ replaced [ index ] ,
350+ ) ,
351+ ) ;
352+ }
353+ const retained = knownCommands . filter ( ( command ) => ! replaced . some ( ( item ) => item . commandId === command . commandId ) ) ;
354+ this . #assertDistinctBatchFootprints( materializations , retained , true ) ;
355+ await this . #assertOutboxCapacity(
356+ session . userId ,
357+ materializations . map ( ( item ) => item . command ) ,
358+ replaced . map ( ( command ) => command . commandId ) ,
359+ knownCommands ,
360+ ) ;
361+ await this . #commitMaterializedEnqueues(
362+ materializations ,
363+ generation ,
364+ options ,
365+ replaced . map ( ( command ) => command . commandId ) ,
366+ ) ;
367+ return materializations . map ( ( item ) => item . command . commandId ) ;
368+ } ) ;
369+ }
370+
311371 /**
312372 * Serializes a product-owned replica projection with enqueue and command ACK
313373 * reconciliation. For read/derive/write cache updates, prefer
@@ -352,7 +412,7 @@ export class OfflineSyncService {
352412 const currentCommands = await this . #commandsForUser( session . userId ) ;
353413 const retainedCommands = replaced ? currentCommands . filter ( ( command ) => command . commandId !== replaced . commandId ) : currentCommands ;
354414 this . #assertDistinctBatchFootprints( [ materialization ] , retainedCommands ) ;
355- await this . #assertOutboxCapacity( session . userId , [ materialization . command ] , replaced ? .commandId , currentCommands ) ;
415+ await this . #assertOutboxCapacity( session . userId , [ materialization . command ] , replaced ? [ replaced . commandId ] : undefined , currentCommands ) ;
356416 await this . #commitMaterializedEnqueues( [ materialization ] , generation , options , replaced ? [ replaced . commandId ] : undefined ) ;
357417 return materialization . command . commandId ;
358418 }
@@ -538,7 +598,11 @@ export class OfflineSyncService {
538598 return { command, optimisticRow, optimisticCompanions } ;
539599 }
540600
541- #assertDistinctBatchFootprints( entries : readonly MaterializedOfflineEnqueue [ ] , existingCommands : readonly OfflineCommand [ ] ) : void {
601+ #assertDistinctBatchFootprints(
602+ entries : readonly MaterializedOfflineEnqueue [ ] ,
603+ existingCommands : readonly OfflineCommand [ ] ,
604+ allowOneAggregate = false ,
605+ ) : void {
542606 const aggregates = new Set < string > ( ) ;
543607 const replicaKeys = new Set < string > ( ) ;
544608 const existingFootprints = new Map < string , string > ( ) ;
@@ -548,15 +612,15 @@ export class OfflineSyncService {
548612 }
549613 for ( const entry of entries ) {
550614 const aggregate = this . #aggregateKey( entry . command ) ;
551- if ( aggregates . has ( aggregate ) ) {
615+ if ( aggregates . has ( aggregate ) && ! allowOneAggregate ) {
552616 throw new Error ( 'Prepared offline batch contains overlapping aggregate intents.' ) ;
553617 }
554618 aggregates . add ( aggregate ) ;
555619 for ( const key of [
556620 this . #replicaRowKey( entry . optimisticRow ) ,
557621 ...entry . optimisticCompanions . map ( ( companion ) => this . #replicaRowKey( companion . key ) ) ,
558622 ] ) {
559- if ( replicaKeys . has ( key ) ) {
623+ if ( replicaKeys . has ( key ) && ! allowOneAggregate ) {
560624 throw new Error ( 'Prepared offline batch contains overlapping replica footprints.' ) ;
561625 }
562626 const existingAggregate = existingFootprints . get ( key ) ;
@@ -700,12 +764,12 @@ export class OfflineSyncService {
700764 async #assertOutboxCapacity(
701765 userId : OfflinePrincipalId ,
702766 newCommands : readonly OfflineCommand [ ] ,
703- excludingCommandId ?: string ,
767+ excludingCommandIds ?: readonly string [ ] ,
704768 knownCommands ?: readonly OfflineCommand [ ] ,
705769 ) : Promise < void > {
706770 const currentCommands = knownCommands ?? ( await this . #commandsForUser( userId ) ) ;
707- const commands = excludingCommandId
708- ? currentCommands . filter ( ( candidate ) => candidate . commandId !== excludingCommandId )
771+ const commands = excludingCommandIds
772+ ? currentCommands . filter ( ( candidate ) => ! excludingCommandIds . includes ( candidate . commandId ) )
709773 : currentCommands ;
710774 const maxCommands = this . #options. outboxLimits ?. maxCommandsPerUser ?? DEFAULT_MAX_OUTBOX_COMMANDS_PER_USER ;
711775 const maxBytes = this . #options. outboxLimits ?. maxBytesPerUser ?? DEFAULT_MAX_OUTBOX_BYTES_PER_USER ;
0 commit comments