@abloatai/humans 0.59.0 → 0.59.2

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 (41) hide show
  1. package/dist/humans.d.ts +1 -1
  2. package/dist/local/BaseSyncedStore.d.ts +1 -3
  3. package/dist/local/BaseSyncedStore.js +2 -7
  4. package/dist/local/Database.d.ts +2 -2
  5. package/dist/local/LazyReferenceCollection.d.ts +1 -1
  6. package/dist/local/Model.js +2 -2
  7. package/dist/local/SyncClient.d.ts +4 -4
  8. package/dist/local/SyncClient.js +20 -1
  9. package/dist/local/interfaces/index.d.ts +1 -1
  10. package/dist/local/stores/syncAction.d.ts +4 -4
  11. package/dist/local/sync/deltaPipeline.d.ts +11 -3
  12. package/dist/local/sync/deltaPipeline.js +27 -80
  13. package/dist/local/sync/schemas.d.ts +10 -10
  14. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
  15. package/dist/local/transactions/mutations/batchProcessing.js +5 -1
  16. package/dist/local/transactions/mutations/commitPayload.d.ts +3 -1
  17. package/dist/local/transactions/mutations/commitPayload.js +14 -0
  18. package/dist/local/transactions/mutations/failureHandling.js +9 -81
  19. package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
  20. package/dist/local/transactions/mutations/failureReporting.js +67 -0
  21. package/dist/local/transactions/mutations/pendingDrain.js +5 -1
  22. package/dist/local/transactions/mutations/replayValidation.d.ts +51 -15
  23. package/dist/local/transactions/mutations/replayValidation.js +2 -0
  24. package/dist/react/AbloProvider.js +1 -1
  25. package/dist/react/ClientSideSuspense.d.ts +1 -1
  26. package/dist/react/DefaultFallback.d.ts +1 -1
  27. package/dist/react/createAbloReact.js +1 -1
  28. package/dist/surface.d.ts +3 -3
  29. package/package.json +2 -2
  30. package/src/local/BaseSyncedStore.ts +2 -8
  31. package/src/local/Model.ts +2 -2
  32. package/src/local/SyncClient.ts +22 -1
  33. package/src/local/interfaces/index.ts +1 -1
  34. package/src/local/sync/SyncWebSocket.ts +1 -1
  35. package/src/local/sync/deltaPipeline.ts +26 -82
  36. package/src/local/transactions/mutations/batchProcessing.ts +5 -1
  37. package/src/local/transactions/mutations/commitPayload.ts +17 -0
  38. package/src/local/transactions/mutations/failureHandling.ts +73 -132
  39. package/src/local/transactions/mutations/failureReporting.ts +93 -0
  40. package/src/local/transactions/mutations/pendingDrain.ts +5 -1
  41. package/src/local/transactions/mutations/replayValidation.ts +2 -0
package/dist/humans.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface HumansSurface {
13
13
  readonly [kStoreCluster]?: StoreCluster;
14
14
  }
