@abloatai/humans 0.59.2 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/dist/Ablo.d.ts +2 -10
  3. package/dist/Ablo.js +0 -1
  4. package/dist/client.d.ts +1 -48
  5. package/dist/humans.d.ts +1 -1
  6. package/dist/local/BaseSyncedStore.d.ts +5 -5
  7. package/dist/local/BaseSyncedStore.js +7 -7
  8. package/dist/local/Database.d.ts +2 -2
  9. package/dist/local/LazyReferenceCollection.d.ts +1 -1
  10. package/dist/local/Model.js +46 -56
  11. package/dist/local/NetworkMonitor.js +2 -0
  12. package/dist/local/RuntimeContext.js +2 -0
  13. package/dist/local/SyncClient.d.ts +12 -36
  14. package/dist/local/SyncClient.js +50 -108
  15. package/dist/local/client/createModelOperations.d.ts +3 -27
  16. package/dist/local/client/createModelOperations.js +20 -21
  17. package/dist/local/client/options.d.ts +14 -39
  18. package/dist/local/client/reactiveEngine.d.ts +3 -9
  19. package/dist/local/client/reactiveEngine.js +6 -151
  20. package/dist/local/client/storeLifecycle.js +5 -1
  21. package/dist/local/fileUploads.d.ts +27 -0
  22. package/dist/local/fileUploads.js +55 -0
  23. package/dist/local/storeContract.d.ts +5 -5
  24. package/dist/local/stores/syncAction.d.ts +4 -4
  25. package/dist/local/sync/contextOnChange.js +1 -1
  26. package/dist/local/sync/createClaimStream.js +1 -1
  27. package/dist/local/sync/credentialLifecycle.d.ts +4 -5
  28. package/dist/local/sync/credentialLifecycle.js +4 -5
  29. package/dist/local/sync/deltaPipeline.js +12 -6
  30. package/dist/local/sync/schemas.d.ts +10 -10
  31. package/dist/local/sync/scopeGroups.d.ts +11 -0
  32. package/dist/local/sync/scopeGroups.js +75 -0
  33. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  34. package/dist/local/transactions/localMutation.js +3 -3
  35. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
  36. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  37. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  38. package/dist/local/transactions/mutations/commitPayload.d.ts +9 -2
  39. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  40. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  41. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  42. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  43. package/dist/local/transactions/mutations/localMutation.js +3 -3
  44. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  45. package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
  46. package/dist/react/AbloProvider.d.ts +11 -86
  47. package/dist/react/AbloProvider.js +11 -163
  48. package/dist/react/ClientSideSuspense.d.ts +1 -1
  49. package/dist/react/DefaultFallback.d.ts +1 -1
  50. package/dist/react/createAbloReact.js +1 -1
  51. package/dist/react/useErrorListener.js +1 -1
  52. package/dist/react/useMutationFailureListener.js +1 -1
  53. package/dist/react.d.ts +1 -1
  54. package/dist/react.js +1 -1
  55. package/dist/surface.d.ts +3 -3
  56. package/dist/surface.js +1 -4
  57. package/package.json +3 -3
  58. package/src/Ablo.ts +5 -17
  59. package/src/client.ts +0 -51
  60. package/src/local/BaseSyncedStore.ts +14 -14
  61. package/src/local/Model.ts +45 -55
  62. package/src/local/NetworkMonitor.ts +2 -0
  63. package/src/local/RuntimeContext.ts +2 -0
  64. package/src/local/SyncClient.ts +73 -140
  65. package/src/local/client/createModelOperations.ts +30 -64
  66. package/src/local/client/options.ts +20 -43
  67. package/src/local/client/reactiveEngine.ts +7 -179
  68. package/src/local/client/storeLifecycle.ts +6 -1
  69. package/src/local/fileUploads.ts +97 -0
  70. package/src/local/storeContract.ts +5 -5
  71. package/src/local/sync/contextOnChange.ts +1 -1
  72. package/src/local/sync/createClaimStream.ts +1 -1
  73. package/src/local/sync/credentialLifecycle.ts +4 -5
  74. package/src/local/sync/deltaPipeline.ts +10 -6
  75. package/src/local/sync/scopeGroups.ts +91 -0
  76. package/src/local/sync/wsFrameHandlers.ts +0 -1
  77. package/src/local/transactions/localMutation.ts +3 -3
  78. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  79. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  80. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  81. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  82. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  83. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  84. package/src/local/transactions/mutations/localMutation.ts +3 -3
  85. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  86. package/src/react/AbloProvider.tsx +17 -249
  87. package/src/react/useErrorListener.ts +1 -1
  88. package/src/react/useMutationFailureListener.ts +1 -1
  89. package/src/react.ts +1 -5
  90. package/src/surface.ts +1 -4
  91. package/dist/local/sync/participants.d.ts +0 -132
  92. package/dist/local/sync/participants.js +0 -342
  93. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  94. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  95. package/src/local/sync/participants.ts +0 -564
  96. package/src/local/transactions/mutations/pendingDrain.ts +0 -169
