@abloatai/humans 0.60.0 → 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 (52) hide show
  1. package/dist/local/BaseSyncedStore.js +5 -5
  2. package/dist/local/Model.js +46 -56
  3. package/dist/local/NetworkMonitor.js +2 -0
  4. package/dist/local/RuntimeContext.js +2 -0
  5. package/dist/local/SyncClient.d.ts +5 -29
  6. package/dist/local/SyncClient.js +26 -99
  7. package/dist/local/client/createModelOperations.js +6 -4
  8. package/dist/local/fileUploads.d.ts +27 -0
  9. package/dist/local/fileUploads.js +55 -0
  10. package/dist/local/stores/syncAction.d.ts +1 -1
  11. package/dist/local/sync/contextOnChange.js +1 -1
  12. package/dist/local/sync/createClaimStream.js +1 -1
  13. package/dist/local/sync/deltaPipeline.js +12 -6
  14. package/dist/local/sync/schemas.d.ts +2 -2
  15. package/dist/local/transactions/localMutation.js +3 -3
  16. package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -2
  17. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  18. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  19. package/dist/local/transactions/mutations/commitPayload.d.ts +8 -1
  20. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  21. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  22. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  23. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  24. package/dist/local/transactions/mutations/localMutation.js +3 -3
  25. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  26. package/dist/react/useErrorListener.js +1 -1
  27. package/dist/react/useMutationFailureListener.js +1 -1
  28. package/package.json +3 -4
  29. package/src/local/BaseSyncedStore.ts +5 -5
  30. package/src/local/Model.ts +45 -55
  31. package/src/local/NetworkMonitor.ts +2 -0
  32. package/src/local/RuntimeContext.ts +2 -0
  33. package/src/local/SyncClient.ts +33 -127
  34. package/src/local/client/createModelOperations.ts +9 -6
  35. package/src/local/fileUploads.ts +97 -0
  36. package/src/local/sync/contextOnChange.ts +1 -1
  37. package/src/local/sync/createClaimStream.ts +1 -1
  38. package/src/local/sync/deltaPipeline.ts +10 -6
  39. package/src/local/transactions/localMutation.ts +3 -3
  40. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  41. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  42. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  43. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  44. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  45. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  46. package/src/local/transactions/mutations/localMutation.ts +3 -3
  47. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  48. package/src/react/useErrorListener.ts +1 -1
  49. package/src/react/useMutationFailureListener.ts +1 -1
  50. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  51. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  52. package/src/local/transactions/mutations/pendingDrain.ts +0 -169
@@ -18,7 +18,7 @@ import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
18
18
  import { LoadStrategy } from '@abloatai/transaction/types';
19
19
  import { globalRuntime } from './context.js';
20
20
  import type { RuntimeContext } from './RuntimeContext.js';
21
- import { AbloAuthenticationError, AbloError, AbloValidationError } from '@abloatai/transaction/errors';
21
+ import { AbloError, AbloValidationError } from '@abloatai/transaction/errors';
22
22
  import { EventEmitter } from 'events';
23
23
  import { NetworkMonitor } from './NetworkMonitor.js';
