@abloatai/humans 0.59.1 → 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 (31) 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/stores/syncAction.d.ts +4 -4
  10. package/dist/local/sync/deltaPipeline.d.ts +11 -3
  11. package/dist/local/sync/deltaPipeline.js +27 -80
  12. package/dist/local/sync/schemas.d.ts +10 -10
  13. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
  14. package/dist/local/transactions/mutations/commitPayload.d.ts +1 -1
  15. package/dist/local/transactions/mutations/failureHandling.js +9 -81
  16. package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
  17. package/dist/local/transactions/mutations/failureReporting.js +67 -0
  18. package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
  19. package/dist/react/AbloProvider.js +1 -1
  20. package/dist/react/ClientSideSuspense.d.ts +1 -1
  21. package/dist/react/DefaultFallback.d.ts +1 -1
  22. package/dist/react/createAbloReact.js +1 -1
  23. package/dist/surface.d.ts +3 -3
  24. package/package.json +2 -2
  25. package/src/local/BaseSyncedStore.ts +2 -8
  26. package/src/local/Model.ts +2 -2
  27. package/src/local/SyncClient.ts +22 -1
  28. package/src/local/sync/SyncWebSocket.ts +1 -1
  29. package/src/local/sync/deltaPipeline.ts +26 -82
  30. package/src/local/transactions/mutations/failureHandling.ts +73 -132
  31. package/src/local/transactions/mutations/failureReporting.ts +93 -0