@@ -0,0 +1,97 @@
1
+ /** File-upload behavior owned beneath the SyncClient boundary. */
2
+
3
+ import { AbloAuthenticationError } from '@abloatai/transaction/errors';
4
+ import type { RuntimeContext } from './RuntimeContext.js';
5
+ import { Model } from './Model.js';
6
+ import { InstanceCache, ModelScope } from './InstanceCache.js';
7
+ import type { MutationQueue } from './transactions/mutations/MutationQueue.js';
8
+
9
+ export interface FileUploadOptions {
10
+ readonly id: string;
11
+ readonly attachableType: string;
12
+ readonly attachableId: string;
13
+ readonly metadata?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface BatchFileUploadOptions {
17
+ readonly ids: string[];
18
+ readonly attachableType: string;
19
+ readonly attachableId: string;
20
+ readonly metadata?: Record<string, unknown>;
21
+ }
22
+
23
+ export interface FileUploadContext {
24
+ readonly userId: string | null;
25
+ readonly organizationId: string | null;
26
+ readonly mutationQueue: MutationQueue;
27
+ readonly objectPool: InstanceCache;
28
+ readonly observability: RuntimeContext['observability'];
29
+ readonly notifyCreated: (model: Model) => void;
30
+ }
31
+
32
+ function authenticatedContext(context: FileUploadContext): {
33
+ readonly userId: string;
34
+ readonly organizationId: string;
35
+ } {
36
+ if (!context.userId || !context.organizationId) {
37
+ throw new AbloAuthenticationError('Authentication required for file uploads', {
38
+ code: 'file_upload_auth_required',
39
+ });
40
+ }
41
+ return { userId: context.userId, organizationId: context.organizationId };
42
+ }
43
+
44
+ function acceptUploadedModel(
45
+ context: FileUploadContext,
46
+ data: Record<string, unknown>,
47
+ ): Model | null {
48
+ const model = context.objectPool.createFromData(data);
49
+ if (!model) return null;
50
+ context.objectPool.add(model, ModelScope.live);
51
+ context.notifyCreated(model);
52
+ return model;
53
+ }
54
+
55
+ export async function uploadFile(
56
+ context: FileUploadContext,
57
+ file: File,
58
+ options: FileUploadOptions,
59
+ ): Promise<Model | null> {
60
+ const identity = authenticatedContext(context);
61
+ try {
62
+ const result = await context.mutationQueue.uploadAttachment(file, {
63
+ id: options.id,
64
+ attachableType: options.attachableType,
65
+ attachableId: options.attachableId,
66
+ metadata: options.metadata,
67
+ }, identity);
68
+ return result
69
+ ? acceptUploadedModel(context, { id: options.id, ...result })
70
+ : null;
71
+ } catch (error) {
72
+ context.observability.captureMutationFailure({
73
+ context: 'file-upload',
74
+ error: error instanceof Error ? error : new Error(String(error)),
75
+ });
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ export async function batchUploadFiles(
81
+ context: FileUploadContext,
82
+ files: File[],
83
+ options: BatchFileUploadOptions,
84
+ ): Promise<Model[]> {
85
+ const identity = authenticatedContext(context);
86
+ const items = options.ids.map((id) => ({
87
+ id,
88
+ attachableType: options.attachableType,
89
+ attachableId: options.attachableId,
90
+ metadata: options.metadata,
91
+ }));
92
+ const results = await context.mutationQueue.batchUploadAttachments(files, items, identity);
93
+ return results.flatMap((result) => {
94
+ const model = acceptUploadedModel(context, { ...result });
95
+ return model ? [model] : [];
96
+ });
97
+ }
@@ -14,7 +14,7 @@ import type { Model } from './Model.js';
14
14
  import type { ModelScope } from '@abloatai/transaction/types';
15
15
  import type { QueryView, QueryViewOptions } from './views/QueryView.js';
16
16
  import type { ViewRegistry } from './views/ViewRegistry.js';
17
- import type { ParticipantScope } from './sync/participants.js';
17
+ import type { GroupScope } from './sync/scopeGroups.js';
18
18
 
19
19
  /**
20
20
  * A snapshot of the client's synchronization state, shaped for binding to UI.
@@ -132,10 +132,10 @@ export interface SyncStoreContract {
132
132
  * read subscriptions and write claims always agree on which group they refer
133
133
  * to. These are optional and do nothing until the connection is open.
134
134
  */
135
- enterScope?(scope: ParticipantScope, opts?: { hydrate?: boolean }): Promise<void>;
136
- leaveScope?(scope: ParticipantScope): Promise<void>;
137
- pinScope?(scope: ParticipantScope): Promise<void>;
138
- unpinScope?(scope: ParticipantScope): Promise<void>;
135
+ enterScope?(scope: GroupScope, opts?: { hydrate?: boolean }): Promise<void>;
136
+ leaveScope?(scope: GroupScope): Promise<void>;
137
+ pinScope?(scope: GroupScope): Promise<void>;
138
+ unpinScope?(scope: GroupScope): Promise<void>;
139
139
  /**
140
140
  * The full reactive {@link SyncStatus} record. The `useSyncStatus()` hook
141
141
  * reads its fields — `state`, `progress`, `pendingChanges`, `isSessionError`,
@@ -54,7 +54,7 @@ export function contextOnChange(
54
54
  // already advanced this exact row in the pool.
55
55
  for (const read of rowReads) {
56
56
  const resident = pool.peek(read.id);
57
- if (!resident || resident.getModelName().toLowerCase() !== read.model.toLowerCase()) {
57
+ if (resident?.getModelName().toLowerCase() !== read.model.toLowerCase()) {
58
58
  continue;
59
59
  }
60
60
  const observed = pool.watermarks.of(resident);
@@ -468,7 +468,7 @@ export function createClaimStream(
468
468
  }
469
469
  ownClaims.clear();
470
470
  for (const claimId of [...pendingHeartbeats.keys()]) {
471
- settleHeartbeat(claimId, ({ reject }) => reject(error));
471
+ settleHeartbeat(claimId, ({ reject }) => { reject(error); });
472
472
  }
473
473
  }),
474
474
  );
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Moved to the confirmation core with the duplex transport (ADR 0016): keeping
3
- * a long-lived socket's credential fresh is connection plumbing an agent needs
4
- * as much as a browser does. This path re-exports it so existing importers
5
- * stay unchanged.
2
+ * The shared session subsystem owns renewal for both browser and agent
3
+ * sessions. This local boundary keeps the reactive store pointed downward at
4
+ * that one lifecycle implementation.
6
5
  */
7
6
 
8
7
  export {
@@ -15,4 +14,4 @@ export {
15
14
  type CredentialRefreshResult,
16
15
  type CredentialRefresher,
17
16
  type CredentialLifecycleContext,
18
- } from '@abloatai/transaction/transport/connection';
17
+ } from '@abloatai/transaction/sessions';
@@ -173,7 +173,9 @@ export function deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
173
173
 
174
174
  let strictlyOrdered = true;
175
175
  for (let index = 1; index < deltas.length; index += 1) {
176
- if (deltas[index - 1]!.id >= deltas[index]!.id) {
176
+ const previous = deltas[index - 1];
177
+ const current = deltas[index];
178
+ if (!previous || !current || previous.id >= current.id) {
177
179
  strictlyOrdered = false;
178
180
  break;
179
181
  }
@@ -400,10 +402,12 @@ export function sliceApplyChanges<T extends { readonly transactionId?: string }>
400
402
  while (index < changes.length) {
401
403
  // The indivisible unit starting here: one transaction's run, or a single
402
404
  // untransacted change.
403
- const transactionId = changes[index]!.transactionId;
405
+ const change = changes[index];
406
+ if (!change) break;
407
+ const transactionId = change.transactionId;
404
408
  let end = index + 1;
405
409
  if (transactionId !== undefined) {
406
- while (end < changes.length && changes[end]!.transactionId === transactionId) end += 1;
410
+ while (changes[end]?.transactionId === transactionId) end += 1;
407
411
  }
408
412
  const groupSize = end - index;
409
413
  if (current.length > 0 && current.length + groupSize > maxDeltas) {
@@ -459,9 +463,10 @@ async function flushDeltaBatchInner(
459
463
  if (customDeltas.length > 0) {
460
464
  runInAction(() => {
461
465
  for (const delta of customDeltas) {
466
+ if (delta.data === null) continue;
462
467
  const data = typeof delta.data === 'string'
463
468
  ? (JSON.parse(delta.data) as Record<string, unknown>)
464
- : (delta.data!);
469
+ : delta.data;
465
470
 
466
471
  // 'C' (Covering) is treated identically to 'I' here — the client
467
472
  // gained permission to see the entity, so we insert it into the
@@ -529,7 +534,7 @@ async function flushDeltaBatchInner(
529
534
  // slice. Slices stay the atomicity unit; the budget only decides where
530
535
  // the loop breathes.
531
536
  let sliceStartedAt = performance.now();
532
- for (let index = 0; index < slices.length; index++) {
537
+ for (const [index, slice] of slices.entries()) {
533
538
  if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
534
539
  pipelineDebug.phase = `apply-yield-${index}`;
535
540
  pipelineDebug.applyYields += 1;
@@ -538,7 +543,6 @@ async function flushDeltaBatchInner(
538
543
  }
539
544
  pipelineDebug.phase = `apply-slice-${index}`;
540
545
  pipelineDebug.applySlices += 1;
541
- const slice = slices[index]!;
542
546
  if (hasApplyPlugins) {
543
547
  runStage(stagePlugins, 'apply', { changes: slice });
544
548
  } else {
@@ -0,0 +1,91 @@
1
+ import type { ClaimTarget } from '@abloatai/transaction/types/streams';
2
+ import type { Schema } from '@abloatai/transaction/schema/schema';
3
+ import { scopeKindOf, type ModelDef } from '@abloatai/transaction/schema/model';
4
+
5
+ /** A schema-shaped selector used to narrow connection groups and presence reads. */
6
+ export type GroupScope =
7
+ | ClaimTarget
8
+ | readonly ClaimTarget[]
9
+ | string
10
+ | readonly string[]
11
+ | { readonly syncGroup: string }
12
+ | { readonly syncGroups: readonly string[] }
13
+ | Record<string, string | readonly string[] | undefined>;
14
+
15
+ /** Resolve an application-shaped scope into the wire groups owned by the schema. */
16
+ export function resolveScopeGroups(
17
+ scope: GroupScope | undefined,
18
+ schema?: Schema,
19
+ ): string[] {
20
+ if (!scope) return [];
21
+ if (typeof scope === 'string') return [scope];
22
+ if (Array.isArray(scope)) {
23
+ const groups: string[] = [];
24
+ for (const entry of scope as readonly unknown[]) {
25
+ if (typeof entry === 'string') groups.push(entry);
26
+ else if (isEntityScope(entry)) groups.push(groupFromEntityRef(entry, schema));
27
+ }
28
+ return groups;
29
+ }
30
+ const direct = scope as { syncGroup?: unknown; syncGroups?: unknown };
31
+ if (isEntityScope(scope)) return [groupFromEntityRef(scope, schema)];
32
+ if (typeof direct.syncGroup === 'string') return [direct.syncGroup];
33
+ if (Array.isArray(direct.syncGroups)) {
34
+ return direct.syncGroups.filter((group): group is string => typeof group === 'string');
35
+ }
36
+ const groups: string[] = [];
37
+ for (const [key, value] of Object.entries(scope) as [string, unknown][]) {
38
+ if (value === undefined) continue;
39
+ if (Array.isArray(value)) {
40
+ for (const id of value as unknown[]) {
41
+ if (typeof id === 'string') groups.push(groupFromSchemaKey(key, id, schema));
42
+ }
43
+ } else if (typeof value === 'string') {
44
+ groups.push(groupFromSchemaKey(key, value, schema));
45
+ }
46
+ }
47
+ return groups;
48
+ }
49
+
50
+ export function groupFromEntityRef(ref: ClaimTarget, schema?: Schema): string {
51
+ const match = findModelForEntityRef(ref, schema);
52
+ const kind = match
53
+ ? groupKindForModel(match.def, match.key)
54
+ : ref.type.toLowerCase();
55
+ return `${kind}:${ref.id}`;
56
+ }
57
+
58
+ function groupFromSchemaKey(schemaKey: string, id: string, schema?: Schema): string {
59
+ const def = schema?.models[schemaKey];
60
+ const kind = def ? groupKindForModel(def, schemaKey) : schemaKey.toLowerCase();
61
+ return `${kind}:${id}`;
62
+ }
63
+
64
+ function groupKindForModel(def: ModelDef, key: string): string {
65
+ return scopeKindOf(def, key) ?? (def.typename ?? key).toLowerCase();
66
+ }
67
+
68
+ function findModelForEntityRef(
69
+ ref: ClaimTarget,
70
+ schema?: Schema,
71
+ ): { key: string; def: ModelDef } | null {
72
+ if (!schema?.models) return null;
73
+ const wanted = ref.type.toLowerCase();
74
+ for (const [key, def] of Object.entries(schema.models) as [string, ModelDef][]) {
75
+ const typename = def.typename ?? key;
76
+ if (typename.toLowerCase() === wanted || key.toLowerCase() === wanted) {
77
+ return { key, def };
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function isEntityScope(scope: unknown): scope is ClaimTarget {
84
+ return (
85
+ typeof scope === 'object' &&
86
+ scope !== null &&
87
+ !Array.isArray(scope) &&
88
+ typeof (scope as { type?: unknown }).type === 'string' &&
89
+ typeof (scope as { id?: unknown }).id === 'string'
90
+ );
91
+ }
@@ -12,7 +12,6 @@ export {
12
12
  wsFrameHandlers,
13
13
  dispatchWsFrame,
14
14
  type PendingCommit,
15
- type PendingClaim,
16
15
  type PendingSubscription,
17
16
  type WsInboundFrame,
18
17
  type WsSession,
@@ -35,11 +35,11 @@ export function createLocalMutationPort(
35
35
  return {
36
36
  updates,
37
37
  applyCreate: (model, transaction) =>
38
- track('optimistic:create', model, transaction),
38
+ { track('optimistic:create', model, transaction); },
39
39
  applyUpdate: (model, transaction) =>
40
- track('optimistic:update', model, transaction),
40
+ { track('optimistic:update', model, transaction); },
41
41
  applyDelete: (model, transaction) =>
42
- track('optimistic:delete', model, transaction),
42
+ { track('optimistic:delete', model, transaction); },
43
43
  rollback: (transaction, reason, error) => {
44
44
  const optimistic = updates.get(transaction.id);
45
45
  if (!optimistic) return Promise.resolve();
@@ -108,12 +108,8 @@ import { enqueueTransaction, type QueueCoalescingContext } from './queueCoalesci
108
108
  import { processBatch, type BatchProcessingContext } from './batchProcessing.js';
109
109
  import { handleFailure, type FailureHandlingContext } from './failureHandling.js';
110
110
  import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection, type ConflictResolutionContext } from './failurePolicy.js';
111
- import { takeNextExecutionBatch as selectExecutionBatch, takePendingDrainBatch as selectPendingDrainBatch } from './executionSelection.js';
111
+ import { takeNextExecutionBatch as selectExecutionBatch } from './executionSelection.js';
112
112
  import { scheduleProcessing as scheduleProcessingExternal, type ProcessingSchedulerContext } from './processingScheduler.js';
113
- import {
114
- drainPendingConfirmations,
115
- type PendingDrainContext,
116
- } from './pendingDrain.js';
117
113
  import { restoreDurableCommits as restoreDurableCommitsExternal, type DurableCommitRestoreContext } from './durableCommitRestore.js';
118
114
 
119
115
  // The queue is split across sibling modules (`commitPayload`,
@@ -244,6 +240,7 @@ export class MutationQueue extends EventEmitter {
244
240
  }[] = [];
245
241
  private persistenceStageScheduled = false;
246
242
  private pendingDrainPromise: Promise<void> | null = null;
243
+ private modelProcessingPromise: Promise<void> | null = null;
247
244
 
248
245
  private executionQueue: QueuedMutation[] = [];
249
246
  private isProcessing = false;
@@ -496,33 +493,6 @@ export class MutationQueue extends EventEmitter {
496
493
  };
497
494
  }
498
495
 
499
- private get pendingDrainContext(): PendingDrainContext {
500
- return {
501
- runtime: this.runtime,
502
- config: { deltaConfirmationTimeout: this.config.deltaConfirmationTimeout },
503
- store: this.store,
504
- executionQueue: this.executionQueue,
505
- optimisticUpdates: this.localMutationPort.updates,
506
- assertDurableReplayOpen: () => { this.assertDurableReplayOpen(); },
507
- processCommitLane: () => this.processCommitLane(),
508
- takePendingDrainBatch: (pending) => this.takePendingDrainBatch(pending),
509
- ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope(batch),
510
- ensureDerivedFields: (transaction) => { this.ensureDerivedFields(transaction); },
511
- sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
512
- sealDurableCommit: (input) => this.sealDurableCommit(input),
513
- assertEnvelopeInsideReplayWindow: (envelope) => { this.assertEnvelopeInsideReplayWindow(envelope); },
514
- parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
515
- dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
516
- persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
517
- removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
518
- scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => { this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId); },
519
- scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => { this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs); },
520
- enqueue: (transaction) => { this.enqueue(transaction); },
521
- recentDeltaCorrelations: this.recentDeltaCorrelations,
522
- emit: (event, payload) => this.emit(event, payload),
523
- };
524
- }
525
-
526
496
  private get durableCommitRestoreContext(): DurableCommitRestoreContext {
527
497
  return {
528
498
  config: this.config,
@@ -1064,10 +1034,6 @@ export class MutationQueue extends EventEmitter {
1064
1034
  return selected.batch;
1065
1035
  }
1066
1036
 
1067
- private takePendingDrainBatch(pending: QueuedMutation[]): QueuedMutation[] {
1068
- return selectPendingDrainBatch(pending, this.config.maxBatchSize);
1069
- }
1070
-
1071
1037
  /**
1072
1038
  * Resolvers for per-transaction `confirmation` promises. Populated in
1073
1039
  * `attachConfirmation` at staging time, consumed by the constructor-time
@@ -1361,22 +1327,15 @@ export class MutationQueue extends EventEmitter {
1361
1327
  }
1362
1328
 
1363
1329
  private async drainPendingInternal(): Promise<void> {
1364
- // The normal batch scheduler and the explicit/reconnect drain are two
1365
- // ways to drive the same durable queue. They must never seal the same
1366
- // staged source records concurrently: the first seal consumes those
1367
- // records, so the second would correctly reject them as already claimed.
1368
- //
1369
- // `isProcessing` is acquired synchronously before either path awaits,
1370
- // making it the queue-wide execution lock. If the normal lane already
1371
- // owns it, that lane will finish the pending work; callers waiting on a
1372
- // specific confirmation remain attached to the exact transaction.
1373
- if (this.isProcessing) return;
1374
- this.isProcessing = true;
1375
- try {
1376
- await drainPendingConfirmations(this.pendingDrainContext);
1377
- } finally {
1378
- this.isProcessing = false;
1379
- if (this.executionQueue.length > 0) this.scheduleProcessing(true);
1330
+ // Explicit flushes and reconnects are merely another trigger for the one
1331
+ // model-mutation execution lane. A second sealing implementation can race
1332
+ // the scheduled lane, consume its journal sources, and later dispatch the
1333
+ // same transaction again. Move every staged row to the owned queue, then
1334
+ // drive the normal lane until the queue has handed off all current work.
1335
+ this.commitCreatedTransactions();
1336
+ await this.processCommitLane();
1337
+ while (this.executionQueue.length > 0 || this.modelProcessingPromise) {
1338
+ await this.processBatch();
1380
1339
  }
1381
1340
  }
1382
1341
  async create(
@@ -1445,7 +1404,19 @@ export class MutationQueue extends EventEmitter {
1445
1404
  }
1446
1405
 
1447
1406
  private async processBatch(): Promise<void> {
1448
- await processBatch(this.batchProcessingContext);
1407
+ if (this.modelProcessingPromise) {
1408
+ await this.modelProcessingPromise;
1409
+ if (this.executionQueue.length > 0) await this.processBatch();
1410
+ return;
1411
+ }
1412
+ const processing = processBatch(this.batchProcessingContext);
1413
+ const tracked = processing.finally(() => {
1414
+ if (this.modelProcessingPromise === tracked) {
1415
+ this.modelProcessingPromise = null;
1416
+ }
1417
+ });
1418
+ this.modelProcessingPromise = tracked;
1419
+ await tracked;
1449
1420
  }
1450
1421
 
1451
1422
  private rememberDeltaCorrelation(correlationId: string, syncId: number): void {
@@ -133,16 +133,31 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
133
133
  if (batchOps.length > 0) {
134
134
  let dispatchStarted = false;
135
135
  try {
136
- const durableEnvelope = await ctx.sealDurableCommit({
137
- idempotencyKey: commitIdempotencyKey,
138
- origin: 'model_batch',
139
- operations: batchOps.map(({ op }) => op),
140
- sourceMutationIds: ctx.sourceMutationIdsFor(batch),
141
- commitOptions: { reads: collectQueuedReads(batch) },
142
- createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
143
- sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
144
- sequence: batch[0]?.commitEnvelope?.sequence,
145
- });
136
+ let durableEnvelope = batch[0]?.durableEnvelope;
137
+ if (durableEnvelope) {
138
+ const mismatched = batch.some(
139
+ (transaction) =>
140
+ transaction.durableEnvelope?.idempotencyKey !==
141
+ durableEnvelope?.idempotencyKey,
142
+ );
143
+ if (mismatched || durableEnvelope.idempotencyKey !== commitIdempotencyKey) {
144
+ throw new Error('Cannot replay a model batch with inconsistent durable envelopes');
145
+ }
146
+ } else {
147
+ durableEnvelope = await ctx.sealDurableCommit({
148
+ idempotencyKey: commitIdempotencyKey,
149
+ origin: 'model_batch',
150
+ operations: batchOps.map(({ op }) => op),
151
+ sourceMutationIds: ctx.sourceMutationIdsFor(batch),
152
+ commitOptions: { reads: collectQueuedReads(batch) },
153
+ createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
154
+ sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
155
+ sequence: batch[0]?.commitEnvelope?.sequence,
156
+ });
157
+ for (const transaction of batch) {
158
+ transaction.durableEnvelope = durableEnvelope;
159
+ }
160
+ }
146
161
  const operations = durableEnvelope.operations;
147
162
 
148
163
  // Capture lastSyncId from the server response for threshold-based
@@ -14,7 +14,10 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
14
14
  import { MutationOperationType } from '@abloatai/transaction/types';
15
15
  import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
16
16
  import type { MutationOptions, WriteOptions } from '../../interfaces/index.js';
17
- import type { CommitEnvelopeMember } from '@abloatai/transaction/commit';
17
+ import type {
18
+ CommitEnvelopeMember,
19
+ DurableCommitEnvelope,
20
+ } from '@abloatai/transaction/commit';
18
21
 
19
22
  export interface UserContext {
20
23
  userId: string;
@@ -123,6 +126,13 @@ export interface QueuedMutation {
123
126
  * re-batching its operations under a fresh key.
124
127
  */
125
128
  commitEnvelope?: CommitEnvelopeMember;
129
+ /**
130
+ * The exact durable request produced by the first successful local seal.
131
+ * Runtime retries dispatch this object directly. Asking the outbox to seal
132
+ * again is both unnecessary and unsafe after a concurrent authoritative
133
+ * completion has begun cleaning up the stored envelope.
134
+ */
135
+ durableEnvelope?: DurableCommitEnvelope;
126
136
  /** Pending-mutation journal entries atomically consumed by this envelope. */
127
137
  sourceMutationIds?: string[];
128
138
  /** Completed locally without a server operation; no sync echo will arrive. */
@@ -158,10 +158,10 @@ export function dispatchCommitBounded(
158
158
  const timeoutMs = ctx.config.commitDispatchTimeoutMs;
159
159
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return dispatched;
160
160
  return new Promise((resolve, reject) => {
161
- const timer = setTimeout(() => reject(new AbloConnectionError(
161
+ const timer = setTimeout(() => { reject(new AbloConnectionError(
162
162
  'The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.',
163
163
  { code: 'commit_no_result' },
164
- )), timeoutMs);
164
+ )); }, timeoutMs);
165
165
  dispatched.then(
166
166
  (value) => { clearTimeout(timer); resolve(value); },
167
167
  (error) => { clearTimeout(timer); reject(error instanceof Error ? error : new Error(String(error))); },
@@ -4,8 +4,12 @@ export function takeNextExecutionBatch(
4
4
  executionQueue: QueuedMutation[],
5
5
  maxBatchSize: number,
6
6
  ): { batch: QueuedMutation[]; remaining: QueuedMutation[] } {
7
+ // Cancellation, delta confirmation, and failure settlement can all make a
8
+ // queued reference terminal before its scheduler callback runs. Terminal or
9
+ // currently executing rows have no authority to cross the dispatch boundary.
10
+ const pendingQueue = executionQueue.filter((tx) => tx.status === 'pending');
7
11
  const retryGroups = new Map<string, Map<string, QueuedMutation>>();
8
- for (const tx of executionQueue) {
12
+ for (const tx of pendingQueue) {
9
13
  const envelope = tx.commitEnvelope;
10
14
  if (!envelope) continue;
11
15
  const group = retryGroups.get(envelope.idempotencyKey) ?? new Map<string, QueuedMutation>();
@@ -16,27 +20,17 @@ export function takeNextExecutionBatch(
16
20
  const members = [...byId.values()];
17
21
  const expectedCount = members[0]?.commitEnvelope?.operationCount;
18
22
  if (expectedCount === undefined || members.length !== expectedCount) continue;
19
- const remaining = executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
23
+ const remaining = pendingQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
20
24
  members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) - (b.commitEnvelope?.operationIndex ?? 0));
21
25
  return { batch: members, remaining };
22
26
  }
23
- const fresh = executionQueue.filter((tx) => !tx.commitEnvelope);
27
+ const fresh = pendingQueue.filter((tx) => !tx.commitEnvelope);
24
28
  const firstFresh = fresh[0];
25
- if (!firstFresh) return { batch: [], remaining: executionQueue };
29
+ if (!firstFresh) return { batch: [], remaining: pendingQueue };
26
30
  const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
27
31
  const selected = explicitIndex === 0
28
32
  ? [firstFresh]
29
33
  : fresh.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
30
34
  const selectedIds = new Set(selected.map((tx) => tx.id));
31
- return { batch: selected, remaining: executionQueue.filter((tx) => !selectedIds.has(tx.id)) };
32
- }
33
-
34
- export function takePendingDrainBatch(pending: QueuedMutation[], maxBatchSize: number): QueuedMutation[] {
35
- const first = pending[0];
36
- if (!first) return [];
37
- const envelope = first.commitEnvelope;
38
- if (envelope) return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
39
- if (typeof first.writeOptions?.idempotencyKey === 'string') return [first];
40
- const explicitIndex = pending.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
41
- return pending.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : pending.length));
35
+ return { batch: selected, remaining: pendingQueue.filter((tx) => !selectedIds.has(tx.id)) };
42
36
  }
@@ -44,6 +44,16 @@ export async function handleFailure(
44
44
  transaction: QueuedMutation,
45
45
  error: Error,
46
46
  ): Promise<void> {
47
+ // The dispatch owner may lose its acknowledgement while an authoritative
48
+ // delta concurrently completes the same transaction. Completion is
49
+ // terminal: a late catch path must not turn that row back into `pending`
50
+ // and schedule a second seal after its durable sources were cleaned up.
51
+ if (
52
+ transaction.status === 'completed' ||
53
+ transaction.status === 'failed' ||
54
+ transaction.status === 'rolled_back' ||
55
+ transaction.status === 'awaiting_delta'
56
+ ) return;
47
57
  transaction.attempts++;
48
58
 
49
59
  // Check whether this is a permanent error that should not be retried.
@@ -43,9 +43,9 @@ export function createLocalMutationPort(emitter: OptimisticEmitter): LocalMutati
43
43
  const updates = new Map<string, OptimisticUpdateEntry>();
44
44
  return {
45
45
  updates,
46
- applyCreate: (model, transaction) => applyOptimisticCreate(updates, emitter, model, transaction),
47
- applyUpdate: (model, transaction) => applyOptimisticUpdate(updates, emitter, model, transaction),
48
- applyDelete: (model, transaction) => applyOptimisticDelete(updates, emitter, model, transaction),
46
+ applyCreate: (model, transaction) => { applyOptimisticCreate(updates, emitter, model, transaction); },
47
+ applyUpdate: (model, transaction) => { applyOptimisticUpdate(updates, emitter, model, transaction); },
48
+ applyDelete: (model, transaction) => { applyOptimisticDelete(updates, emitter, model, transaction); },
49
49
  rollback: (transaction, reason, error) => rollbackOptimistic(updates, emitter, transaction, reason, error),
50
50
  };
51
51
  }
@@ -12,6 +12,12 @@ export interface QueueCoalescingContext {
12
12
  }
13
13
 
14
14
  export function enqueueTransaction(ctx: QueueCoalescingContext, transaction: QueuedMutation): void {
15
+ // Only the pending state may cross into the execution owner. A late timer,
16
+ // reconnect callback, or stale staging callback must not resurrect a row
17
+ // that is already executing or terminal, and repeated triggers must not put
18
+ // the same source mutation into the queue twice.
19
+ if (transaction.status !== 'pending') return;
20
+ if (ctx.executionQueue.some((candidate) => candidate.id === transaction.id)) return;
15
21
  ctx.ensureDerivedFields(transaction);
16
22
  const modelKey = `${transaction.modelName}:${transaction.modelId}`;
17
23
  if (transaction.type === 'update' && transaction.attempts === 0 && !transaction.commitEnvelope) {