24
24
  import {
@@ -51,9 +51,16 @@ import {
51
51
  type SyncState,
52
52
  } from './syncClientTypes.js';
53
53
  import type { BootstrapSnapshot } from './syncClientTypes.js';
54
+ import {
55
+ batchUploadFiles,
56
+ uploadFile,
57
+ type BatchFileUploadOptions,
58
+ type FileUploadContext,
59
+ type FileUploadOptions,
60
+ } from './fileUploads.js';
54
61
 
55
62
  export type { BootstrapSnapshot, RehydrationStats } from './syncClientTypes.js';
56
-
63
+ const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
57
64
  export class SyncClient extends EventEmitter {
58
65
  private objectPool: InstanceCache;
59
66
  private database: Database;
@@ -1057,7 +1064,7 @@ export class SyncClient extends EventEmitter {
1057
1064
  if (capturedChanges === undefined) return;
1058
1065
 
1059
1066
  this.objectPool.upsert(model, ModelScope.live);
1060
- this.stageMutation('update', model, capturedChanges);
1067
+ void this.stageMutation('update', model, capturedChanges);
1061
1068
  this.notifyObservers({
1062
1069
  type: 'update',
1063
1070
  modelType: model.getModelName(),
@@ -1080,128 +1087,30 @@ export class SyncClient extends EventEmitter {
1080
1087
  return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
1081
1088
  }
1082
1089
 
1083
- /**
1084
- * Upload a file and create its attachment record. The upload runs through
1085
- * the {@link MutationQueue}, and a model is built from the server's
1086
- * response and added to the pool.
1087
- */
1088
- async uploadFile(
1089
- file: File,
1090
- options: {
1091
- id: string;
1092
- attachableType: string;
1093
- attachableId: string;
1094
- metadata?: Record<string, unknown>;
1095
- }
1096
- ): Promise<Model | null> {
1097
- if (!this.userId || !this.organizationId) {
1098
- throw new AbloAuthenticationError('Authentication required for file uploads', {
1099
- code: 'file_upload_auth_required',
1100
- });
1101
- }
1102
-
1103
- try {
1104
- // Use MutationQueue to handle the upload mutation
1105
- const result = await this.mutationQueue.uploadAttachment(
1106
- file,
1107
- {
1108
- id: options.id,
1109
- attachableType: options.attachableType,
1110
- attachableId: options.attachableId,
1111
- metadata: options.metadata,
1112
- },
1113
- {
1114
- userId: this.userId,
1115
- organizationId: this.organizationId,
1116
- }
1117
- );
1118
-
1119
- if (result) {
1120
- // Create model from response using ModelRegistry (generic — no concrete class import)
1121
- const model = this.objectPool.createFromData({
1122
- id: options.id,
1123
- ...result,
1124
- });
1125
-
1126
- if (model) {
1127
- this.objectPool.add(model, ModelScope.live);
1128
- this.notifyObservers({
1129
- type: 'create',
1130
- modelType: model.getModelName(),
1131
- model,
1132
- });
1133
- return model;
1134
- }
1135
- }
1136
-
1137
- return null;
1138
- } catch (error) {
1139
- this.runtime.observability.captureMutationFailure({
1140
- context: 'file-upload',
1141
- error: error instanceof Error ? error : new Error(String(error)),
1142
- });
1143
- throw error;
1144
- }
1145
- }
1146
-
1147
- /**
1148
- * Batch upload files — single GraphQL call + parallel S3 PUTs.
1149
- *
1150
- * Returns the raw `Model[]` built by the object pool (typename is
1151
- * determined by the payload the server returns — currently always
1152
- * `Attachment`). The SDK has no knowledge of app-specific model classes,
1153
- * so it cannot honestly claim a narrower return type; consumers that
1154
- * need an `Attachment[]` project through their own typed accessor
1155
- * (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
1156
- * after the upload resolves.
1157
- */
1158
- async batchUploadFiles(
1159
- files: File[],
1160
- options: {
1161
- ids: string[];
1162
- attachableType: string;
1163
- attachableId: string;
1164
- metadata?: Record<string, unknown>;
1165
- }
1166
- ): Promise<Model[]> {
1167
- if (!this.userId || !this.organizationId) {
1168
- throw new AbloAuthenticationError('Authentication required for file uploads', {
1169
- code: 'file_upload_auth_required',
1170
- });
1171
- }
1172
-
1173
- const items = options.ids.map((id) => ({
1174
- id,
1175
- attachableType: options.attachableType,
1176
- attachableId: options.attachableId,
1177
- metadata: options.metadata,
1178
- }));
1179
-
1180
- const results = await this.mutationQueue.batchUploadAttachments(files, items, {
1090
+ private fileUploadContext(): FileUploadContext {
1091
+ return {
1181
1092
  userId: this.userId,
1182
1093
  organizationId: this.organizationId,
1183
- });
1094
+ mutationQueue: this.mutationQueue,
1095
+ objectPool: this.objectPool,
1096
+ observability: this.runtime.observability,
1097
+ notifyCreated: (model) => {
1098
+ this.notifyObservers({ type: 'create', modelType: model.getModelName(), model });
1099
+ },
1100
+ };
1101
+ }
1184
1102
 
1185
- const models: Model[] = [];
1186
- for (const result of results) {
1187
- const model = this.objectPool.createFromData({ ...result });
1188
- if (model) {
1189
- this.objectPool.add(model, ModelScope.live);
1190
- this.notifyObservers({
1191
- type: 'create',
1192
- modelType: model.getModelName(),
1193
- model,
1194
- });
1195
- models.push(model);
1196
- }
1197
- }
1103
+ uploadFile(file: File, options: FileUploadOptions): Promise<Model | null> {
1104
+ return uploadFile(this.fileUploadContext(), file, options);
1105
+ }
1198
1106
 
1199
- return models;
1107
+ batchUploadFiles(files: File[], options: BatchFileUploadOptions): Promise<Model[]> {
1108
+ return batchUploadFiles(this.fileUploadContext(), files, options);
1200
1109
  }
1201
1110
 
1202
1111
  /** Archive model (ARCHIVE) - works offline */
1203
- archive(model: Model): void {
1204
- this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
1112
+ archive(model: Model): Promise<void> | undefined {
1113
+ return this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
1205
1114
  }
1206
1115
 
1207
1116
  /**
@@ -1243,7 +1152,7 @@ export class SyncClient extends EventEmitter {
1243
1152
  // Most internal callers intentionally use fire-and-forget writes. Observe
1244
1153
  // their rejection without replacing the exact promise returned to model
1245
1154
  // operations that need authoritative per-transaction confirmation.
1246
- void confirmation.catch(() => undefined);
1155
+ void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
1247
1156
  const pending = staging.then(() => undefined).catch((error: Error) => {
1248
1157
  this.runtime.observability.captureMutationFailure({
1249
1158
  context: `stage-mutation-${type}`,
@@ -1495,14 +1404,11 @@ export class SyncClient extends EventEmitter {
1495
1404
  markConnected(): void {
1496
1405
  this.setConnectionState('connected');
1497
1406
  // Browser online state may have marked the client connected before the
1498
- // WebSocket itself was ready. Always kick both durable lanes on the real
1499
- // socket event, even when the high-level state did not change.
1500
- void this.drainPendingConfirmations().catch((error: unknown) => {
1501
- this.runtime.observability.captureMutationFailure({
1502
- context: 'restore-commit-outbox',
1503
- error: error instanceof Error ? error : new Error(String(error)),
1504
- });
1505
- });
1407
+ // WebSocket itself was ready. Kick the durable lanes through the staging
1408
+ // barrier: a model mutation enters the in-memory store before its journal
1409
+ // row finishes saving, so a direct reconnect drain can otherwise try to
1410
+ // seal a source record that does not exist yet. The pending drain also
1411
+ // starts the atomic commit lane, so one ordered entry point covers both.
1506
1412
  void this.processPendingMutations();
1507
1413
  }
1508
1414
 
@@ -149,6 +149,9 @@ import {
149
149
  type ReadSetContext,
150
150
  } from '@abloatai/transaction/internal/read-set';
151
151
 
152
+ const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
153
+ const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
154
+
152
155
  export interface ModelClientMeta {
153
156
  readonly key: string;
154
157
  readonly typename: string;
@@ -490,7 +493,7 @@ export function createModelOperations<T, C>(
490
493
  // await does not create an unhandled-rejection process error. Returning
491
494
  // the original promise preserves normal rejection for callers that do
492
495
  // await or attach their own catch handler.
493
- void confirmation.catch(() => undefined);
496
+ void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
494
497
  return confirmation;
495
498
  };
496
499
  };
@@ -660,9 +663,9 @@ export function createModelOperations<T, C>(
660
663
  // This runs after authoritative confirmation. A best-effort abandon frame
661
664
  // cannot turn a committed write into an apparent failure; the server has
662
665
  // already fulfilled the participant's claims as part of that commit.
663
- await releaseClaimsForEntity(entityId).catch(() => undefined);
666
+ await releaseClaimsForEntity(entityId).catch(ignoreBestEffortClaimReleaseFailure);
664
667
  if (explicit && !explicitWasLocal) {
665
- await explicit.release?.().catch(() => undefined);
668
+ await explicit.release?.().catch(ignoreBestEffortClaimReleaseFailure);
666
669
  }
667
670
  };
668
671
 
@@ -1376,7 +1379,7 @@ export function createModelOperations<T, C>(
1376
1379
  await waitForMutation(model, confirmation);
1377
1380
  return modelAsRow<T>(model);
1378
1381
  } finally {
1379
- await autoLease?.release?.().catch(() => {});
1382
+ await autoLease?.release?.().catch(ignoreBestEffortClaimReleaseFailure);
1380
1383
  }
1381
1384
  });
1382
1385
 
@@ -1501,7 +1504,7 @@ export function createModelOperations<T, C>(
1501
1504
  const confirmation = syncClient.update(
1502
1505
  model,
1503
1506
  effective,
1504
- patch as Record<string, unknown>,
1507
+ patch,
1505
1508
  );
1506
1509
  await waitForMutation(model, confirmation);
1507
1510
  return modelAsRow<T>(model);
@@ -1562,7 +1565,7 @@ export function createModelOperations<T, C>(
1562
1565
  const confirmation = syncClient.update(
1563
1566
  model,
1564
1567
  effective,
1565
- params.data as Record<string, unknown>,
1568
+ params.data,
1566
1569
  );
1567
1570
  await waitForMutation(model, confirmation);
1568
1571
  const updated = modelAsRow<T>(model);
@@ -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
+ }
@@ -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
  );
@@ -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 {
@@ -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))); },