@abloatai/humans 0.60.0 → 0.62.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 (99) hide show
  1. package/dist/Ablo.d.ts +4 -4
  2. package/dist/Ablo.js +2 -1
  3. package/dist/client.d.ts +7 -12
  4. package/dist/humans.d.ts +2 -2
  5. package/dist/humans.js +2 -6
  6. package/dist/local/BaseSyncedStore.d.ts +2 -4
  7. package/dist/local/BaseSyncedStore.js +5 -8
  8. package/dist/local/Model.js +46 -56
  9. package/dist/local/NetworkMonitor.js +2 -0
  10. package/dist/local/RuntimeContext.js +2 -0
  11. package/dist/local/SyncClient.d.ts +8 -32
  12. package/dist/local/SyncClient.js +26 -99
  13. package/dist/local/client/clientPrelude.d.ts +2 -0
  14. package/dist/local/client/clientPrelude.js +13 -1
  15. package/dist/local/client/createInternalComponents.js +2 -0
  16. package/dist/local/client/createModelOperations.d.ts +5 -0
  17. package/dist/local/client/createModelOperations.js +7 -4
  18. package/dist/local/client/reactiveEngine.d.ts +2 -2
  19. package/dist/local/client/reactiveEngine.js +9 -11
  20. package/dist/local/fileUploads.d.ts +27 -0
  21. package/dist/local/fileUploads.js +55 -0
  22. package/dist/local/query/client.d.ts +3 -0
  23. package/dist/local/query/client.js +1 -1
  24. package/dist/local/stores/syncAction.d.ts +1 -1
  25. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  26. package/dist/local/sync/BootstrapFetcher.js +4 -4
  27. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  28. package/dist/local/sync/OnDemandLoader.js +1 -0
  29. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  30. package/dist/local/sync/SyncWebSocket.js +1 -36
  31. package/dist/local/sync/contextOnChange.js +1 -1
  32. package/dist/local/sync/createClaimStream.d.ts +9 -19
  33. package/dist/local/sync/createClaimStream.js +41 -56
  34. package/dist/local/sync/deltaPipeline.js +12 -6
  35. package/dist/local/sync/schemas.d.ts +2 -2
  36. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  37. package/dist/local/sync/socketEventWiring.js +1 -5
  38. package/dist/local/transactions/localMutation.js +3 -3
  39. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
  40. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  41. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  42. package/dist/local/transactions/mutations/commitPayload.d.ts +8 -1
  43. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  44. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  45. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  46. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  47. package/dist/local/transactions/mutations/localMutation.js +3 -3
  48. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  49. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  50. package/dist/presence/index.d.ts +15 -0
  51. package/dist/presence/index.js +49 -0
  52. package/dist/react/AbloProvider.d.ts +3 -3
  53. package/dist/react/AbloProvider.js +4 -3
  54. package/dist/react/useErrorListener.js +1 -1
  55. package/dist/react/useMutationFailureListener.js +1 -1
  56. package/dist/surface.d.ts +1 -1
  57. package/dist/surface.js +1 -0
  58. package/package.json +3 -4
  59. package/src/Ablo.ts +15 -6
  60. package/src/client.ts +8 -13
  61. package/src/humans.ts +3 -10
  62. package/src/local/BaseSyncedStore.ts +5 -11
  63. package/src/local/Model.ts +45 -55
  64. package/src/local/NetworkMonitor.ts +2 -0
  65. package/src/local/RuntimeContext.ts +2 -0
  66. package/src/local/SyncClient.ts +33 -127
  67. package/src/local/client/clientPrelude.ts +20 -0
  68. package/src/local/client/createInternalComponents.ts +2 -0
  69. package/src/local/client/createModelOperations.ts +17 -6
  70. package/src/local/client/reactiveEngine.ts +19 -14
  71. package/src/local/fileUploads.ts +97 -0
  72. package/src/local/query/client.ts +4 -0
  73. package/src/local/sync/BootstrapFetcher.ts +13 -4
  74. package/src/local/sync/OnDemandLoader.ts +3 -0
  75. package/src/local/sync/SyncWebSocket.ts +1 -42
  76. package/src/local/sync/contextOnChange.ts +1 -1
  77. package/src/local/sync/createClaimStream.ts +52 -72
  78. package/src/local/sync/deltaPipeline.ts +10 -6
  79. package/src/local/sync/socketEventWiring.ts +1 -8
  80. package/src/local/transactions/localMutation.ts +3 -3
  81. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  82. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  83. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  84. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  85. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  86. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  87. package/src/local/transactions/mutations/localMutation.ts +3 -3
  88. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  89. package/src/presence/index.ts +72 -0
  90. package/src/react/AbloProvider.tsx +14 -9
  91. package/src/react/useErrorListener.ts +1 -1
  92. package/src/react/useMutationFailureListener.ts +1 -1
  93. package/src/surface.ts +1 -0
  94. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  95. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  96. package/dist/presenceStream.d.ts +0 -69
  97. package/dist/presenceStream.js +0 -200
  98. package/src/local/transactions/mutations/pendingDrain.ts +0 -169
  99. package/src/presenceStream.ts +0 -279
