@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
@@ -36,7 +36,6 @@ export type {
36
36
  SyncCapabilities,
37
37
  BootstrapHint,
38
38
  BootstrapDataEvent,
39
- PresenceUpdate,
40
39
  CoreSyncEventMap,
41
40
  DefaultCollaborationEvents,
42
41
  EventMap,
@@ -129,14 +128,10 @@ export class SyncWebSocket<
129
128
 
130
129
  /**
131
130
  * The open ritual, run by the transport between its `connected` emit and the
132
- * heartbeat start: announce presence, tell the server where we left off,
131
+ * heartbeat start: tell the server where we left off,
133
132
  * request the deltas we missed, and start the catch-up poll.
134
133
  */
135
134
  protected override onOpened(): void {
136
- // Send presence update with timezone (server sets presence to "online" on connect,
137
- // this improves localTime accuracy by providing the user's actual timezone)
138
- this.sendPresenceUpdate('online');
139
-
140
135
  // Immediately request incremental sync based on our stored cursor.
141
136
  // `requestIncrementalSync` is async — a bare call inside try/catch is a
142
137
  // rejection hole (the catch never sees it); route failures through
@@ -323,42 +318,6 @@ export class SyncWebSocket<
323
318
  this.sendAck(syncId);
324
319
  }
325
320
 
326
- /**
327
- * Send presence update to server.
328
- * Use this for:
329
- * - Updating timezone (improves localTime accuracy shown to other users)
330
- * - Manual status changes (away, custom status)
331
- *
332
- * Note: "online" status is automatically set by server on WebSocket connect,
333
- * and "offline" is set on disconnect. You don't need to call this for basic online/offline.
334
- *
335
- * @param status - "online", "away", or custom status string
336
- * @param customStatus - Optional custom status message
337
- */
338
- sendPresenceUpdate(
339
- status: 'online' | 'away' | 'offline' = 'online',
340
- customStatus?: string
341
- ): void {
342
- if (!this.isConnected()) return;
343
-
344
- const timezone = (() => {
345
- try {
346
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
347
- } catch {
348
- return 'UTC';
349
- }
350
- })();
351
-
352
- this.send({
353
- type: 'presence_update',
354
- payload: {
355
- status,
356
- timezone,
357
- ...(customStatus ? { customStatus } : {}),
358
- },
359
- });
360
- }
361
-
362
321
  /**
363
322
  * Stop the periodic catchup interval
364
323
  */
@@ -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);
@@ -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,16 +14,15 @@
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
  */
24
21
 
25
22
  import type {
26
23
  WsTransport,
27
- PresenceUpdate,
28
24
  } from '@abloatai/transaction/transport/websocket';
25
+ import type { PresenceSession } from '@abloatai/transaction/presence';
29
26
  import type {
30
27
  ClaimOptions,
31
28
  ClaimTarget,
@@ -79,12 +76,15 @@ function claimLabel(type: string, id: string, field?: string): string {
79
76
  }
80
77
 
81
78
  export interface ClaimStreamConfig {
82
- /** Identity used to filter our own active claims out of `others`. */
83
- participantId: string;
84
79
  /** Where the coordination trace is logged. Defaults to silent. */
85
80
  logger?: Logger;
86
81
  }
87
82
 
83
+ export interface ClaimPresenceSource {
84
+ readonly others: readonly PresenceSession[];
85
+ onChange(listener: () => void): () => void;
86
+ }
87
+
88
88
  /**
89
89
  * How long a heartbeat waits for its `claim_heartbeat_ack` before giving up
90
90
  * as transient (the auto-heartbeat loop's next tick retries). Comfortably
@@ -107,14 +107,6 @@ export interface AttachableClaimStream extends ClaimStream {
107
107
  */
108
108
  claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
109
109
  attach(transport: ClaimTransport): void;
110
- /**
111
- * Seeds the participant identity once the host resolves it. The stream can
112
- * be built before identity is known — a hosted client learns who it is
113
- * from its credential's scope during connect — and until then the
114
- * construction-time id (possibly empty) would let the participant's own
115
- * claims into `others`. Idempotent; later frames filter on the new id.
116
- */
117
- setParticipant(participant: { id: string }): void;
118
110
  dispose(): void;
119
111
  }
120
112
 