@@ -27,11 +27,11 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
27
27
  export declare const persistedTransactionSchema: z.ZodObject<{
28
28
  id: z.ZodString;
29
29
  type: z.ZodEnum<{
30
- update: "update";
30
+ archive: "archive";
31
31
  create: "create";
32
32
  delete: "delete";
33
- archive: "archive";
34
33
  unarchive: "unarchive";
34
+ update: "update";
35
35
  }>;
36
36
  modelName: z.ZodString;
37
37
  modelId: z.ZodString;
@@ -95,10 +95,10 @@ export declare function deserializePersistedTransaction(row: unknown, runtime?:
95
95
  export declare const persistedMutationSchema: z.ZodObject<{
96
96
  mutationId: z.ZodOptional<z.ZodString>;
97
97
  type: z.ZodEnum<{
98
- update: "update";
98
+ archive: "archive";
99
99
  create: "create";
100
100
  delete: "delete";
101
- archive: "archive";
101
+ update: "update";
102
102
  }>;
103
103
  modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
104
104
  modelName: z.ZodString;
@@ -133,15 +133,14 @@ export declare const PENDING_MUTATION_RECORD_PREFIX = "pending-mutation:";
133
133
  export declare const PENDING_MUTATION_REPLAY_WINDOW_MS: number;
134
134
  /** Scope-less records written by the first aggregate-journal release. */
135
135
  export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
136
- storageVersion: z.ZodLiteral<1>;
137
136
  id: z.ZodString;
138
137
  type: z.ZodLiteral<"pending_mutation">;
139
138
  mutation: z.ZodObject<{
140
139
  type: z.ZodEnum<{
141
- update: "update";
140
+ archive: "archive";
142
141
  create: "create";
143
142
  delete: "delete";
144
- archive: "archive";
143
+ update: "update";
145
144
  }>;
146
145
  modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
147
146
  modelName: z.ZodString;
@@ -168,22 +167,17 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
168
167
  mutationId: z.ZodString;
169
168
  }, z.core.$loose>;
170
169
  timestamp: z.ZodNumber;
170
+ storageVersion: z.ZodLiteral<1>;
171
171
  }, z.core.$strict>;
172
172
  export declare const pendingMutationRecordSchema: z.ZodObject<{
173
- storageVersion: z.ZodLiteral<2>;
174
- scope: z.ZodObject<{
175
- organizationId: z.ZodString;
176
- participantId: z.ZodString;
177
- namespace: z.ZodString;
178
- }, z.core.$strict>;
179
173
  id: z.ZodString;
180
174
  type: z.ZodLiteral<"pending_mutation">;
181
175
  mutation: z.ZodObject<{
182
176
  type: z.ZodEnum<{
183
- update: "update";
177
+ archive: "archive";
184
178
  create: "create";
185
179
  delete: "delete";
186
- archive: "archive";
180
+ update: "update";
187
181
  }>;
188
182
  modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
189
183
  modelName: z.ZodString;
@@ -210,6 +204,12 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
210
204
  mutationId: z.ZodString;
211
205
  }, z.core.$loose>;
212
206
  timestamp: z.ZodNumber;
207
+ storageVersion: z.ZodLiteral<2>;
208
+ scope: z.ZodObject<{
209
+ organizationId: z.ZodString;
210
+ participantId: z.ZodString;
211
+ namespace: z.ZodString;
212
+ }, z.core.$strict>;
213
213
  }, z.core.$strict>;
214
214
  export type PendingMutationRecord = z.infer<typeof pendingMutationRecordSchema>;
215
215
  export declare function pendingMutationRecordId(mutationId: string): string;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useCallback, useContext, useEffect, useMemo, useRef, useState, createContext, } from 'react';
4
4
  import { createParticipantClaimId, parseParticipantTtlSeconds, resolveParticipantSyncGroups, } from '../local/sync/participants.js';
5
5
  import { SyncContext } from './context.js';
@@ -33,4 +33,4 @@ export interface ClientSideSuspenseProps {
33
33
  /** What to render once the subtree is cleared to render. */
34
34
  children: ReactNode;
35
35
  }
36
- export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
36
+ export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react/jsx-runtime").JSX.Element;
@@ -21,4 +21,4 @@
21
21
  * pass `fallback={null}`. Consumers who want to skip the gate entirely
22
22
  * pass `fallback="passthrough"`.
23
23
  */
24
- export declare function DefaultFallback(): import("react").JSX.Element;
24
+ export declare function DefaultFallback(): import("react/jsx-runtime").JSX.Element;
@@ -38,7 +38,7 @@ export function createAbloReact(schema) {
38
38
  // implementation's internal-context fallback.
39
39
  const BoundClientContext = createContext(null);
40
40
  function BoundAbloProvider(props) {
41
- return createElement(BoundClientContext.Provider, { value: props.client }, createElement((AbloProvider), props));
41
+ return createElement(BoundClientContext.Provider, { value: props.client }, createElement(AbloProvider, props));
42
42
  }
43
43
  function useBoundAblo(modelOrSelect, id, options) {
44
44
  const bound = useContext(BoundClientContext);
package/dist/surface.d.ts CHANGED
@@ -19,18 +19,18 @@
19
19
  * tuple, so it is the one list of model-verb names a generated summary can
20
20
  * describe.
21
21
  */
22
- export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "create", "update", "delete", "claim", "join", "onChange"];
22
+ export declare const PUBLIC_MODEL_VERBS: readonly ['get', 'read', 'list', 'listAll', 'local', 'create', 'update', 'delete', 'claim', 'join', 'onChange'];
23
23
  /**
24
24
  * The option keys accepted by `local.list` and `onChange`, matching the
25
25
  * keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
26
26
  * `state`, not `scope`.
27
27
  */
28
- export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orderBy", "limit", "offset", "state"];
28
+ export declare const PUBLIC_LIST_OPTION_KEYS: readonly ['where', 'filter', 'orderBy', 'limit', 'offset', 'state'];
29
29
  /**
30
30
  * The keys of the client constructor options, {@link AbloOptions}. Only
31
31
  * `schema` is required; every other key is optional.
32
32
  */
33
- export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "projectId", "branchId", "authEndpoint", "authTimeoutMs", "allowCrossOriginAuthEndpoint", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "debug", "logLevel", "logger", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser", "collaborationEvents", "plugins"];
33
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ['schema', 'apiKey', 'projectId', 'branchId', 'authEndpoint', 'authTimeoutMs', 'allowCrossOriginAuthEndpoint', 'persistence', 'durableWrites', 'commitOutbox', 'commitOutboxScope', 'debug', 'logLevel', 'logger', 'authToken', 'baseURL', 'fetch', 'defaultHeaders', 'defaultQuery', 'dangerouslyAllowBrowser', 'collaborationEvents', 'plugins'];
34
34
  export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
35
35
  export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
36
36
  export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.59.1",
3
+ "version": "0.59.2",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.59.1",
87
+ "@abloatai/transaction": "^0.59.2",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
@@ -1578,7 +1578,6 @@ export class BaseSyncedStore<
1578
1578
  acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
1579
1579
  get objectPool() { return store.objectPool; },
1580
1580
  // Dynamic-dispatch hooks — protected override points on this class.
1581
- getStateFields: (modelName) => this.getStateFields(modelName),
1582
1581
  isCustomEntity: (modelName) => this.isCustomEntity(modelName),
1583
1582
  createCustomEntity: (modelName, modelId, data) =>
1584
1583
  this.createCustomEntity(modelName, modelId, data),
@@ -1607,14 +1606,9 @@ export class BaseSyncedStore<
1607
1606
  );
1608
1607
  }