@@ -5,10 +5,8 @@
5
5
  * everyone else's, and watch the wait queue when a claim is contended.
6
6
  *
7
7
  * The stream is built directly on the sync WebSocket and shares that one
8
- * connection. It learns about other participants' claims from the same
9
- * `presence_update` frames the {@link createPresenceStream} presence stream
10
- * consumes — the server piggybacks each participant's `activeClaims` on every
11
- * presence frame — and sends its own claims as `claim_begin` and
8
+ * connection. It learns about other sessions' claims from the normalized
9
+ * presence projection and sends its own claims as `claim_begin` and
12
10
  * `claim_abandon` frames.
13
11
  *
14
12
  * Wire frames:
@@ -16,8 +14,7 @@
16
14
  * entityId, description, field?, estimatedMs? }`.
17
15
  * • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
18
16
  * entityId? }`.
19
- * • Inbound, via presence — `event.activeClaims`, each stamped with
20
- * `declaredAt` and `expiresAt`.
17
+ * • Inbound, via presence — authoritative `claim` activities.
21
18
  * • Inbound `claim_rejected` — the server refused the claim, with conflict
22
19
  * metadata.
23
20
  */
@@ -40,10 +37,7 @@ function claimLabel(type, id, field) {
40
37
  * above a round trip, comfortably below the ttl/3 beat cadence.
41
38
  */
42
39
  const HEARTBEAT_ACK_TIMEOUT_MS = 10_000;
43
- export function createClaimStream(config, transport = null) {
44
- // Mutable: the host seeds the resolved identity via `setParticipant` once
45
- // it is known; the own-claim filter always reads the current value.
46
- let participantId = config.participantId;
40
+ export function createClaimStream(config, transport = null, presence = null) {
47
41
  const logger = config.logger ?? noopLogger;
48
42
  // ── State: others' open claims, keyed by claimId ───────────────
49
43
  const activeByClaimId = new Map();
@@ -113,48 +107,7 @@ export function createClaimStream(config, transport = null) {
113
107
  if (attached)
114
108
  return;
115
109
  attached = t;
116
- // (1) Inbound presence frames carry every participant's full
117
- // active-claim set. Prune previous claims by holder, then
118
- // re-add from the frame — the frame is authoritative for that
119
- // participant's open claims at that moment.
120
- unsubs.push(t.subscribe('presence_update', (event) => {
121
- if (!event.userId)
122
- return;
123
- if (event.userId === participantId)
124
- return;
125
- let mutated = false;
126
- if (event.kind === 'leave') {
127
- for (const [id, claim] of activeByClaimId) {
128
- if (claim.heldBy === event.userId) {
129
- activeByClaimId.delete(id);
130
- mutated = true;
131
- }
132
- }
133
- if (mutated)
134
- notifyListeners();
135
- return;
136
- }
137
- for (const [id, claim] of activeByClaimId) {
138
- if (claim.heldBy === event.userId) {
139
- activeByClaimId.delete(id);
140
- mutated = true;
141
- }
142
- }
143
- for (const claim of event.activeClaims ?? []) {
144
- // Terminal-status entries (committed / expired / canceled) are
145
- // one-shot "this claim ended" signals. The holder sweep above
146
- // already removed the prior active entry; skipping the re-add
147
- // drops it from `others`, which is what resolves a contender's
148
- // `settled()`. Absent status means active (wire back-compat).
149
- if (claim.status && claim.status !== 'active')
150
- continue;
151
- observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
152
- mutated = true;
153
- }
154
- if (mutated)
155
- notifyListeners();
156
- }));
157
- // (2) Server-side rejection frames.
110
+ // Server-side rejection frames.
158
111
  unsubs.push(t.subscribe('claim_rejected', (rejection) => {
159
112
  if (!rejection.claimId)
160
113
  return;
@@ -284,12 +237,47 @@ export function createClaimStream(config, transport = null) {
284
237
  }
285
238
  ownClaims.clear();
286
239
  for (const claimId of [...pendingHeartbeats.keys()]) {
287
- settleHeartbeat(claimId, ({ reject }) => reject(error));
240
+ settleHeartbeat(claimId, ({ reject }) => { reject(error); });
288
241
  }
289
242
  }));
290
243
  }
291
244
  if (transport)
292
245
  attach(transport);
246
+ const refreshPresenceClaims = () => {
247
+ if (presence === null)
248
+ return;
249
+ activeByClaimId.clear();
250
+ for (const session of presence.others) {
251
+ for (const activity of session.activities) {
252
+ if (activity.operation !== 'claim'
253
+ || activity.source !== 'claim'
254
+ || activity.target.id === undefined)
255
+ continue;
256
+ const claimId = activity.id.startsWith('claim:')
257
+ ? activity.id.slice('claim:'.length)
258
+ : activity.id;
259
+ observeForeignClaim(session.participant.id, {
260
+ claimId,
261
+ entityType: activity.target.model,
262
+ entityId: activity.target.id,
263
+ ...(activity.target.field !== undefined
264
+ ? { field: activity.target.field }
265
+ : {}),
266
+ ...(activity.target.fields !== undefined
267
+ ? { fields: activity.target.fields }
268
+ : {}),
269
+ description: 'claim',
270
+ declaredAt: Date.parse(activity.startedAt),
271
+ expiresAt: Date.parse(activity.expiresAt),
272
+ }, session.participant.kind);
273
+ }
274
+ }
275
+ notifyListeners();
276
+ };
277
+ if (presence !== null) {
278
+ refreshPresenceClaims();
279
+ unsubs.push(presence.onChange(refreshPresenceClaims));
280
+ }
293
281
  // ── Outbound ────────────────────────────────────────────────────
294
282
  function sendBegin(claimId, claim) {
295
283
  if (!attached?.isConnected())
@@ -475,9 +463,6 @@ export function createClaimStream(config, transport = null) {
475
463
  }, () => claimsSnapshot);
476
464
  },
477
465
  attach,
478
- setParticipant(participant) {
479
- participantId = participant.id;
480
- },
481
466
  dispose() {
482
467
  for (const off of unsubs)
483
468
  off();
@@ -67,7 +67,9 @@ export function deduplicateDeltas(deltas) {
67
67
  return deltas;
68
68
  let strictlyOrdered = true;
69
69
  for (let index = 1; index < deltas.length; index += 1) {
70
- if (deltas[index - 1].id >= deltas[index].id) {
70
+ const previous = deltas[index - 1];
71
+ const current = deltas[index];
72
+ if (!previous || !current || previous.id >= current.id) {
71
73
  strictlyOrdered = false;
72
74
  break;
73
75
  }
@@ -277,10 +279,13 @@ export function sliceApplyChanges(changes, maxDeltas) {
277
279
  while (index < changes.length) {
278
280
  // The indivisible unit starting here: one transaction's run, or a single
279
281
  // untransacted change.
280
- const transactionId = changes[index].transactionId;
282
+ const change = changes[index];
283
+ if (!change)
284
+ break;
285
+ const transactionId = change.transactionId;
281
286
  let end = index + 1;
282
287
  if (transactionId !== undefined) {
283
- while (end < changes.length && changes[end].transactionId === transactionId)
288
+ while (changes[end]?.transactionId === transactionId)
284
289
  end += 1;
285
290
  }
286
291
  const groupSize = end - index;
@@ -327,9 +332,11 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
327
332
  if (customDeltas.length > 0) {
328
333
  runInAction(() => {
329
334
  for (const delta of customDeltas) {
335
+ if (delta.data === null)
336
+ continue;
330
337
  const data = typeof delta.data === 'string'
331
338
  ? JSON.parse(delta.data)
332
- : (delta.data);
339
+ : delta.data;
333
340
  // 'C' (Covering) is treated identically to 'I' here — the client
334
341
  // gained permission to see the entity, so we insert it into the
335
342
  // pool as if newly created.
@@ -395,7 +402,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
395
402
  // slice. Slices stay the atomicity unit; the budget only decides where
396
403
  // the loop breathes.
397
404
  let sliceStartedAt = performance.now();
398
- for (let index = 0; index < slices.length; index++) {
405
+ for (const [index, slice] of slices.entries()) {
399
406
  if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
400
407
  pipelineDebug.phase = `apply-yield-${index}`;
401
408
  pipelineDebug.applyYields += 1;
@@ -404,7 +411,6 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
404
411
  }
405
412
  pipelineDebug.phase = `apply-slice-${index}`;
406
413
  pipelineDebug.applySlices += 1;
407
- const slice = slices[index];
408
414
  if (hasApplyPlugins) {
409
415
  runStage(stagePlugins, 'apply', { changes: slice });
410
416
  }
@@ -20,10 +20,10 @@ export declare const ServerDeltaSchema: z.ZodObject<{
20
20
  id: z.ZodNumber;
21
21
  data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
22
22
  actionType: z.ZodEnum<{
23
- A: "A";
24
23
  I: "I";
25
24
  U: "U";
26
25
  D: "D";
26
+ A: "A";
27
27
  V: "V";
28
28
  C: "C";
29
29
  G: "G";
@@ -44,10 +44,10 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
44
44
  id: z.ZodNumber;
45
45
  data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
46
46
  actionType: z.ZodEnum<{
47
- A: "A";
48
47
  I: "I";
49
48
  U: "U";
50
49
  D: "D";
50
+ A: "A";
51
51
  V: "V";
52
52
  C: "C";
53
53
  G: "G";
@@ -5,7 +5,7 @@ import type { InstanceCache } from '../InstanceCache.js';
5
5
  import type { ConnectionManager } from './ConnectionManager.js';
6
6
  import type { SubscriptionManager } from './SubscriptionManager.js';
7
7
  import type { SyncStatus } from '../storeContract.js';
8
- import type { BootstrapHint, BootstrapDataEvent, PresenceUpdate, SyncWebSocket, EventMap } from './SyncWebSocket.js';
8
+ import type { BootstrapHint, BootstrapDataEvent, SyncWebSocket, EventMap } from './SyncWebSocket.js';
9
9
  import type { SyncDelta } from './SyncWebSocket.js';
10
10
  export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>> {
11
11
  syncWebSocket: SyncWebSocket<TCollaboration>;
@@ -23,7 +23,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
23
23
  applyDeltaFrame(deltas: SyncDelta[]): void;
24
24
  handleBootstrapRequired(hint: BootstrapHint): void;
25
25
  handleBootstrapData(data: BootstrapDataEvent): void;
26
- handlePresenceUpdate(data: PresenceUpdate): void;
27
26
  performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
28
27
  handleTerminalSessionError(error: Error): void;
29
28
  nudgeReconnect(): void;
@@ -43,10 +43,6 @@ export function wireSocketEvents(deps) {
43
43
  const data = args[0];
44
44
  deps.handleBootstrapData(data);
45
45
  });
46
- const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
47
- const data = args[0];
48
- deps.handlePresenceUpdate(data);
49
- });
50
46
  // Error events
51
47
  const onError = deps.syncWebSocket.subscribe('error', (error) => {
52
48
  if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
@@ -126,5 +122,5 @@ export function wireSocketEvents(deps) {
126
122
  deps.runtime.logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
127
123
  deps.updateSyncStatus({ state: 'reconnecting' });
128
124
  });
129
- deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
125
+ deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
130
126
  }
@@ -16,9 +16,9 @@ export function createLocalMutationPort(emit) {
16
16
  };
17
17
  return {
18
18
  updates,
19
- applyCreate: (model, transaction) => track('optimistic:create', model, transaction),
20
- applyUpdate: (model, transaction) => track('optimistic:update', model, transaction),
21
- applyDelete: (model, transaction) => track('optimistic:delete', model, transaction),
19
+ applyCreate: (model, transaction) => { track('optimistic:create', model, transaction); },
20
+ applyUpdate: (model, transaction) => { track('optimistic:update', model, transaction); },
21
+ applyDelete: (model, transaction) => { track('optimistic:delete', model, transaction); },
22
22
  rollback: (transaction, reason, error) => {
23
23
  const optimistic = updates.get(transaction.id);
24
24
  if (!optimistic)
@@ -111,6 +111,7 @@ export declare class MutationQueue extends EventEmitter {
111
111
  private pendingPersistenceStages;
112
112
  private persistenceStageScheduled;
113
113
  private pendingDrainPromise;
114
+ private modelProcessingPromise;
114
115
  private executionQueue;
115
116
  private isProcessing;
116
117
  private processTimer?;
@@ -153,7 +154,6 @@ export declare class MutationQueue extends EventEmitter {
153
154
  private get failureHandlingContext();
154
155
  private get conflictResolutionContext();
155
156
  private get processingSchedulerContext();
156
- private get pendingDrainContext();
157
157
  private get durableCommitRestoreContext();
158
158
  private get persistenceContext();
159
159
  private nextCommitSequence;
@@ -225,7 +225,6 @@ export declare class MutationQueue extends EventEmitter {
225
225
  */
226
226
  private scheduleReplicationLagTimeout;
227
227
  private takeNextExecutionBatch;
228
- private takePendingDrainBatch;
229
228
  /**
230
229
  * Resolvers for per-transaction `confirmation` promises. Populated in
231
230
  * `attachConfirmation` at staging time, consumed by the constructor-time
@@ -477,7 +476,7 @@ export declare class MutationQueue extends EventEmitter {
477
476
  awaitingDeltaCount: number;
478
477
  awaitingDeltaTransactions: {
479
478
  id: string;
480
- type: "update" | "create" | "delete" | "archive" | "unarchive";
479
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
481
480
  modelName: string;
482
481
  modelId: string;
483
482
  syncIdNeeded: number | undefined;
@@ -486,13 +485,13 @@ export declare class MutationQueue extends EventEmitter {
486
485
  }[];
487
486
  pendingTransactions: {
488
487
  id: string;
489
- type: "update" | "create" | "delete" | "archive" | "unarchive";
488
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
490
489
  modelName: string;
491
490
  modelId: string;
492
491
  }[];
493
492
  executingTransactions: {
494
493
  id: string;
495
- type: "update" | "create" | "delete" | "archive" | "unarchive";
494
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
496
495
  modelName: string;
497
496
  modelId: string;
498
497
  }[];
@@ -34,9 +34,8 @@ import { enqueueTransaction } from './queueCoalescing.js';
34
34
  import { processBatch } from './batchProcessing.js';
35
35
  import { handleFailure } from './failureHandling.js';
36
36
  import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection } from './failurePolicy.js';
37
- import { takeNextExecutionBatch as selectExecutionBatch, takePendingDrainBatch as selectPendingDrainBatch } from './executionSelection.js';
37
+ import { takeNextExecutionBatch as selectExecutionBatch } from './executionSelection.js';
38
38
  import { scheduleProcessing as scheduleProcessingExternal } from './processingScheduler.js';
39
- import { drainPendingConfirmations, } from './pendingDrain.js';
40
39
  import { restoreDurableCommits as restoreDurableCommitsExternal } from './durableCommitRestore.js';
41
40
  export class MutationQueue extends EventEmitter {
42
41
  // Keep one hour of clock/network margin inside the server's 24-hour ledger.
@@ -70,6 +69,7 @@ export class MutationQueue extends EventEmitter {
70
69
  pendingPersistenceStages = [];
71
70
  persistenceStageScheduled = false;
72
71
  pendingDrainPromise = null;
72
+ modelProcessingPromise = null;
73
73
  executionQueue = [];
74
74
  isProcessing = false;
75
75
  processTimer;
@@ -303,32 +303,6 @@ export class MutationQueue extends EventEmitter {
303
303
  logger: this.runtime.logger,
304
304
  };
305
305
  }
306
- get pendingDrainContext() {
307
- return {
308
- runtime: this.runtime,
309
- config: { deltaConfirmationTimeout: this.config.deltaConfirmationTimeout },
310
- store: this.store,
311
- executionQueue: this.executionQueue,
312
- optimisticUpdates: this.localMutationPort.updates,
313
- assertDurableReplayOpen: () => { this.assertDurableReplayOpen(); },
314
- processCommitLane: () => this.processCommitLane(),
315
- takePendingDrainBatch: (pending) => this.takePendingDrainBatch(pending),
316
- ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope(batch),
317
- ensureDerivedFields: (transaction) => { this.ensureDerivedFields(transaction); },
318
- sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
319
- sealDurableCommit: (input) => this.sealDurableCommit(input),
320
- assertEnvelopeInsideReplayWindow: (envelope) => { this.assertEnvelopeInsideReplayWindow(envelope); },
321
- parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
322
- dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
323
- persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
324
- removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
325
- scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => { this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId); },
326
- scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => { this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs); },
327
- enqueue: (transaction) => { this.enqueue(transaction); },
328
- recentDeltaCorrelations: this.recentDeltaCorrelations,
329
- emit: (event, payload) => this.emit(event, payload),
330
- };
331
- }
332
306
  get durableCommitRestoreContext() {
333
307
  return {
334
308
  config: this.config,
@@ -738,9 +712,6 @@ export class MutationQueue extends EventEmitter {
738
712
  this.executionQueue = selected.remaining;
739
713
  return selected.batch;
740
714
  }
741
- takePendingDrainBatch(pending) {
742
- return selectPendingDrainBatch(pending, this.config.maxBatchSize);
743
- }
744
715
  /**
745
716
  * Resolvers for per-transaction `confirmation` promises. Populated in
746
717
  * `attachConfirmation` at staging time, consumed by the constructor-time
@@ -1004,25 +975,15 @@ export class MutationQueue extends EventEmitter {
1004
975
  return this.pendingDrainPromise;
1005
976
  }
1006
977
  async drainPendingInternal() {
1007
- // The normal batch scheduler and the explicit/reconnect drain are two
1008
- // ways to drive the same durable queue. They must never seal the same
1009
- // staged source records concurrently: the first seal consumes those
1010
- // records, so the second would correctly reject them as already claimed.
1011
- //
1012
- // `isProcessing` is acquired synchronously before either path awaits,
1013
- // making it the queue-wide execution lock. If the normal lane already
1014
- // owns it, that lane will finish the pending work; callers waiting on a
1015
- // specific confirmation remain attached to the exact transaction.
1016
- if (this.isProcessing)
1017
- return;
1018
- this.isProcessing = true;
1019
- try {
1020
- await drainPendingConfirmations(this.pendingDrainContext);
1021
- }
1022
- finally {
1023
- this.isProcessing = false;
1024
- if (this.executionQueue.length > 0)
1025
- this.scheduleProcessing(true);
978
+ // Explicit flushes and reconnects are merely another trigger for the one
979
+ // model-mutation execution lane. A second sealing implementation can race
980
+ // the scheduled lane, consume its journal sources, and later dispatch the
981
+ // same transaction again. Move every staged row to the owned queue, then
982
+ // drive the normal lane until the queue has handed off all current work.
983
+ this.commitCreatedTransactions();
984
+ await this.processCommitLane();
985
+ while (this.executionQueue.length > 0 || this.modelProcessingPromise) {
986
+ await this.processBatch();
1026
987
  }
1027
988
  }
1028
989
  async create(model, context, writeOptions, sourceMutationId) {
@@ -1055,7 +1016,20 @@ export class MutationQueue extends EventEmitter {
1055
1016
  scheduleProcessingExternal(this.processingSchedulerContext, immediate);
1056
1017
  }
1057
1018
  async processBatch() {
1058
- await processBatch(this.batchProcessingContext);
1019
+ if (this.modelProcessingPromise) {
1020
+ await this.modelProcessingPromise;
1021
+ if (this.executionQueue.length > 0)
1022
+ await this.processBatch();
1023
+ return;
1024
+ }
1025
+ const processing = processBatch(this.batchProcessingContext);
1026
+ const tracked = processing.finally(() => {
1027
+ if (this.modelProcessingPromise === tracked) {
1028
+ this.modelProcessingPromise = null;
1029
+ }
1030
+ });
1031
+ this.modelProcessingPromise = tracked;
1032
+ await tracked;
1059
1033
  }
1060
1034
  rememberDeltaCorrelation(correlationId, syncId) {
1061
1035
  // Refresh insertion order when a replay repeats the same correlation id.
@@ -64,16 +64,29 @@ export async function processBatch(ctx) {
64
64
  if (batchOps.length > 0) {
65
65
  let dispatchStarted = false;
66
66
  try {
67
- const durableEnvelope = await ctx.sealDurableCommit({
68
- idempotencyKey: commitIdempotencyKey,
69
- origin: 'model_batch',
70
- operations: batchOps.map(({ op }) => op),
71
- sourceMutationIds: ctx.sourceMutationIdsFor(batch),
72
- commitOptions: { reads: collectQueuedReads(batch) },
73
- createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
74
- sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
75
- sequence: batch[0]?.commitEnvelope?.sequence,
76
- });
67
+ let durableEnvelope = batch[0]?.durableEnvelope;
68
+ if (durableEnvelope) {
69
+ const mismatched = batch.some((transaction) => transaction.durableEnvelope?.idempotencyKey !==
70
+ durableEnvelope?.idempotencyKey);
71
+ if (mismatched || durableEnvelope.idempotencyKey !== commitIdempotencyKey) {
72
+ throw new Error('Cannot replay a model batch with inconsistent durable envelopes');
73
+ }
74
+ }
75
+ else {
76
+ durableEnvelope = await ctx.sealDurableCommit({
77
+ idempotencyKey: commitIdempotencyKey,
78
+ origin: 'model_batch',
79
+ operations: batchOps.map(({ op }) => op),
80
+ sourceMutationIds: ctx.sourceMutationIdsFor(batch),
81
+ commitOptions: { reads: collectQueuedReads(batch) },
82
+ createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
83
+ sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
84
+ sequence: batch[0]?.commitEnvelope?.sequence,
85
+ });
86
+ for (const transaction of batch) {
87
+ transaction.durableEnvelope = durableEnvelope;
88
+ }
89
+ }
77
90
  const operations = durableEnvelope.operations;
78
91
  // Capture lastSyncId from the server response for threshold-based
79
92
  // confirmation.
@@ -11,7 +11,7 @@
11
11
  import type { RuntimeContext } from '../../RuntimeContext.js';
12
12
  import { MutationOperationType } from '@abloatai/transaction/types';
13
13
  import type { MutationOptions, WriteOptions } from '../../interfaces/index.js';
14
- import type { CommitEnvelopeMember } from '@abloatai/transaction/commit';
14
+ import type { CommitEnvelopeMember, DurableCommitEnvelope } from '@abloatai/transaction/commit';
15
15
  export interface UserContext {
16
16
  userId: string;
17
17
  organizationId: string;
@@ -69,6 +69,13 @@ export interface QueuedMutation {
69
69
  * re-batching its operations under a fresh key.
70
70
  */
71
71
  commitEnvelope?: CommitEnvelopeMember;
72
+ /**
73
+ * The exact durable request produced by the first successful local seal.
74
+ * Runtime retries dispatch this object directly. Asking the outbox to seal
75
+ * again is both unnecessary and unsafe after a concurrent authoritative
76
+ * completion has begun cleaning up the stored envelope.
77
+ */
78
+ durableEnvelope?: DurableCommitEnvelope;
72
79
  /** Pending-mutation journal entries atomically consumed by this envelope. */
73
80
  sourceMutationIds?: string[];
74
81
  /** Completed locally without a server operation; no sync echo will arrive. */
@@ -95,7 +95,9 @@ export function dispatchCommitBounded(ctx, ...args) {
95
95
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
96
96
  return dispatched;
97
97
  return new Promise((resolve, reject) => {
98
- const timer = setTimeout(() => reject(new AbloConnectionError('The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.', { code: 'commit_no_result' })), timeoutMs);
98
+ const timer = setTimeout(() => {
99
+ reject(new AbloConnectionError('The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.', { code: 'commit_no_result' }));
100
+ }, timeoutMs);
99
101
  dispatched.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error instanceof Error ? error : new Error(String(error))); });
100
102
  });
101
103
  }
@@ -3,4 +3,3 @@ export declare function takeNextExecutionBatch(executionQueue: QueuedMutation[],
3
3
  batch: QueuedMutation[];
4
4
  remaining: QueuedMutation[];
5
5
  };
6
- export declare function takePendingDrainBatch(pending: QueuedMutation[], maxBatchSize: number): QueuedMutation[];
@@ -1,6 +1,10 @@
1
1
  export function takeNextExecutionBatch(executionQueue, maxBatchSize) {
2
+ // Cancellation, delta confirmation, and failure settlement can all make a
3
+ // queued reference terminal before its scheduler callback runs. Terminal or
4
+ // currently executing rows have no authority to cross the dispatch boundary.
5
+ const pendingQueue = executionQueue.filter((tx) => tx.status === 'pending');
2
6
  const retryGroups = new Map();
3
- for (const tx of executionQueue) {
7
+ for (const tx of pendingQueue) {
4
8
  const envelope = tx.commitEnvelope;
5
9
  if (!envelope)
6
10
  continue;
@@ -13,30 +17,18 @@ export function takeNextExecutionBatch(executionQueue, maxBatchSize) {
13
17
  const expectedCount = members[0]?.commitEnvelope?.operationCount;
14
18
  if (expectedCount === undefined || members.length !== expectedCount)
15
19
  continue;
16
- const remaining = executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
20
+ const remaining = pendingQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
17
21
  members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) - (b.commitEnvelope?.operationIndex ?? 0));
18
22
  return { batch: members, remaining };
19
23
  }
20
- const fresh = executionQueue.filter((tx) => !tx.commitEnvelope);
24
+ const fresh = pendingQueue.filter((tx) => !tx.commitEnvelope);
21
25
  const firstFresh = fresh[0];
22
26
  if (!firstFresh)
23
- return { batch: [], remaining: executionQueue };
27
+ return { batch: [], remaining: pendingQueue };
24
28
  const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
25
29
  const selected = explicitIndex === 0
26
30
  ? [firstFresh]
27
31
  : fresh.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
28
32
  const selectedIds = new Set(selected.map((tx) => tx.id));
29
- return { batch: selected, remaining: executionQueue.filter((tx) => !selectedIds.has(tx.id)) };
30
- }
31
- export function takePendingDrainBatch(pending, maxBatchSize) {
32
- const first = pending[0];
33
- if (!first)
34
- return [];
35
- const envelope = first.commitEnvelope;
36
- if (envelope)
37
- return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
38
- if (typeof first.writeOptions?.idempotencyKey === 'string')
39
- 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));
33
+ return { batch: selected, remaining: pendingQueue.filter((tx) => !selectedIds.has(tx.id)) };
42
34
  }
@@ -13,6 +13,15 @@ export function transientRetryDelayMs(error, attempt, retryBackoff) {
13
13
  return Math.floor(Math.random() * ceiling);
14
14
  }
15
15
  export async function handleFailure(ctx, transaction, error) {
16
+ // The dispatch owner may lose its acknowledgement while an authoritative
17
+ // delta concurrently completes the same transaction. Completion is
18
+ // terminal: a late catch path must not turn that row back into `pending`
19
+ // and schedule a second seal after its durable sources were cleaned up.
20
+ if (transaction.status === 'completed' ||
21
+ transaction.status === 'failed' ||
22
+ transaction.status === 'rolled_back' ||
23
+ transaction.status === 'awaiting_delta')
24
+ return;
16
25
  transaction.attempts++;
17
26
  // Check whether this is a permanent error that should not be retried.
18
27
  if (ctx.isPermanentError(error)) {
@@ -12,9 +12,9 @@ export function createLocalMutationPort(emitter) {
12
12
  const updates = new Map();
13
13
  return {
14
14
  updates,
15
- applyCreate: (model, transaction) => applyOptimisticCreate(updates, emitter, model, transaction),
16
- applyUpdate: (model, transaction) => applyOptimisticUpdate(updates, emitter, model, transaction),
17
- applyDelete: (model, transaction) => applyOptimisticDelete(updates, emitter, model, transaction),
15
+ applyCreate: (model, transaction) => { applyOptimisticCreate(updates, emitter, model, transaction); },
16
+ applyUpdate: (model, transaction) => { applyOptimisticUpdate(updates, emitter, model, transaction); },
17
+ applyDelete: (model, transaction) => { applyOptimisticDelete(updates, emitter, model, transaction); },
18
18
  rollback: (transaction, reason, error) => rollbackOptimistic(updates, emitter, transaction, reason, error),
19
19
  };
20
20
  }
@@ -1,6 +1,14 @@
1
1
  import { mergeUpdateData } from './coalesceRules.js';
2
2
  import { hasCommitCoalescingBarrier } from './commitPayload.js';
3
3
  export function enqueueTransaction(ctx, transaction) {
4
+ // Only the pending state may cross into the execution owner. A late timer,
5
+ // reconnect callback, or stale staging callback must not resurrect a row
6
+ // that is already executing or terminal, and repeated triggers must not put
7
+ // the same source mutation into the queue twice.
8
+ if (transaction.status !== 'pending')
9
+ return;
10
+ if (ctx.executionQueue.some((candidate) => candidate.id === transaction.id))
11
+ return;
4
12
  ctx.ensureDerivedFields(transaction);
5
13
  const modelKey = `${transaction.modelName}:${transaction.modelId}`;
6
14
  if (transaction.type === 'update' && transaction.attempts === 0 && !transaction.commitEnvelope) {