@@ -144,10 +136,8 @@ interface OwnGrant {
144
136
  export function createClaimStream(
145
137
  config: ClaimStreamConfig,
146
138
  transport: ClaimTransport | null = null,
139
+ presence: ClaimPresenceSource | null = null,
147
140
  ): AttachableClaimStream {
148
- // Mutable: the host seeds the resolved identity via `setParticipant` once
149
- // it is known; the own-claim filter always reads the current value.
150
- let participantId = config.participantId;
151
141
  const logger = config.logger ?? noopLogger;
152
142
 
153
143
  // ── State: others' open claims, keyed by claimId ───────────────
@@ -247,54 +237,7 @@ export function createClaimStream(
247
237
  if (attached) return;
248
238
  attached = t;
249
239
 
250
- // (1) Inbound presence frames carry every participant's full
251
- // active-claim set. Prune previous claims by holder, then
252
- // re-add from the frame — the frame is authoritative for that
253
- // participant's open claims at that moment.
254
- unsubs.push(
255
- t.subscribe('presence_update', (event: PresenceUpdate) => {
256
- if (!event.userId) return;
257
- if (event.userId === participantId) return;
258
-
259
- let mutated = false;
260
-
261
- if (event.kind === 'leave') {
262
- for (const [id, claim] of activeByClaimId) {
263
- if (claim.heldBy === event.userId) {
264
- activeByClaimId.delete(id);
265
- mutated = true;
266
- }
267
- }
268
- if (mutated) notifyListeners();
269
- return;
270
- }
271
-
272
- for (const [id, claim] of activeByClaimId) {
273
- if (claim.heldBy === event.userId) {
274
- activeByClaimId.delete(id);
275
- mutated = true;
276
- }
277
- }
278
- for (const claim of event.activeClaims ?? []) {
279
- // Terminal-status entries (committed / expired / canceled) are
280
- // one-shot "this claim ended" signals. The holder sweep above
281
- // already removed the prior active entry; skipping the re-add
282
- // drops it from `others`, which is what resolves a contender's
283
- // `settled()`. Absent status means active (wire back-compat).
284
- if (claim.status && claim.status !== 'active') continue;
285
- observeForeignClaim(
286
- event.userId,
287
- claim,
288
- event.participantKind,
289
- event.isAgent,
290
- );
291
- mutated = true;
292
- }
293
- if (mutated) notifyListeners();
294
- }),
295
- );
296
-
297
- // (2) Server-side rejection frames.
240
+ // Server-side rejection frames.
298
241
  unsubs.push(
299
242
  t.subscribe('claim_rejected', (rejection) => {
300
243
  if (!rejection.claimId) return;
@@ -468,7 +411,7 @@ export function createClaimStream(
468
411
  }
469
412
  ownClaims.clear();
470
413
  for (const claimId of [...pendingHeartbeats.keys()]) {
471
- settleHeartbeat(claimId, ({ reject }) => reject(error));
414
+ settleHeartbeat(claimId, ({ reject }) => { reject(error); });
472
415
  }
473
416
  }),
474
417
  );
@@ -476,6 +419,46 @@ export function createClaimStream(
476
419
 
477
420
  if (transport) attach(transport);
478
421
 
422
+ const refreshPresenceClaims = (): void => {
423
+ if (presence === null) return;
424
+ activeByClaimId.clear();
425
+ for (const session of presence.others) {
426
+ for (const activity of session.activities) {
427
+ if (
428
+ activity.operation !== 'claim'
429
+ || activity.source !== 'claim'
430
+ || activity.target.id === undefined
431
+ ) continue;
432
+ const claimId = activity.id.startsWith('claim:')
433
+ ? activity.id.slice('claim:'.length)
434
+ : activity.id;
435
+ observeForeignClaim(
436
+ session.participant.id,
437
+ {
438
+ claimId,
439
+ entityType: activity.target.model,
440
+ entityId: activity.target.id,
441
+ ...(activity.target.field !== undefined
442
+ ? { field: activity.target.field }
443
+ : {}),
444
+ ...(activity.target.fields !== undefined
445
+ ? { fields: activity.target.fields }
446
+ : {}),
447
+ description: 'claim',
448
+ declaredAt: Date.parse(activity.startedAt),
449
+ expiresAt: Date.parse(activity.expiresAt),
450
+ },
451
+ session.participant.kind,
452
+ );
453
+ }
454
+ }
455
+ notifyListeners();
456
+ };
457
+ if (presence !== null) {
458
+ refreshPresenceClaims();
459
+ unsubs.push(presence.onChange(refreshPresenceClaims));
460
+ }
461
+
479
462
  // ── Outbound ────────────────────────────────────────────────────
480
463
  function sendBegin(claimId: string, claim: OwnClaim): void {
481
464
  if (!attached?.isConnected()) return;
@@ -705,9 +688,6 @@ export function createClaimStream(
705
688
  );
706
689
  },
707
690
  attach,
708
- setParticipant(participant: { id: string }): void {
709
- participantId = participant.id;
710
- },
711
691
  dispose(): void {
712
692
  for (const off of unsubs) off();
713
693
  unsubs.length = 0;
@@ -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 {
@@ -9,7 +9,6 @@ import type { SyncStatus } from '../storeContract.js';
9
9
  import type {
10
10
  BootstrapHint,
11
11
  BootstrapDataEvent,
12
- PresenceUpdate,
13
12
  SyncWebSocket,
14
13
  EventMap,
15
14
  } from './SyncWebSocket.js';
@@ -31,7 +30,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
31
30
  applyDeltaFrame(deltas: SyncDelta[]): void;
32
31
  handleBootstrapRequired(hint: BootstrapHint): void;
33
32
  handleBootstrapData(data: BootstrapDataEvent): void;
34
- handlePresenceUpdate(data: PresenceUpdate): void;
35
33
  performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
36
34
  handleTerminalSessionError(error: Error): void;
37
35
  nudgeReconnect(): void;
@@ -93,11 +91,6 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
93
91
  deps.handleBootstrapData(data);
94
92
  });
95
93
 
96
- const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
97
- const data = args[0];
98
- deps.handlePresenceUpdate(data);
99
- });
100
-
101
94
  // Error events
102
95
  const onError = deps.syncWebSocket.subscribe('error', (error: Error) => {
103
96
  if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
@@ -189,7 +182,7 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
189
182
  deps.disposers.push(
190
183
  onConnected, onDisconnected, onReconnecting,
191
184
  onDelta, onDeltaBatch, onBootstrapRequired,
192
- onBootstrapData, onPresenceUpdate,
185
+ onBootstrapData,
193
186
  onError, onSessionError, onHandshakeFailed, onReconnectFailed,
194
187
  () => { deps.areaOfInterest.dispose(); },
195
188
  );
@@ -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
  }