1609
1608
 
1610
- /** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
1611
- protected getStateFields(_modelName: string): string[] {
1612
- return ['status', 'state', 'isActive'];
1613
- }
1614
-
1615
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
1609
+ /** Deduplicate repeated delivery of the same positive sync id. */
1616
1610
  protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
1617
- return deltaPipeline.deduplicateDeltas(this.deltaPipelineContext, deltas);
1611
+ return deltaPipeline.deduplicateDeltas(deltas);
1618
1612
  }
1619
1613
 
1620
1614
  /** Process incoming delta with smart batching */
@@ -412,7 +412,7 @@ export abstract class Model {
412
412
  opts?: { fallbackToLive?: boolean },
413
413
  ): ModelData {
414
414
  const out: ModelData = {};
415
- const modified = this.modifiedProperties instanceof Map ? this.modifiedProperties : null;
415
+ const modified = this.modifiedProperties;
416
416
  const original = this.getOriginalSnapshot();
417
417
  for (const key of keys) {
418
418
  if (key === 'id') continue;
@@ -438,7 +438,7 @@ export abstract class Model {
438
438
  * is never consumed. With no `keys`, consumes every tracked field.
439
439
  */
440
440
  consumeModifiedFields(keys?: Iterable<string>): void {
441
- if (!(this.modifiedProperties instanceof Map) || this.modifiedProperties.size === 0) {
441
+ if (this.modifiedProperties.size === 0) {
442
442
  return;
443
443
  }
444
444
  const only = keys ? new Set(keys) : null;
@@ -13,7 +13,7 @@ import { InstanceCache, ModelScope } from './InstanceCache.js';
13
13
  import { Model } from './Model.js';
14
14
  import type { ModelData } from '@abloatai/transaction/types/modelData';
15
15
  import type { AppliedChange } from '../plugin.js';
16
- import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
16
+ import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
17
17
  // ModelRegistry instance accessed via this.objectPool.registry
18
18
  import { LoadStrategy } from '@abloatai/transaction/types';
19
19
  import { globalRuntime } from './context.js';
@@ -1938,6 +1938,27 @@ export class SyncClient extends EventEmitter {
1938
1938
  // otherwise re-add it for the brief window before the matching delete
1939
1939
  // confirmation lands.
1940
1940
  if (this.echoTracker.consumeEcho(transactionId)) {
1941
+ // A direct assignment can re-enter change tracking while this
1942
+ // optimistic write is in flight. Leaving the acknowledged field dirty
1943
+ // makes conflict resolution preserve it over the next collaborator
1944
+ // delta, so peers appear desynchronized until refresh.
1945
+ //
1946
+ // Re-baseline only values this echo actually confirms. If the user has
1947
+ // edited the same field again since the write was sent, its current
1948
+ // dirty value differs from the echo and remains queued.
1949
+ if (resident && result.data) {
1950
+ const acknowledgedFields: string[] = [];
1951
+ for (const [field, change] of resident.modifiedProperties) {
1952
+ if (
1953
+ Object.prototype.hasOwnProperty.call(result.data, field) &&
1954
+ deepEqual(change.new, result.data[field])
1955
+ ) {
1956
+ acknowledgedFields.push(field);
1957
+ }
1958
+ }
1959
+ resident.consumeModifiedFields(acknowledgedFields);
1960
+ resident.markAsSynced();
1961
+ }
1941
1962
  continue;
1942
1963
  }
1943
1964
 
@@ -319,7 +319,7 @@ export class SyncWebSocket<
319
319
  /**
320
320
  * Public wrapper for sending ack from outside the class
321
321
  */
322
- acknowledge(syncId: number): void {
322
+ override acknowledge(syncId: number): void {
323
323
  this.sendAck(syncId);
324
324
  }
325
325
 
@@ -102,7 +102,6 @@ export interface DeltaPipelineContext {
102
102
  };
103
103
 
104
104
  // ── Dynamic-dispatch hooks back into the store (protected override points) ──
105
- getStateFields(modelName: string): string[];
106
105
  isCustomEntity(modelName: string): boolean;
107
106
  createCustomEntity(modelName: string, modelId: string, data: Record<string, unknown>): Model | null;
108
107
  deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
@@ -159,91 +158,36 @@ export function handleGroupHandlerFailure(
159
158
  }
160
159
  }
161
160
 
162
- /** Builds a small signature of a delta's state fields, used to detect no-op duplicate deltas. */
163
- function extractStateSignature(
164
- ctx: DeltaPipelineContext,
165
- delta: SyncDelta,
166
- ): Record<string, unknown> | null {
167
- if (!delta.data || typeof delta.data !== 'object') return null;
168
-
169
- const data = typeof delta.data === 'string'
170
- ? (JSON.parse(delta.data) as Record<string, unknown>)
171
- : (delta.data);
172
-
173
- // Generic state fields subclasses can override getStateFields() for model-specific fields
174
- const fieldsToCheck = ctx.getStateFields(delta.modelName);
175
- const signature: Record<string, unknown> = {
176
- actionType: delta.actionType,
177
- modelName: delta.modelName,
178
- };
179
-
180
- for (const field of fieldsToCheck) {
181
- if (field in data) signature[field] = data[field];
182
- }
183
-
184
- return signature;
185
- }
186
-
187
- function isSameState(a: Record<string, unknown> | null, b: Record<string, unknown> | null): boolean {
188
- if (!a || !b) return false;
189
- const keys = Object.keys(a);
190
- if (keys.length !== Object.keys(b).length) return false;
191
- return keys.every((k) => a[k] === b[k]);
192
- }
161
+ /**
162
+ * Deduplicate repeated delivery of the same log entry.
163
+ *
164
+ * A row may legitimately change several times in one receive frame. Those
165
+ * changes are ordered facts, even when a small subset of fields (such as
166
+ * `status`) happens to remain equal. Collapsing by entity or a partial state
167
+ * signature can therefore discard the newest row image while the cursor still
168
+ * advances past it. Only an identical positive sync id proves duplicate
169
+ * delivery; non-positive ids carry no usable log identity and stay untouched.
170
+ */
171
+ export function deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
172
+ if (deltas.length < 2 || deltas.some((delta) => delta.id <= 0)) return deltas;
193
173
 
194
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
195
- export function deduplicateDeltas(ctx: DeltaPipelineContext, deltas: SyncDelta[]): SyncDelta[] {
196
- // The dominant live-publication shape is a frame of independent entity
197
- // creates. When every entity key occurs once, reconciliation cannot remove
198
- // or reorder anything: preserve the already commit-ordered input directly
199
- // and avoid allocating a bucket array, state signature, and two sorts per
200
- // delta. The first duplicate falls through to the full transition logic.
201
- const uniqueEntities = new Set<string>();
202
- let hasDuplicateEntity = false;
203
- for (const delta of deltas) {
204
- const key = `${delta.modelName}:${delta.modelId}`;
205
- if (uniqueEntities.has(key)) {
206
- hasDuplicateEntity = true;
174
+ let strictlyOrdered = true;
175
+ for (let index = 1; index < deltas.length; index += 1) {
176
+ if (deltas[index - 1]!.id >= deltas[index]!.id) {
177
+ strictlyOrdered = false;
207
178
  break;
208
179
  }
209
- uniqueEntities.add(key);
210
- }
211
- if (!hasDuplicateEntity) return deltas;
212
-
213
- const byEntity = new Map<string, SyncDelta[]>();
214
- for (const d of deltas) {
215
- const key = `${d.modelName}:${d.modelId}`;
216
- if (!byEntity.has(key)) byEntity.set(key, []);
217
- byEntity.get(key)!.push(d);
218
- }
219
-
220
- const result: SyncDelta[] = [];
221
- for (const entityDeltas of byEntity.values()) {
222
- const sorted = entityDeltas.sort((a, b) => a.id - b.id);
223
-
224
- // DELETE wins — it's the final state
225
- const del = sorted.find((d) => d.actionType === 'D');
226
- if (del) { result.push(del); continue; }
227
-
228
- // Keep deltas that represent different states
229
- const unique: SyncDelta[] = [];
230
- let prev: Record<string, unknown> | null = null;
231
- for (const d of sorted) {
232
- const sig = extractStateSignature(ctx, d);
233
- if (!isSameState(prev, sig)) { unique.push(d); prev = sig; }
234
- }
235
-
236
- if (unique.length > 0) {
237
- result.push(...unique);
238
- } else {
239
- // `sorted` is never empty (every byEntity bucket gets at least one
240
- // delta pushed) — the guard only narrows the indexed access.
241
- const last = sorted.at(-1);
242
- if (last) result.push(last);
243
- }
244
180
  }
245
-
246
- return result.sort((a, b) => a.id - b.id);
181
+ if (strictlyOrdered) return deltas;
182
+
183
+ const seen = new Set<number>();
184
+ return [...deltas]
185
+ .sort((a, b) => a.id - b.id)
186
+ .filter((delta) => {
187
+ if (seen.has(delta.id)) return false;
188
+ seen.add(delta.id);
189
+ return true;
190
+ });
247
191
  }
248
192
 
249
193
  /**
@@ -2,8 +2,8 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
2
2
  import type { MutationQueueConfig } from './MutationQueue.js';
3
3
  import type { QueuedMutation } from './commitPayload.js';
4
4
  import type { MutationStore } from './MutationStore.js';
5
- import { AbloError } from '@abloatai/transaction/errors';
6
5
  import { extractStatusCode } from './commitPayload.js';
6
+ import { reportPermanentMutationFailure } from './failureReporting.js';
7
7
 
8
8
  export interface FailureHandlingContext {
9
9
  readonly runtime: RuntimeContext;
@@ -13,7 +13,11 @@ export interface FailureHandlingContext {
13
13
  >;
14
14
  readonly store: MutationStore;
15
15
  readonly isPermanentError: (error: Error) => boolean;
16
- readonly rollbackOptimistic: (transaction: QueuedMutation, reason: string, error?: Error) => Promise<void>;
16
+ readonly rollbackOptimistic: (
17
+ transaction: QueuedMutation,
18
+ reason: string,
19
+ error?: Error,
20
+ ) => Promise<void>;
17
21
  readonly enqueue: (transaction: QueuedMutation) => void;
18
22
  readonly getLastPermanentErrorSignature: () => string | undefined;
19
23
  readonly setLastPermanentErrorSignature: (signature: string) => void;
@@ -35,138 +39,75 @@ export function transientRetryDelayMs(
35
39
  return Math.floor(Math.random() * ceiling);
36
40
  }
37
41
 
38
- export async function handleFailure(ctx: FailureHandlingContext, transaction: QueuedMutation, error: Error): Promise<void> {
39
- transaction.attempts++;
40
-
41
- // Check whether this is a permanent error that should not be retried.
42
- if (ctx.isPermanentError(error)) {
43
- // Logged at warn: a permanent error means the server rejected the write,
44
- // so the developer should see the reason in the console. The typed
45
- // AbloError fields (`type`, `code`, `httpStatus`) are included so the
46
- // cause is visible — for example a foreign-key violation
47
- // (AbloValidationError) versus expired authentication
48
- // (AbloAuthenticationError).
49
- try {
50
- const abloErr = error instanceof AbloError ? error : undefined;
51
- const details = {
52
- txId: transaction.id.slice(0, 8),
53
- type: transaction.type,
54
- model: transaction.modelName,
55
- modelId: transaction.modelId.slice(0, 12),
56
- errorType: abloErr?.type ?? error?.name,
57
- errorCode: abloErr?.code,
58
- httpStatus: abloErr?.httpStatus,
59
- requestId: abloErr?.requestId,
60
- message: error?.message,
61
- inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
62
- };
63
-
64
- // A `create` whose id already exists is the benign idempotency case:
65
- // "this row is already there." It's the least alarming permanent
66
- // error, so it doesn't warrant a `warn` — `info` keeps it visible
67
- // without crying wolf. Everything else (FK violation, auth expiry,
68
- // server 500) stays at `warn`.
69
- const isBenignIdempotent =
70
- transaction.type === 'create' &&
71
- (abloErr?.code === 'unique_violation' ||
72
- abloErr?.type === 'AbloIdempotencyError');
73
-
74
- // Demote exact repeats (same write rejected for the same reason on
75
- // each reconnect replay) to `debug` so the loop logs once.
76
- const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
77
- const isRepeat = sig === ctx.getLastPermanentErrorSignature();
78
- ctx.setLastPermanentErrorSignature(sig);
79
-
80
- const logger = ctx.runtime.logger;
81
-
82
- // Two registers from one call site, split by log level (the default
83
- // logger is gated at `warn`, so `debug` stays hidden unless
84
- // ABLO_LOG_LEVEL=debug is set to inspect the engine):
85
- // - the default-visible line speaks the application developer's
86
- // language: their verb (such as `update`), their model, the typed
87
- // error's own message, and the wire `code` for searching. It uses
88
- // no engine jargon and prints no JSON dump, which would alarm
89
- // without helping.
90
- // - the forensic `details` ride a companion `debug` line for anyone
91
- // debugging the engine internals.
92
- const revertNote = ctx.config.enableOptimistic
93
- ? ' The local change was reverted.'
94
- : '';
95
- const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
96
- const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
97
- const requestRef = abloErr?.requestId
98
- ? ` [request_id: ${abloErr.requestId}]`
99
- : '';
100
- // An optimistic write resolves before the server answers, so a later
101
- // rejection has no caller left to return to and this log is the only
102
- // place it appears. That reads to an application developer as their own
103
- // save silently failing — the write showed, then vanished — and sends
104
- // them into their editor instead of here. Name the subscription that
105
- // hands them the same typed error, so the application can say what
106
- // happened rather than only the console.
107
- const channelNote = ctx.config.enableOptimistic
108
- ? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
109
- : '';
110
- const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestRef}.${revertNote}${channelNote}`;
111
-
112
- if (isRepeat) {
113
- // Same write rejected for the same reason on each reconnect replay —
114
- // log the forensics once, stay quiet after.
115
- logger.debug('write rejected again (same reason)', details);
116
- } else if (isBenignIdempotent) {
117
- // Already-exists on a `create` is expected on replay, not a problem.
118
- logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
119
- logger.debug('idempotent skip — details', details);
120
- } else {
121
- logger.warn(headline);
122
- logger.debug('write rejection — details', details);
123
- }
124
- } catch {}
125
-
126
- // Mark as failed immediately and rollback
127
- ctx.store.updateStatus(transaction.id, 'failed');
128
-
129
- if (ctx.config.enableOptimistic) {
130
- await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
131
- }
132
-
133
- ctx.emit('transaction:failed', { transaction, error, permanent: true });
134
- // The id-suffixed event is what the awaited model-write promise listens
135
- // on through `waitForConfirmation` — without it a permanently
136
- // rejected write left the caller's promise hanging forever.
137
- ctx.emit(`transaction:failed:${transaction.id}`, { error });
138
- return;
42
+ export async function handleFailure(
43
+ ctx: FailureHandlingContext,
44
+ transaction: QueuedMutation,
45
+ error: Error,
46
+ ): Promise<void> {
47
+ transaction.attempts++;
48
+
49
+ // Check whether this is a permanent error that should not be retried.
50
+ if (ctx.isPermanentError(error)) {
51
+ reportPermanentMutationFailure(
52
+ {
53
+ runtime: ctx.runtime,
54
+ enableOptimistic: ctx.config.enableOptimistic,
55
+ getLastPermanentErrorSignature: ctx.getLastPermanentErrorSignature,
56
+ setLastPermanentErrorSignature: ctx.setLastPermanentErrorSignature,
57
+ },
58
+ transaction,
59
+ error,
60
+ );
61
+
62
+ // Mark as failed immediately and rollback
63
+ ctx.store.updateStatus(transaction.id, 'failed');
64
+
65
+ if (ctx.config.enableOptimistic) {
66
+ await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
139
67
  }
140
68
 
141
- transaction.firstTransientFailureAt ??= Date.now();
142
- const insideAvailabilityWindow =
143
- Date.now() - transaction.firstTransientFailureAt < ctx.config.availabilityRetryWindowMs;
144
-
145
- if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
146
- // Exponential backoff with full jitter on every transient retry:
147
- // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
148
- // (429/503) use a longer base than other transient errors. The re-enqueue
149
- // is scheduled rather than awaited, so one backing-off transaction cannot
150
- // stall unrelated commits.
151
- const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
152
-
153
- ctx.store.updateStatus(transaction.id, 'pending');
154
- setTimeout(() => {
155
- // The queue may have shut down or the tx may have been settled
156
- // (e.g. delta-confirmed) while we backed off.
157
- if (ctx.store.get(transaction.id)?.status !== 'pending') return;
158
- ctx.enqueue(transaction);
159
- }, delay);
160
- } else {
161
- // Mark as failed and rollback
162
- ctx.store.updateStatus(transaction.id, 'failed');
163
-
164
- if (ctx.config.enableOptimistic) {
165
- await ctx.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
166
- }
69
+ ctx.emit('transaction:failed', { transaction, error, permanent: true });
70
+ // The id-suffixed event is what the awaited model-write promise listens
71
+ // on through `waitForConfirmation` — without it a permanently
72
+ // rejected write left the caller's promise hanging forever.
73
+ ctx.emit(`transaction:failed:${transaction.id}`, { error });
74
+ return;
75
+ }
167
76
 
168
- ctx.emit('transaction:failed', { transaction, error });
169
- // Settle `waitForConfirmation` waiters (see the permanent branch above).
170
- ctx.emit(`transaction:failed:${transaction.id}`, { error });
77
+ transaction.firstTransientFailureAt ??= Date.now();
78
+ const insideAvailabilityWindow =
79
+ Date.now() - transaction.firstTransientFailureAt <
80
+ ctx.config.availabilityRetryWindowMs;
81
+
82
+ if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
83
+ // Exponential backoff with full jitter on every transient retry:
84
+ // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
85
+ // (429/503) use a longer base than other transient errors. The re-enqueue
86
+ // is scheduled rather than awaited, so one backing-off transaction cannot
87
+ // stall unrelated commits.
88
+ const delay = transientRetryDelayMs(
89
+ error,
90
+ transaction.attempts,
91
+ ctx.config.retryBackoff,
92
+ );
93
+
94
+ ctx.store.updateStatus(transaction.id, 'pending');
95
+ setTimeout(() => {
96
+ // The queue may have shut down or the tx may have been settled
97
+ // (e.g. delta-confirmed) while we backed off.
98
+ if (ctx.store.get(transaction.id)?.status !== 'pending') return;
99
+ ctx.enqueue(transaction);
100
+ }, delay);
101
+ } else {
102
+ // Mark as failed and rollback
103
+ ctx.store.updateStatus(transaction.id, 'failed');
104
+
105
+ if (ctx.config.enableOptimistic) {
106
+ await ctx.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
171
107
  }
108
+
109
+ ctx.emit('transaction:failed', { transaction, error });
110
+ // Settle `waitForConfirmation` waiters (see the permanent branch above).
111
+ ctx.emit(`transaction:failed:${transaction.id}`, { error });
172
112
  }
113
+ }