@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.
- package/dist/humans.d.ts +1 -1
- package/dist/local/BaseSyncedStore.d.ts +1 -3
- package/dist/local/BaseSyncedStore.js +2 -7
- package/dist/local/Database.d.ts +2 -2
- package/dist/local/LazyReferenceCollection.d.ts +1 -1
- package/dist/local/Model.js +2 -2
- package/dist/local/SyncClient.d.ts +4 -4
- package/dist/local/SyncClient.js +20 -1
- package/dist/local/stores/syncAction.d.ts +4 -4
- package/dist/local/sync/deltaPipeline.d.ts +11 -3
- package/dist/local/sync/deltaPipeline.js +27 -80
- package/dist/local/sync/schemas.d.ts +10 -10
- package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
- package/dist/local/transactions/mutations/commitPayload.d.ts +1 -1
- package/dist/local/transactions/mutations/failureHandling.js +9 -81
- package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
- package/dist/local/transactions/mutations/failureReporting.js +67 -0
- package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
- package/dist/react/AbloProvider.js +1 -1
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/createAbloReact.js +1 -1
- package/dist/surface.d.ts +3 -3
- package/package.json +2 -2
- package/src/local/BaseSyncedStore.ts +2 -8
- package/src/local/Model.ts +2 -2
- package/src/local/SyncClient.ts +22 -1
- package/src/local/sync/SyncWebSocket.ts +1 -1
- package/src/local/sync/deltaPipeline.ts +26 -82
- package/src/local/transactions/mutations/failureHandling.ts +73 -132
- 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
|
-
|
|
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
|
-
|
|
98
|
+
archive: "archive";
|
|
99
99
|
create: "create";
|
|
100
100
|
delete: "delete";
|
|
101
|
-
|
|
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
|
-
|
|
140
|
+
archive: "archive";
|
|
142
141
|
create: "create";
|
|
143
142
|
delete: "delete";
|
|
144
|
-
|
|
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
|
-
|
|
177
|
+
archive: "archive";
|
|
184
178
|
create: "create";
|
|
185
179
|
delete: "delete";
|
|
186
|
-
|
|
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 {
|
|
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(
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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(
|
|
1611
|
+
return deltaPipeline.deduplicateDeltas(deltas);
|
|
1618
1612
|
}
|
|
1619
1613
|
|
|
1620
1614
|
/** Process incoming delta with smart batching */
|
package/src/local/Model.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
441
|
+
if (this.modifiedProperties.size === 0) {
|
|
442
442
|
return;
|
|
443
443
|
}
|
|
444
444
|
const only = keys ? new Set(keys) : null;
|
package/src/local/SyncClient.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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: (
|
|
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(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
}
|