15
15
  export declare function humans(): {
16
- readonly id: "humans";
16
+ readonly id: 'humans';
17
17
  readonly requires: {
18
18
  readonly duplex: true;
19
19
  };
@@ -647,9 +647,7 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
647
647
  * (no plugins installed) and the `humans()` apply handler both call it.
648
648
  */
649
649
  applyChangesToPool(changes: readonly AppliedChange[]): void;
650
- /** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
651
- protected getStateFields(_modelName: string): string[];
652
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
650
+ /** Deduplicate repeated delivery of the same positive sync id. */
653
651
  protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
654
652
  /** Process incoming delta with smart batching */
655
653
  protected processDeltaWithBatching(delta: SyncDelta): void;
@@ -1179,7 +1179,6 @@ export class BaseSyncedStore {
1179
1179
  acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
1180
1180
  get objectPool() { return store.objectPool; },
1181
1181
  // Dynamic-dispatch hooks — protected override points on this class.
1182
- getStateFields: (modelName) => this.getStateFields(modelName),
1183
1182
  isCustomEntity: (modelName) => this.isCustomEntity(modelName),
1184
1183
  createCustomEntity: (modelName, modelId, data) => this.createCustomEntity(modelName, modelId, data),
1185
1184
  deduplicateDeltas: (deltas) => this.deduplicateDeltas(deltas),
@@ -1202,13 +1201,9 @@ export class BaseSyncedStore {
1202
1201
  applyChangesToPool(changes) {
1203
1202
  this.syncClient.applyDeltaBatchToPool(changes, (name, data) => this.enrichRelations(name, data));
1204
1203
  }
1205
- /** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
1206
- getStateFields(_modelName) {
1207
- return ['status', 'state', 'isActive'];
1208
- }
1209
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
1204
+ /** Deduplicate repeated delivery of the same positive sync id. */
1210
1205
  deduplicateDeltas(deltas) {
1211
- return deltaPipeline.deduplicateDeltas(this.deltaPipelineContext, deltas);
1206
+ return deltaPipeline.deduplicateDeltas(deltas);
1212
1207
  }
1213
1208
  /** Process incoming delta with smart batching */
1214
1209
  processDeltaWithBatching(delta) {
@@ -101,7 +101,7 @@ export declare class Database {
101
101
  * where a missing store points to silent data loss. Callers that
102
102
  * already expect optional behavior (e.g. lazy lookups) can omit it.
103
103
  */
104
- getStore(modelName: string, context?: string): import("./stores/ObjectStore.js").ObjectStore | InMemoryObjectStore | undefined;
104
+ getStore(modelName: string, context?: string): InMemoryObjectStore | import("./stores/ObjectStore.js").ObjectStore | undefined;
105
105
  /** Get store or throw if not found (for operations that require the store). */
106
106
  private getRequiredStore;
107
107
  /** Log preserved fields during partial UPDATE merge (debug helper) */
@@ -274,7 +274,7 @@ export declare class Database {
274
274
  * `getStore(modelName, context?)` is defined near the top of this
275
275
  * class — single accessor for both inMemory and IDB modes.
276
276
  */
277
- getAllStores(): Map<string, import("./stores/ObjectStore.js").ObjectStore> | Map<string, InMemoryObjectStore>;
277
+ getAllStores(): Map<string, InMemoryObjectStore> | Map<string, import("./stores/ObjectStore.js").ObjectStore>;
278
278
  /**
279
279
  * Model persistence tracking
280
280
  */
@@ -65,7 +65,7 @@ export declare class LazyReferenceCollection<T extends Model> {
65
65
  private get database();
66
66
  /** Get objectPool from static dependencies */
67
67
  private get objectPool();
68
- constructor(modelName: string, parent: Model, foreignKey: string, customQuery?: any | undefined, options?: LazyCollectionOptions);
68
+ constructor(modelName: string, parent: Model, foreignKey: string, customQuery?: any, options?: LazyCollectionOptions);
69
69
  /**
70
70
  * Set up MobX observation lifecycle hooks
71
71
  * When React components observe this collection, we prevent GC of the parent model
@@ -306,7 +306,7 @@ export class Model {
306
306
  */
307
307
  capturePreviousValues(keys, opts) {
308
308
  const out = {};
309
- const modified = this.modifiedProperties instanceof Map ? this.modifiedProperties : null;
309
+ const modified = this.modifiedProperties;
310
310
  const original = this.getOriginalSnapshot();
311
311
  for (const key of keys) {
312
312
  if (key === 'id')
@@ -334,7 +334,7 @@ export class Model {
334
334
  * is never consumed. With no `keys`, consumes every tracked field.
335
335
  */
336
336
  consumeModifiedFields(keys) {
337
- if (!(this.modifiedProperties instanceof Map) || this.modifiedProperties.size === 0) {
337
+ if (this.modifiedProperties.size === 0) {
338
338
  return;
339
339
  }
340
340
  const only = keys ? new Set(keys) : null;
@@ -409,14 +409,14 @@ export declare class SyncClient extends EventEmitter {
409
409
  * Get detailed debug info for the sync debug page
410
410
  */
411
411
  getDebugInfo(): {
412
- connectionState: "connected" | "disconnected" | "connecting";
412
+ connectionState: "connected" | "connecting" | "disconnected";
413
413
  pendingMutationsCount: number;
414
414
  mutationQueue: {
415
415
  lastSeenSyncId: number;
416
416
  awaitingDeltaCount: number;
417
417
  awaitingDeltaTransactions: {
418
418
  id: string;
419
- type: "update" | "create" | "delete" | "archive" | "unarchive";
419
+ type: "archive" | "create" | "delete" | "unarchive" | "update";
420
420
  modelName: string;
421
421
  modelId: string;
422
422
  syncIdNeeded: number | undefined;
@@ -425,13 +425,13 @@ export declare class SyncClient extends EventEmitter {
425
425
  }[];
426
426
  pendingTransactions: {
427
427
  id: string;
428
- type: "update" | "create" | "delete" | "archive" | "unarchive";
428
+ type: "archive" | "create" | "delete" | "unarchive" | "update";
429
429
  modelName: string;
430
430
  modelId: string;
431
431
  }[];
432
432
  executingTransactions: {
433
433
  id: string;
434
- type: "update" | "create" | "delete" | "archive" | "unarchive";
434
+ type: "archive" | "create" | "delete" | "unarchive" | "update";
435
435
  modelName: string;
436
436
  modelId: string;
437
437
  }[];
@@ -10,7 +10,7 @@
10
10
  import { runInAction } from 'mobx';
11
11
  import { InstanceCache, ModelScope } from './InstanceCache.js';
12
12
  import { Model } from './Model.js';
13
- import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
13
+ import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
14
14
  // ModelRegistry instance accessed via this.objectPool.registry
15
15
  import { LoadStrategy } from '@abloatai/transaction/types';
16
16
  import { globalRuntime } from './context.js';
@@ -1588,6 +1588,25 @@ export class SyncClient extends EventEmitter {
1588
1588
  // otherwise re-add it for the brief window before the matching delete
1589
1589
  // confirmation lands.
1590
1590
  if (this.echoTracker.consumeEcho(transactionId)) {
1591
+ // A direct assignment can re-enter change tracking while this
1592
+ // optimistic write is in flight. Leaving the acknowledged field dirty
1593
+ // makes conflict resolution preserve it over the next collaborator
1594
+ // delta, so peers appear desynchronized until refresh.
1595
+ //
1596
+ // Re-baseline only values this echo actually confirms. If the user has
1597
+ // edited the same field again since the write was sent, its current
1598
+ // dirty value differs from the echo and remains queued.
1599
+ if (resident && result.data) {
1600
+ const acknowledgedFields = [];
1601
+ for (const [field, change] of resident.modifiedProperties) {
1602
+ if (Object.prototype.hasOwnProperty.call(result.data, field) &&
1603
+ deepEqual(change.new, result.data[field])) {
1604
+ acknowledgedFields.push(field);
1605
+ }
1606
+ }
1607
+ resident.consumeModifiedFields(acknowledgedFields);
1608
+ resident.markAsSynced();
1609
+ }
1591
1610
  continue;
1592
1611
  }
1593
1612
  // If a later op in this batch will remove this id, skip earlier
@@ -141,7 +141,7 @@ import type { MutationOptions } from '@abloatai/transaction/client/resources/mut
141
141
  * `claim` are deliberately absent: both are resolved on the client before a write
142
142
  * is staged, so neither reaches this layer.
143
143
  */
144
- export type WriteOptions = Pick<MutationOptions, 'readAt' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'>;
144
+ export type WriteOptions = Pick<MutationOptions, 'readAt' | 'reads' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'>;
145
145
  /** A single mutation within a batch. Its `options` travel with it so the server
146
146
  * can cache and replay the operation for idempotent retries. */
147
147
  export interface MutationOperation {
@@ -11,13 +11,13 @@ export declare const syncActionSchema: z.ZodObject<{
11
11
  modelId: z.ZodString;
12
12
  action: z.ZodEnum<{
13
13
  A: "A";
14
- I: "I";
15
- U: "U";
16
- D: "D";
17
- V: "V";
18
14
  C: "C";
15
+ D: "D";
19
16
  G: "G";
17
+ I: "I";
20
18
  S: "S";
19
+ U: "U";
20
+ V: "V";
21
21
  }>;
22
22
  data: z.ZodUnknown;
23
23
  __class: z.ZodDefault<z.ZodLiteral<"SyncAction">>;
@@ -73,7 +73,6 @@ export interface DeltaPipelineContext {
73
73
  * {@link handleGroupHandlerFailure}). */
74
74
  clear(): void;
75
75
  };
76
- getStateFields(modelName: string): string[];
77
76
  isCustomEntity(modelName: string): boolean;
78
77
  createCustomEntity(modelName: string, modelId: string, data: Record<string, unknown>): Model | null;
79
78
  deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
@@ -94,8 +93,17 @@ export interface DeltaPipelineContext {
94
93
  * It never throws, because it runs inside the pipeline's fire-and-forget path.
95
94
  */
96
95
  export declare function handleGroupHandlerFailure(ctx: DeltaPipelineContext, delta: SyncDelta, error: unknown): void;
97
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
98
- export declare function deduplicateDeltas(ctx: DeltaPipelineContext, deltas: SyncDelta[]): SyncDelta[];
96
+ /**
97
+ * Deduplicate repeated delivery of the same log entry.
98
+ *
99
+ * A row may legitimately change several times in one receive frame. Those
100
+ * changes are ordered facts, even when a small subset of fields (such as
101
+ * `status`) happens to remain equal. Collapsing by entity or a partial state
102
+ * signature can therefore discard the newest row image while the cursor still
103
+ * advances past it. Only an identical positive sync id proves duplicate
104
+ * delivery; non-positive ids carry no usable log identity and stay untouched.
105
+ */
106
+ export declare function deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
99
107
  /**
100
108
  * Performs per-delta bookkeeping and enqueues the delta. Returns `true` when
101
109
  * the delta was pushed onto `pendingDeltas` — a regular batchable insert,
@@ -52,90 +52,37 @@ export function handleGroupHandlerFailure(ctx, delta, error) {
52
52
  // Best-effort: the reconnect/bootstrap cycle self-heals on next connect.
53
53
  }
54
54
  }
55
- /** Builds a small signature of a delta's state fields, used to detect no-op duplicate deltas. */
56
- function extractStateSignature(ctx, delta) {
57
- if (!delta.data || typeof delta.data !== 'object')
58
- return null;
59
- const data = typeof delta.data === 'string'
60
- ? JSON.parse(delta.data)
61
- : (delta.data);
62
- // Generic state fields subclasses can override getStateFields() for model-specific fields
63
- const fieldsToCheck = ctx.getStateFields(delta.modelName);
64
- const signature = {
65
- actionType: delta.actionType,
66
- modelName: delta.modelName,
67
- };
68
- for (const field of fieldsToCheck) {
69
- if (field in data)
70
- signature[field] = data[field];
71
- }
72
- return signature;
73
- }
74
- function isSameState(a, b) {
75
- if (!a || !b)
76
- return false;
77
- const keys = Object.keys(a);
78
- if (keys.length !== Object.keys(b).length)
79
- return false;
80
- return keys.every((k) => a[k] === b[k]);
81
- }
82
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
83
- export function deduplicateDeltas(ctx, deltas) {
84
- // The dominant live-publication shape is a frame of independent entity
85
- // creates. When every entity key occurs once, reconciliation cannot remove
86
- // or reorder anything: preserve the already commit-ordered input directly
87
- // and avoid allocating a bucket array, state signature, and two sorts per
88
- // delta. The first duplicate falls through to the full transition logic.
89
- const uniqueEntities = new Set();
90
- let hasDuplicateEntity = false;
91
- for (const delta of deltas) {
92
- const key = `${delta.modelName}:${delta.modelId}`;
93
- if (uniqueEntities.has(key)) {
94
- hasDuplicateEntity = true;
55
+ /**
56
+ * Deduplicate repeated delivery of the same log entry.
57
+ *
58
+ * A row may legitimately change several times in one receive frame. Those
59
+ * changes are ordered facts, even when a small subset of fields (such as
60
+ * `status`) happens to remain equal. Collapsing by entity or a partial state
61
+ * signature can therefore discard the newest row image while the cursor still
62
+ * advances past it. Only an identical positive sync id proves duplicate
63
+ * delivery; non-positive ids carry no usable log identity and stay untouched.
64
+ */
65
+ export function deduplicateDeltas(deltas) {
66
+ if (deltas.length < 2 || deltas.some((delta) => delta.id <= 0))
67
+ return deltas;
68
+ let strictlyOrdered = true;
69
+ for (let index = 1; index < deltas.length; index += 1) {
70
+ if (deltas[index - 1].id >= deltas[index].id) {
71
+ strictlyOrdered = false;
95
72
  break;
96
73
  }
97
- uniqueEntities.add(key);
98
74
  }
99
- if (!hasDuplicateEntity)
75
+ if (strictlyOrdered)
100
76
  return deltas;
101
- const byEntity = new Map();
102
- for (const d of deltas) {
103
- const key = `${d.modelName}:${d.modelId}`;
104
- if (!byEntity.has(key))
105
- byEntity.set(key, []);
106
- byEntity.get(key).push(d);
107
- }
108
- const result = [];
109
- for (const entityDeltas of byEntity.values()) {
110
- const sorted = entityDeltas.sort((a, b) => a.id - b.id);
111
- // DELETE wins — it's the final state
112
- const del = sorted.find((d) => d.actionType === 'D');
113
- if (del) {
114
- result.push(del);
115
- continue;
116
- }
117
- // Keep deltas that represent different states
118
- const unique = [];
119
- let prev = null;
120
- for (const d of sorted) {
121
- const sig = extractStateSignature(ctx, d);
122
- if (!isSameState(prev, sig)) {
123
- unique.push(d);
124
- prev = sig;
125
- }
126
- }
127
- if (unique.length > 0) {
128
- result.push(...unique);
129
- }
130
- else {
131
- // `sorted` is never empty (every byEntity bucket gets at least one
132
- // delta pushed) — the guard only narrows the indexed access.
133
- const last = sorted.at(-1);
134
- if (last)
135
- result.push(last);
136
- }
137
- }
138
- return result.sort((a, b) => a.id - b.id);
77
+ const seen = new Set();
78
+ return [...deltas]
79
+ .sort((a, b) => a.id - b.id)
80
+ .filter((delta) => {
81
+ if (seen.has(delta.id))
82
+ return false;
83
+ seen.add(delta.id);
84
+ return true;
85
+ });
139
86
  }
140
87
  /**
141
88
  * Performs per-delta bookkeeping and enqueues the delta. Returns `true` when
@@ -18,19 +18,19 @@ import type { RuntimeContext } from "../RuntimeContext.js";
18
18
  */
19
19
  export declare const ServerDeltaSchema: z.ZodObject<{
20
20
  id: z.ZodNumber;
21
- data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
22
21
  actionType: z.ZodEnum<{
23
22
  A: "A";
24
- I: "I";
25
- U: "U";
26
- D: "D";
27
- V: "V";
28
23
  C: "C";
24
+ D: "D";
29
25
  G: "G";
26
+ I: "I";
30
27
  S: "S";
28
+ U: "U";
29
+ V: "V";
31
30
  }>;
32
31
  modelName: z.ZodString;
33
32
  modelId: z.ZodString;
33
+ data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
34
34
  }, z.core.$loose>;
35
35
  export type ValidatedServerDelta = z.infer<typeof ServerDeltaSchema>;
36
36
  export declare const BootstrapResponseSchema: z.ZodObject<{
@@ -42,19 +42,19 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
42
42
  models: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodPipe<z.ZodUnion<readonly [z.ZodArray<z.ZodUnknown>, z.ZodString, z.ZodNull]>, z.ZodTransform<unknown[], string | unknown[] | null>>>>;
43
43
  deltas: z.ZodOptional<z.ZodArray<z.ZodObject<{
44
44
  id: z.ZodNumber;
45
- data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
46
45
  actionType: z.ZodEnum<{
47
46
  A: "A";
48
- I: "I";
49
- U: "U";
50
- D: "D";
51
- V: "V";
52
47
  C: "C";
48
+ D: "D";
53
49
  G: "G";
50
+ I: "I";
54
51
  S: "S";
52
+ U: "U";
53
+ V: "V";
55
54
  }>;
56
55
  modelName: z.ZodString;
57
56
  modelId: z.ZodString;
57
+ data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
58
58
  }, z.core.$loose>>>;
59
59
  deltaCount: z.ZodOptional<z.ZodNumber>;
60
60
  failedModels: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -477,7 +477,7 @@ export declare class MutationQueue extends EventEmitter {
477
477
  awaitingDeltaCount: number;
478
478
  awaitingDeltaTransactions: {
479
479
  id: string;
480
- type: "update" | "create" | "delete" | "archive" | "unarchive";
480
+ type: "archive" | "create" | "delete" | "unarchive" | "update";
481
481
  modelName: string;
482
482
  modelId: string;
483
483
  syncIdNeeded: number | undefined;
@@ -486,13 +486,13 @@ export declare class MutationQueue extends EventEmitter {
486
486
  }[];
487
487
  pendingTransactions: {
488
488
  id: string;
489
- type: "update" | "create" | "delete" | "archive" | "unarchive";
489
+ type: "archive" | "create" | "delete" | "unarchive" | "update";
490
490
  modelName: string;
491
491
  modelId: string;
492
492
  }[];
493
493
  executingTransactions: {
494
494
  id: string;
495
- type: "update" | "create" | "delete" | "archive" | "unarchive";
495
+ type: "archive" | "create" | "delete" | "unarchive" | "update";
496
496
  modelName: string;
497
497
  modelId: string;
498
498
  }[];
@@ -1,5 +1,5 @@
1
1
  import { AbloError, AbloNotFoundError } from '@abloatai/transaction/errors';
2
- import { applyWriteOptions, normalizeModelKey, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
2
+ import { applyWriteOptions, collectQueuedReads, normalizeModelKey, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
3
3
  export async function processBatch(ctx) {
4
4
  if (ctx.durableReplayBlock)
5
5
  return;
@@ -69,6 +69,7 @@ export async function processBatch(ctx) {
69
69
  origin: 'model_batch',
70
70
  operations: batchOps.map(({ op }) => op),
71
71
  sourceMutationIds: ctx.sourceMutationIdsFor(batch),
72
+ commitOptions: { reads: collectQueuedReads(batch) },
72
73
  createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
73
74
  sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
74
75
  sequence: batch[0]?.commitEnvelope?.sequence,
@@ -84,6 +85,9 @@ export async function processBatch(ctx) {
84
85
  dispatchStarted = true;
85
86
  const result = ctx.parseMutationCommitResult(await ctx.dispatchCommitBounded(operations, {
86
87
  idempotencyKey: commitIdempotencyKey,
88
+ ...(durableEnvelope.commitOptions.reads !== undefined
89
+ ? { reads: durableEnvelope.commitOptions.reads }
90
+ : {}),
87
91
  }));
88
92
  await ctx.persistDurableCommitAcceptance(durableEnvelope, result);
89
93
  const lastSyncId = result.lastSyncId;
@@ -105,6 +105,8 @@ export interface QueuedMutation {
105
105
  */
106
106
  confirmation?: Promise<void>;
107
107
  }
108
+ /** Merge per-write premises into the one batch-level read set sent on wire. */
109
+ export declare function collectQueuedReads(transactions: readonly QueuedMutation[]): MutationOptions['reads'] | undefined;
108
110
  export declare const normalizeModelKey: (modelName: string) => string;
109
111
  export declare const stripModelSuffix: (modelName: string) => string;
110
112
  /**
@@ -120,7 +122,7 @@ export declare const stripModelSuffix: (modelName: string) => string;
120
122
  * foreign-key ordering because the row already exists, so they all share the
121
123
  * configured default non-create priority.
122
124
  */
123
- export declare const computePriorityScore: (type: QueuedMutation["type"], modelName: string, runtime?: RuntimeContext) => number;
125
+ export declare const computePriorityScore: (type: QueuedMutation['type'], modelName: string, runtime?: RuntimeContext) => number;
124
126
  export declare const TX_TYPE_TO_MUTATION_OP: Record<QueuedMutation['type'], MutationOperationType>;
125
127
  export declare function hasStaleWriteOptions(options?: WriteOptions): boolean;
126
128
  /** Options whose identity/audit semantics forbid merging two caller writes. */
@@ -75,6 +75,20 @@ export function projectCommitPayload(modelName, source, opts, runtime = globalRu
75
75
  }
76
76
  return snapshotJsonValue(out, '$.input');
77
77
  }
78
+ /** Merge per-write premises into the one batch-level read set sent on wire. */
79
+ export function collectQueuedReads(transactions) {
80
+ const declared = transactions
81
+ .map((transaction) => transaction.writeOptions?.reads)
82
+ .filter((reads) => reads !== undefined);
83
+ if (declared.length === 0)
84
+ return undefined;
85
+ const unique = new Map();
86
+ for (const dependency of declared.flatMap((reads) => reads ?? [])) {
87
+ unique.set(JSON.stringify(dependency), dependency);
88
+ }
89
+ const reads = [...unique.values()];
90
+ return reads.length > 0 ? reads : null;
91
+ }
78
92
  export const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
79
93
  export const stripModelSuffix = (modelName) => modelName.replace('Model', '');
80
94
  /**
@@ -1,5 +1,5 @@
1
- import { AbloError } from '@abloatai/transaction/errors';
2
1
  import { extractStatusCode } from './commitPayload.js';
2
+ import { reportPermanentMutationFailure } from './failureReporting.js';
3
3
  export function transientRetryDelayMs(error, attempt, retryBackoff) {
4
4
  const { baseMs, capMs } = retryBackoff;
5
5
  let base = baseMs;
@@ -16,85 +16,12 @@ export async function handleFailure(ctx, transaction, error) {
16
16
  transaction.attempts++;
17
17
  // Check whether this is a permanent error that should not be retried.
18
18
  if (ctx.isPermanentError(error)) {
19
- // Logged at warn: a permanent error means the server rejected the write,
20
- // so the developer should see the reason in the console. The typed
21
- // AbloError fields (`type`, `code`, `httpStatus`) are included so the
22
- // cause is visible — for example a foreign-key violation
23
- // (AbloValidationError) versus expired authentication
24
- // (AbloAuthenticationError).
25
- try {
26
- const abloErr = error instanceof AbloError ? error : undefined;
27
- const details = {
28
- txId: transaction.id.slice(0, 8),
29
- type: transaction.type,
30
- model: transaction.modelName,
31
- modelId: transaction.modelId.slice(0, 12),
32
- errorType: abloErr?.type ?? error?.name,
33
- errorCode: abloErr?.code,
34
- httpStatus: abloErr?.httpStatus,
35
- requestId: abloErr?.requestId,
36
- message: error?.message,
37
- inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
38
- };
39
- // A `create` whose id already exists is the benign idempotency case:
40
- // "this row is already there." It's the least alarming permanent
41
- // error, so it doesn't warrant a `warn` — `info` keeps it visible
42
- // without crying wolf. Everything else (FK violation, auth expiry,
43
- // server 500) stays at `warn`.
44
- const isBenignIdempotent = transaction.type === 'create' &&
45
- (abloErr?.code === 'unique_violation' ||
46
- abloErr?.type === 'AbloIdempotencyError');
47
- // Demote exact repeats (same write rejected for the same reason on
48
- // each reconnect replay) to `debug` so the loop logs once.
49
- const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
50
- const isRepeat = sig === ctx.getLastPermanentErrorSignature();
51
- ctx.setLastPermanentErrorSignature(sig);
52
- const logger = ctx.runtime.logger;
53
- // Two registers from one call site, split by log level (the default
54
- // logger is gated at `warn`, so `debug` stays hidden unless
55
- // ABLO_LOG_LEVEL=debug is set to inspect the engine):
56
- // - the default-visible line speaks the application developer's
57
- // language: their verb (such as `update`), their model, the typed
58
- // error's own message, and the wire `code` for searching. It uses
59
- // no engine jargon and prints no JSON dump, which would alarm
60
- // without helping.
61
- // - the forensic `details` ride a companion `debug` line for anyone
62
- // debugging the engine internals.
63
- const revertNote = ctx.config.enableOptimistic
64
- ? ' The local change was reverted.'
65
- : '';
66
- const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
67
- const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
68
- const requestRef = abloErr?.requestId
69
- ? ` [request_id: ${abloErr.requestId}]`
70
- : '';
71
- // An optimistic write resolves before the server answers, so a later
72
- // rejection has no caller left to return to and this log is the only
73
- // place it appears. That reads to an application developer as their own
74
- // save silently failing — the write showed, then vanished — and sends
75
- // them into their editor instead of here. Name the subscription that
76
- // hands them the same typed error, so the application can say what
77
- // happened rather than only the console.
78
- const channelNote = ctx.config.enableOptimistic
79
- ? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
80
- : '';
81
- const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestRef}.${revertNote}${channelNote}`;
82
- if (isRepeat) {
83
- // Same write rejected for the same reason on each reconnect replay —
84
- // log the forensics once, stay quiet after.
85
- logger.debug('write rejected again (same reason)', details);
86
- }
87
- else if (isBenignIdempotent) {
88
- // Already-exists on a `create` is expected on replay, not a problem.
89
- logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
90
- logger.debug('idempotent skip — details', details);
91
- }
92
- else {
93
- logger.warn(headline);
94
- logger.debug('write rejection — details', details);
95
- }
96
- }
97
- catch { }
19
+ reportPermanentMutationFailure({
20
+ runtime: ctx.runtime,
21
+ enableOptimistic: ctx.config.enableOptimistic,
22
+ getLastPermanentErrorSignature: ctx.getLastPermanentErrorSignature,
23
+ setLastPermanentErrorSignature: ctx.setLastPermanentErrorSignature,
24
+ }, transaction, error);
98
25
  // Mark as failed immediately and rollback
99
26
  ctx.store.updateStatus(transaction.id, 'failed');
100
27
  if (ctx.config.enableOptimistic) {
@@ -108,7 +35,8 @@ export async function handleFailure(ctx, transaction, error) {
108
35
  return;
109
36
  }
110
37
  transaction.firstTransientFailureAt ??= Date.now();
111
- const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt < ctx.config.availabilityRetryWindowMs;
38
+ const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt <
39
+ ctx.config.availabilityRetryWindowMs;
112
40
  if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
113
41
  // Exponential backoff with full jitter on every transient retry:
114
42
  // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
@@ -0,0 +1,10 @@
1
+ import type { RuntimeContext } from '../../RuntimeContext.js';
2
+ import type { QueuedMutation } from './commitPayload.js';
3
+ export interface PermanentFailureReportingContext {
4
+ readonly runtime: RuntimeContext;
5
+ readonly enableOptimistic: boolean;
6
+ readonly getLastPermanentErrorSignature: () => string | undefined;
7
+ readonly setLastPermanentErrorSignature: (signature: string) => void;
8
+ }
9
+ /** Report a terminal rejection at a severity that matches its meaning. */
10
+ export declare function reportPermanentMutationFailure(ctx: PermanentFailureReportingContext, transaction: QueuedMutation, error: Error): void;