@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.
- 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/interfaces/index.d.ts +1 -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/batchProcessing.js +5 -1
- package/dist/local/transactions/mutations/commitPayload.d.ts +3 -1
- package/dist/local/transactions/mutations/commitPayload.js +14 -0
- 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/pendingDrain.js +5 -1
- package/dist/local/transactions/mutations/replayValidation.d.ts +51 -15
- package/dist/local/transactions/mutations/replayValidation.js +2 -0
- 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/interfaces/index.ts +1 -1
- package/src/local/sync/SyncWebSocket.ts +1 -1
- package/src/local/sync/deltaPipeline.ts +26 -82
- package/src/local/transactions/mutations/batchProcessing.ts +5 -1
- package/src/local/transactions/mutations/commitPayload.ts +17 -0
- package/src/local/transactions/mutations/failureHandling.ts +73 -132
- package/src/local/transactions/mutations/failureReporting.ts +93 -0
- package/src/local/transactions/mutations/pendingDrain.ts +5 -1
- package/src/local/transactions/mutations/replayValidation.ts +2 -0
package/dist/humans.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
1206
|
+
return deltaPipeline.deduplicateDeltas(deltas);
|
|
1212
1207
|
}
|
|
1213
1208
|
/** Process incoming delta with smart batching */
|
|
1214
1209
|
processDeltaWithBatching(delta) {
|
package/dist/local/Database.d.ts
CHANGED
|
@@ -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 |
|
|
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
|
|
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
|
|
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
|
package/dist/local/Model.js
CHANGED
|
@@ -306,7 +306,7 @@ export class Model {
|
|
|
306
306
|
*/
|
|
307
307
|
capturePreviousValues(keys, opts) {
|
|
308
308
|
const out = {};
|
|
309
|
-
const modified = this.modifiedProperties
|
|
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 (
|
|
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" | "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
434
|
+
type: "archive" | "create" | "delete" | "unarchive" | "update";
|
|
435
435
|
modelName: string;
|
|
436
436
|
modelId: string;
|
|
437
437
|
}[];
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -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
|
-
/**
|
|
98
|
-
|
|
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
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
75
|
+
if (strictlyOrdered)
|
|
100
76
|
return deltas;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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[
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 <
|
|
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;
|