@abloatai/ablo 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/BaseSyncedStore.d.ts +72 -195
- package/dist/BaseSyncedStore.js +240 -625
- package/dist/Database.d.ts +8 -9
- package/dist/Database.js +9 -14
- package/dist/LazyReferenceCollection.js +5 -0
- package/dist/Model.js +6 -8
- package/dist/ModelRegistry.d.ts +25 -6
- package/dist/ModelRegistry.js +12 -6
- package/dist/NetworkMonitor.js +1 -1
- package/dist/ObjectPool.js +9 -3
- package/dist/SyncClient.d.ts +15 -5
- package/dist/SyncClient.js +152 -66
- package/dist/SyncEngineContext.d.ts +1 -3
- package/dist/SyncEngineContext.js +1 -2
- package/dist/agent/Agent.d.ts +21 -8
- package/dist/agent/Agent.js +26 -13
- package/dist/ai-sdk/coordination-context.js +9 -3
- package/dist/ai-sdk/wrap.d.ts +1 -1
- package/dist/ai-sdk/wrap.js +1 -1
- package/dist/auth/credentialPolicy.d.ts +10 -2
- package/dist/auth/credentialSource.d.ts +1 -13
- package/dist/auth/credentialSource.js +6 -13
- package/dist/auth/index.js +3 -3
- package/dist/cli.cjs +328 -262
- package/dist/client/Ablo.d.ts +35 -764
- package/dist/client/Ablo.js +64 -688
- package/dist/client/ApiClient.d.ts +14 -1
- package/dist/client/ApiClient.js +63 -9
- package/dist/client/auth.d.ts +8 -17
- package/dist/client/auth.js +42 -6
- package/dist/client/consoleLogger.d.ts +36 -0
- package/dist/client/consoleLogger.js +45 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.js +10 -6
- package/dist/client/credentialEndpoint.d.ts +63 -0
- package/dist/client/credentialEndpoint.js +87 -0
- package/dist/client/hostedEndpoints.d.ts +24 -0
- package/dist/client/hostedEndpoints.js +24 -0
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/identity.js +2 -1
- package/dist/client/modelRegistration.d.ts +14 -0
- package/dist/client/modelRegistration.js +321 -0
- package/dist/client/options.d.ts +396 -0
- package/dist/client/options.js +10 -0
- package/dist/client/resourceTypes.d.ts +344 -0
- package/dist/client/resourceTypes.js +13 -0
- package/dist/client/schemaConfig.d.ts +56 -0
- package/dist/client/schemaConfig.js +188 -0
- package/dist/client/sessionMint.d.ts +1 -1
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/client/wsMutationExecutor.d.ts +33 -0
- package/dist/client/wsMutationExecutor.js +77 -0
- package/dist/context.js +0 -3
- package/dist/coordination/index.d.ts +8 -1
- package/dist/coordination/index.js +24 -1
- package/dist/coordination/trace.d.ts +0 -1
- package/dist/coordination/trace.js +0 -1
- package/dist/core/DatabaseManager.d.ts +0 -1
- package/dist/core/DatabaseManager.js +26 -24
- package/dist/core/QueryProcessor.js +7 -8
- package/dist/core/QueryView.d.ts +14 -2
- package/dist/core/QueryView.js +4 -5
- package/dist/core/StoreManager.d.ts +2 -2
- package/dist/core/StoreManager.js +5 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/openIDBWithTimeout.js +15 -11
- package/dist/core/query-utils.js +1 -1
- package/dist/core/storeContract.d.ts +143 -0
- package/dist/core/storeContract.js +16 -0
- package/dist/errorCodes.d.ts +11 -2
- package/dist/errorCodes.js +155 -137
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +14 -1
- package/dist/index.js +5 -0
- package/dist/interfaces/index.d.ts +6 -20
- package/dist/keys/index.js +18 -9
- package/dist/mutators/UndoManager.js +6 -1
- package/dist/mutators/defineMutators.d.ts +1 -3
- package/dist/policy/types.d.ts +1 -1
- package/dist/query/client.d.ts +11 -0
- package/dist/query/client.js +81 -52
- package/dist/react/AbloProvider.d.ts +6 -10
- package/dist/react/AbloProvider.js +22 -15
- package/dist/react/context.d.ts +2 -118
- package/dist/react/useAblo.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react/useMutators.js +7 -1
- package/dist/react/useReactive.js +0 -1
- package/dist/react/useUndoScope.js +2 -2
- package/dist/schema/ddlLock.d.ts +39 -0
- package/dist/schema/ddlLock.js +51 -0
- package/dist/schema/field.js +1 -1
- package/dist/schema/index.d.ts +4 -3
- package/dist/schema/index.js +15 -5
- package/dist/schema/model.d.ts +3 -3
- package/dist/schema/model.js +2 -2
- package/dist/schema/openapi.js +5 -1
- package/dist/schema/queries.d.ts +1 -1
- package/dist/schema/queries.js +1 -0
- package/dist/schema/residency.d.ts +35 -0
- package/dist/schema/residency.js +27 -0
- package/dist/schema/roles.d.ts +25 -0
- package/dist/schema/roles.js +37 -0
- package/dist/schema/schema.d.ts +3 -3
- package/dist/schema/schema.js +11 -7
- package/dist/schema/serialize.d.ts +8 -8
- package/dist/schema/sync-delta-row.d.ts +2 -2
- package/dist/schema/sync-delta-row.js +2 -2
- package/dist/server/storage-mode.d.ts +8 -1
- package/dist/server/storage-mode.js +14 -4
- package/dist/source/adapter.d.ts +1 -1
- package/dist/source/adapters/drizzle.js +4 -3
- package/dist/source/adapters/kysely.js +4 -3
- package/dist/source/adapters/memory.js +1 -1
- package/dist/source/adapters/prisma.js +5 -4
- package/dist/source/conformance.js +10 -8
- package/dist/source/connector.js +4 -2
- package/dist/source/factory.d.ts +106 -0
- package/dist/source/factory.js +273 -0
- package/dist/source/index.d.ts +11 -461
- package/dist/source/index.js +14 -416
- package/dist/source/next.d.ts +1 -1
- package/dist/source/next.js +1 -1
- package/dist/source/pushQueue.d.ts +1 -1
- package/dist/source/pushQueue.js +6 -3
- package/dist/source/signing.d.ts +59 -0
- package/dist/source/signing.js +142 -0
- package/dist/source/types.d.ts +334 -0
- package/dist/source/types.js +44 -0
- package/dist/stores/ObjectStore.d.ts +1 -1
- package/dist/stores/ObjectStore.js +23 -23
- package/dist/stores/SyncActionStore.d.ts +1 -1
- package/dist/stores/SyncActionStore.js +70 -35
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/dist/sync/AreaOfInterestManager.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.js +1 -1
- package/dist/sync/BootstrapHelper.d.ts +1 -3
- package/dist/sync/BootstrapHelper.js +29 -8
- package/dist/sync/ConnectionManager.d.ts +8 -0
- package/dist/sync/ConnectionManager.js +19 -4
- package/dist/sync/HydrationCoordinator.d.ts +12 -0
- package/dist/sync/HydrationCoordinator.js +25 -5
- package/dist/sync/NetworkProbe.d.ts +3 -2
- package/dist/sync/NetworkProbe.js +7 -6
- package/dist/sync/SyncWebSocket.d.ts +71 -120
- package/dist/sync/SyncWebSocket.js +324 -630
- package/dist/sync/awaitClaimGrant.js +27 -19
- package/dist/sync/bootstrapApply.d.ts +60 -0
- package/dist/sync/bootstrapApply.js +65 -0
- package/dist/sync/commitFrames.d.ts +43 -0
- package/dist/sync/commitFrames.js +94 -0
- package/dist/sync/createClaimStream.js +3 -2
- package/dist/sync/createSnapshot.d.ts +1 -3
- package/dist/sync/credentialLifecycle.d.ts +175 -0
- package/dist/sync/credentialLifecycle.js +329 -0
- package/dist/sync/deltaPipeline.d.ts +111 -0
- package/dist/sync/deltaPipeline.js +262 -0
- package/dist/sync/groupChange.d.ts +111 -0
- package/dist/sync/groupChange.js +246 -0
- package/dist/sync/heartbeat.d.ts +62 -0
- package/dist/sync/heartbeat.js +91 -0
- package/dist/sync/participants.d.ts +8 -8
- package/dist/sync/syncCursor.d.ts +44 -0
- package/dist/sync/syncCursor.js +59 -0
- package/dist/sync/syncPlan.d.ts +62 -0
- package/dist/sync/syncPlan.js +51 -0
- package/dist/sync/wsFrameHandlers.d.ts +117 -0
- package/dist/sync/wsFrameHandlers.js +378 -0
- package/dist/testing/fixtures/bootstrap.d.ts +9 -11
- package/dist/testing/fixtures/deltas.d.ts +1 -1
- package/dist/testing/fixtures/models.js +1 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
- package/dist/testing/helpers/sync-engine-harness.js +2 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
- package/dist/testing/mocks/MockSyncContext.js +1 -32
- package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
- package/dist/transactions/TransactionQueue.d.ts +20 -56
- package/dist/transactions/TransactionQueue.js +101 -487
- package/dist/transactions/TransactionStore.d.ts +18 -0
- package/dist/transactions/TransactionStore.js +51 -0
- package/dist/transactions/coalesceRules.d.ts +34 -0
- package/dist/transactions/coalesceRules.js +117 -0
- package/dist/transactions/commitPayload.d.ts +134 -0
- package/dist/transactions/commitPayload.js +152 -0
- package/dist/transactions/deltaConfirmation.d.ts +60 -0
- package/dist/transactions/deltaConfirmation.js +223 -0
- package/dist/transactions/optimistic.d.ts +24 -0
- package/dist/transactions/optimistic.js +45 -0
- package/dist/transactions/persistedReplay.d.ts +93 -0
- package/dist/transactions/persistedReplay.js +105 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.js +0 -5
- package/dist/types/modelData.d.ts +12 -0
- package/dist/types/modelData.js +11 -0
- package/dist/types/participant.d.ts +21 -0
- package/dist/types/participant.js +12 -0
- package/dist/types/streams.d.ts +8 -14
- package/dist/utils/mobx-setup.d.ts +1 -1
- package/dist/utils/mobx-setup.js +2 -2
- package/dist/wire/errorEnvelope.d.ts +11 -2
- package/dist/wire/errorEnvelope.js +14 -2
- package/dist/wire/frames.d.ts +90 -0
- package/dist/wire/frames.js +55 -1
- package/dist/wire/index.d.ts +4 -1
- package/dist/wire/index.js +20 -1
- package/dist/wire/protocol.d.ts +45 -0
- package/dist/wire/protocol.js +45 -0
- package/dist/wire/protocolVersion.d.ts +56 -0
- package/dist/wire/protocolVersion.js +63 -0
- package/docs/api-keys.md +4 -3
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/identity.md +4 -4
- package/docs/integration-guide.md +1 -1
- package/docs/react.md +1 -1
- package/docs/sessions.md +5 -7
- package/package.json +15 -13
- package/dist/client/index.d.ts +0 -36
- package/dist/client/index.js +0 -33
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -12
- package/dist/interfaces/headless.d.ts +0 -95
- package/dist/interfaces/headless.js +0 -41
- package/dist/query/index.d.ts +0 -6
- package/dist/query/index.js +0 -5
- package/dist/realtime/index.d.ts +0 -10
- package/dist/realtime/index.js +0 -9
- package/dist/schema/plane.d.ts +0 -23
- package/dist/schema/plane.js +0 -19
- package/dist/server/next.d.ts +0 -51
- package/dist/server/next.js +0 -47
- package/dist/sync/OfflineFlush.d.ts +0 -9
- package/dist/sync/OfflineFlush.js +0 -22
- package/dist/sync/OfflineTransactionStore.d.ts +0 -37
- package/dist/sync/OfflineTransactionStore.js +0 -263
- package/dist/transactions/index.d.ts +0 -16
- package/dist/transactions/index.js +0 -7
|
@@ -8,195 +8,17 @@
|
|
|
8
8
|
* - Proper dependency injection (no singleton)
|
|
9
9
|
*/
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
|
+
import { Model } from '../Model.js';
|
|
11
12
|
import { getContext } from '../context.js';
|
|
12
|
-
import { getActiveRegistry } from '../ModelRegistry.js';
|
|
13
|
-
import { MutationOperationType } from '../types/index.js';
|
|
14
13
|
import { handleMutationError } from './mutation-error-handler.js';
|
|
15
14
|
import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
|
|
16
15
|
import { SyncPosition } from '../sync/syncPosition.js';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Project a Model's serialized data onto its schema-declared fields
|
|
25
|
-
* and return a wire-safe commit payload. Two jobs:
|
|
26
|
-
*
|
|
27
|
-
* 1. Drop framework internals (`__class`, `__typename`, `clientId`,
|
|
28
|
-
* `syncStatus`) and anything not declared on the model's schema.
|
|
29
|
-
* 2. JSON.stringify values typed as `field.json()` — TEXT columns
|
|
30
|
-
* storing JSON need explicit stringification; postgres.js won't
|
|
31
|
-
* auto-serialize for non-JSONB columns.
|
|
32
|
-
*
|
|
33
|
-
* For updates (`dropUndefined: true`), `undefined` values are also
|
|
34
|
-
* stripped so they don't translate to `SET column = NULL` on the
|
|
35
|
-
* server side.
|
|
36
|
-
*
|
|
37
|
-
* Fields are read from `ModelRegistry`, populated by
|
|
38
|
-
* `registerModelsFromSchema` at SDK initialization. If the model
|
|
39
|
-
* isn't registered with field metadata (edge case — e.g., tests or
|
|
40
|
-
* manually registered models), projection falls back to identity and
|
|
41
|
-
* the caller gets whatever the Model serialized.
|
|
42
|
-
*/
|
|
43
|
-
function projectCommitPayload(modelName, source, opts) {
|
|
44
|
-
const metadata = getActiveRegistry().getMetadata(modelName);
|
|
45
|
-
const fields = metadata?.fields;
|
|
46
|
-
const out = {};
|
|
47
|
-
if (!fields) {
|
|
48
|
-
// Unknown registration — strip framework keys and ship the rest.
|
|
49
|
-
for (const [k, v] of Object.entries(source)) {
|
|
50
|
-
if (FRAMEWORK_KEYS.has(k))
|
|
51
|
-
continue;
|
|
52
|
-
if (opts.dropUndefined && v === undefined)
|
|
53
|
-
continue;
|
|
54
|
-
out[k] = v;
|
|
55
|
-
}
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
for (const [key, meta] of Object.entries(fields)) {
|
|
59
|
-
if (!(key in source))
|
|
60
|
-
continue;
|
|
61
|
-
const value = source[key];
|
|
62
|
-
if (opts.dropUndefined && value === undefined)
|
|
63
|
-
continue;
|
|
64
|
-
// JSON-typed fields (`jsonb` on the server): ship as OBJECTS over
|
|
65
|
-
// the wire, not pre-stringified strings. Previously we stringified
|
|
66
|
-
// here, which round-tripped incorrectly:
|
|
67
|
-
//
|
|
68
|
-
// 1. Client stringifies `position: {x, y}` → `'{"x":...}'`
|
|
69
|
-
// 2. Server writes to jsonb column (parses string → jsonb object, fine)
|
|
70
|
-
// 3. Server's delta echoes `data: JSON.stringify(op.input)` where
|
|
71
|
-
// `op.input.position` is still the STRING from step 1
|
|
72
|
-
// 4. Client merges delta → `model.position = "{...}"` (STRING)
|
|
73
|
-
// 5. Next drag: `{ ...layer.position, x, y }` spreads the STRING
|
|
74
|
-
// char-by-char, producing corrupted char-indexed objects like
|
|
75
|
-
// `{"0":"{","1":"\"","2":"x",...,"x":null,"y":null,...}`
|
|
76
|
-
// 6. That corrupt object lands in the next commit, stored in jsonb.
|
|
77
|
-
//
|
|
78
|
-
// Sending objects avoids the round-trip mismatch: the wire carries
|
|
79
|
-
// the object through delta + commit unchanged, and `postgres-js`
|
|
80
|
-
// serializes JS objects to jsonb correctly via its own
|
|
81
|
-
// `json.serialize` (triggered by Postgres's ParameterDescription
|
|
82
|
-
// response identifying the column as type 3802 / jsonb).
|
|
83
|
-
out[key] = value;
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
|
-
const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
|
|
88
|
-
const stripModelSuffix = (modelName) => modelName.replace('Model', '');
|
|
89
|
-
/**
|
|
90
|
-
* FK-ordered create priority.
|
|
91
|
-
*
|
|
92
|
-
* Reads `config.modelCreatePriority` out of the runtime SyncEngineContext —
|
|
93
|
-
* this map is populated once at `createSyncEngine(...)` time by walking the
|
|
94
|
-
* schema's `belongsTo` graph (see `computeFKDepthPriority` in
|
|
95
|
-
* `client/createSyncEngine.ts`). The queue stays schema-agnostic: no model
|
|
96
|
-
* names appear here, and consumer applications can override specific
|
|
97
|
-
* priorities via `configOverrides.modelCreatePriority` without touching the
|
|
98
|
-
* SDK.
|
|
99
|
-
*
|
|
100
|
-
* Non-create ops (update/delete/archive/unarchive) don't need FK ordering
|
|
101
|
-
* because the row already exists, so they all share
|
|
102
|
-
* `config.defaultNonCreatePriority`.
|
|
103
|
-
*/
|
|
104
|
-
const computePriorityScore = (type, modelName) => {
|
|
105
|
-
const { modelCreatePriority, defaultCreatePriority, defaultNonCreatePriority } = getContext().config;
|
|
106
|
-
if (type !== 'create')
|
|
107
|
-
return defaultNonCreatePriority;
|
|
108
|
-
return modelCreatePriority.get(modelName) ?? defaultCreatePriority;
|
|
109
|
-
};
|
|
110
|
-
const TX_TYPE_TO_MUTATION_OP = {
|
|
111
|
-
create: MutationOperationType.CREATE,
|
|
112
|
-
update: MutationOperationType.UPDATE,
|
|
113
|
-
delete: MutationOperationType.DELETE,
|
|
114
|
-
archive: MutationOperationType.ARCHIVE,
|
|
115
|
-
unarchive: MutationOperationType.UNARCHIVE,
|
|
116
|
-
};
|
|
117
|
-
function hasStaleWriteOptions(options) {
|
|
118
|
-
return (options?.readAt !== undefined ||
|
|
119
|
-
options?.onStale !== undefined);
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Project a transaction's `writeOptions` onto the wire operation. Stale
|
|
123
|
-
* guards (`readAt`/`onStale`) ride at the op root; `idempotencyKey`/`label`
|
|
124
|
-
* ride in the op's `options` slot (`MutationOperation.options` — the
|
|
125
|
-
* mutation_log cache key + audit tag). This is the single place the
|
|
126
|
-
* caller-supplied write vocabulary crosses onto the wire.
|
|
127
|
-
*/
|
|
128
|
-
function applyWriteOptions(op, transaction) {
|
|
129
|
-
const operation = op;
|
|
130
|
-
const writeOptions = transaction.writeOptions;
|
|
131
|
-
if (!writeOptions)
|
|
132
|
-
return operation;
|
|
133
|
-
if (writeOptions.readAt !== undefined) {
|
|
134
|
-
operation.readAt = writeOptions.readAt;
|
|
135
|
-
}
|
|
136
|
-
if (writeOptions.onStale !== undefined) {
|
|
137
|
-
operation.onStale = writeOptions.onStale;
|
|
138
|
-
}
|
|
139
|
-
if (writeOptions.idempotencyKey != null || writeOptions.label !== undefined) {
|
|
140
|
-
operation.options = {
|
|
141
|
-
...(writeOptions.idempotencyKey != null
|
|
142
|
-
? { idempotencyKey: writeOptions.idempotencyKey }
|
|
143
|
-
: {}),
|
|
144
|
-
...(writeOptions.label !== undefined ? { label: writeOptions.label } : {}),
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
return operation;
|
|
148
|
-
}
|
|
149
|
-
function asTransportError(value) {
|
|
150
|
-
return (value && typeof value === 'object' ? value : {});
|
|
151
|
-
}
|
|
152
|
-
function extractStatusCode(error) {
|
|
153
|
-
return asTransportError(error).response?.status;
|
|
154
|
-
}
|
|
155
|
-
class TransactionStore {
|
|
156
|
-
transactions = new Map();
|
|
157
|
-
byStatus = new Map();
|
|
158
|
-
add(transaction) {
|
|
159
|
-
this.transactions.set(transaction.id, transaction);
|
|
160
|
-
if (!this.byStatus.has(transaction.status)) {
|
|
161
|
-
this.byStatus.set(transaction.status, new Set());
|
|
162
|
-
}
|
|
163
|
-
this.byStatus.get(transaction.status).add(transaction.id);
|
|
164
|
-
}
|
|
165
|
-
get(id) {
|
|
166
|
-
return this.transactions.get(id);
|
|
167
|
-
}
|
|
168
|
-
updateStatus(id, newStatus) {
|
|
169
|
-
const tx = this.transactions.get(id);
|
|
170
|
-
if (!tx)
|
|
171
|
-
return;
|
|
172
|
-
this.byStatus.get(tx.status)?.delete(id);
|
|
173
|
-
tx.status = newStatus;
|
|
174
|
-
if (!this.byStatus.has(newStatus)) {
|
|
175
|
-
this.byStatus.set(newStatus, new Set());
|
|
176
|
-
}
|
|
177
|
-
this.byStatus.get(newStatus).add(id);
|
|
178
|
-
}
|
|
179
|
-
getByStatus(status) {
|
|
180
|
-
const ids = this.byStatus.get(status) || new Set();
|
|
181
|
-
return Array.from(ids)
|
|
182
|
-
.map((id) => this.transactions.get(id))
|
|
183
|
-
.filter(Boolean);
|
|
184
|
-
}
|
|
185
|
-
remove(id) {
|
|
186
|
-
const tx = this.transactions.get(id);
|
|
187
|
-
if (!tx)
|
|
188
|
-
return;
|
|
189
|
-
this.transactions.delete(id);
|
|
190
|
-
this.byStatus.get(tx.status)?.delete(id);
|
|
191
|
-
}
|
|
192
|
-
clear() {
|
|
193
|
-
this.transactions.clear();
|
|
194
|
-
this.byStatus.clear();
|
|
195
|
-
}
|
|
196
|
-
getAll() {
|
|
197
|
-
return Array.from(this.transactions.values());
|
|
198
|
-
}
|
|
199
|
-
}
|
|
16
|
+
import { projectCommitPayload, computePriorityScore, normalizeModelKey, stripModelSuffix, hasStaleWriteOptions, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
|
|
17
|
+
import { TransactionStore } from './TransactionStore.js';
|
|
18
|
+
import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
|
|
19
|
+
import { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
20
|
+
import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './persistedReplay.js';
|
|
21
|
+
import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimistic.js';
|
|
200
22
|
export class TransactionQueue extends EventEmitter {
|
|
201
23
|
store = new TransactionStore();
|
|
202
24
|
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
@@ -250,10 +72,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
250
72
|
}
|
|
251
73
|
}
|
|
252
74
|
entityKey(modelName, modelId) {
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
isTransactionForModel(transaction, modelName, modelId) {
|
|
256
|
-
return transaction.modelName === modelName && transaction.modelId === modelId;
|
|
75
|
+
return entityKey(modelName, modelId);
|
|
257
76
|
}
|
|
258
77
|
resolveConfirmation(transaction) {
|
|
259
78
|
const resolver = this.confirmationResolvers.get(transaction.id);
|
|
@@ -263,19 +82,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
263
82
|
resolver.resolve();
|
|
264
83
|
}
|
|
265
84
|
takeUnsentCreateForModel(modelName, modelId) {
|
|
266
|
-
|
|
267
|
-
tx.status === 'pending' &&
|
|
268
|
-
tx.attempts === 0 &&
|
|
269
|
-
this.isTransactionForModel(tx, modelName, modelId);
|
|
270
|
-
const stagedIndex = this.createdTransactions.findIndex(isUnsentCreate);
|
|
271
|
-
if (stagedIndex >= 0) {
|
|
272
|
-
return this.createdTransactions.splice(stagedIndex, 1)[0];
|
|
273
|
-
}
|
|
274
|
-
const queuedIndex = this.executionQueue.findIndex(isUnsentCreate);
|
|
275
|
-
if (queuedIndex >= 0) {
|
|
276
|
-
return this.executionQueue.splice(queuedIndex, 1)[0];
|
|
277
|
-
}
|
|
278
|
-
return this.store.getByStatus('pending').find(isUnsentCreate);
|
|
85
|
+
return takeUnsentCreateForModel(this.createdTransactions, this.executionQueue, this.store, modelName, modelId);
|
|
279
86
|
}
|
|
280
87
|
async cancelUnsentCreateForDelete(transaction) {
|
|
281
88
|
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
@@ -285,17 +92,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
285
92
|
this.resolveConfirmation(transaction);
|
|
286
93
|
}
|
|
287
94
|
findCreateBarrierForDelete(modelName, modelId) {
|
|
288
|
-
|
|
289
|
-
...this.store.getByStatus('pending'),
|
|
290
|
-
...this.store.getByStatus('executing'),
|
|
291
|
-
...this.store.getByStatus('awaiting_delta'),
|
|
292
|
-
].filter((tx) => tx.type === 'create' &&
|
|
293
|
-
this.isTransactionForModel(tx, modelName, modelId) &&
|
|
294
|
-
// A never-attempted pending create can be cancelled instead. Once the
|
|
295
|
-
// create has been sent, even a retry-pending state is a causal barrier:
|
|
296
|
-
// the server may already have applied it and only the response was lost.
|
|
297
|
-
(tx.status !== 'pending' || tx.attempts > 0));
|
|
298
|
-
return liveCreates.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
95
|
+
return findCreateBarrierForDelete(this.store, modelName, modelId);
|
|
299
96
|
}
|
|
300
97
|
completeLocalDelete(model, context, writeOptions) {
|
|
301
98
|
const actualModelName = model.getModelName();
|
|
@@ -328,69 +125,10 @@ export class TransactionQueue extends EventEmitter {
|
|
|
328
125
|
return transaction;
|
|
329
126
|
}
|
|
330
127
|
deferDeleteUntilCreateSettles(createTransaction, deleteTransaction) {
|
|
331
|
-
|
|
332
|
-
const deferred = this.deferredDeletesByCreate.get(key) ?? [];
|
|
333
|
-
deferred.push(deleteTransaction);
|
|
334
|
-
this.deferredDeletesByCreate.set(key, deferred);
|
|
128
|
+
deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
|
|
335
129
|
}
|
|
336
130
|
releaseDeferredDeletesForCreate(createTransaction) {
|
|
337
|
-
|
|
338
|
-
const deferred = this.deferredDeletesByCreate.get(key);
|
|
339
|
-
if (!deferred || deferred.length === 0)
|
|
340
|
-
return;
|
|
341
|
-
this.deferredDeletesByCreate.delete(key);
|
|
342
|
-
for (const deleteTransaction of deferred) {
|
|
343
|
-
if (this.store.get(deleteTransaction.id)?.status !== 'pending')
|
|
344
|
-
continue;
|
|
345
|
-
this.enqueue(deleteTransaction);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
// Merge two GraphQL update payloads with special handling for metadata fields
|
|
349
|
-
mergeUpdateData(left, right, _modelName) {
|
|
350
|
-
const out = { ...(left || {}) };
|
|
351
|
-
const src = right || {};
|
|
352
|
-
for (const key of Object.keys(src)) {
|
|
353
|
-
// Special case: metadata payloads may be JSON strings; merge objects instead of clobbering
|
|
354
|
-
if (key === 'metadata') {
|
|
355
|
-
const l = out.metadata;
|
|
356
|
-
const r = src.metadata;
|
|
357
|
-
// If both sides undefined/null, continue
|
|
358
|
-
if (l == null && r == null) {
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
// Normalize to objects
|
|
362
|
-
const toObj = (v) => {
|
|
363
|
-
if (v == null)
|
|
364
|
-
return {};
|
|
365
|
-
if (typeof v === 'string') {
|
|
366
|
-
try {
|
|
367
|
-
return JSON.parse(v);
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
return {};
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
if (typeof v === 'object')
|
|
374
|
-
return v;
|
|
375
|
-
return {};
|
|
376
|
-
};
|
|
377
|
-
const lobj = toObj(l);
|
|
378
|
-
const robj = toObj(r);
|
|
379
|
-
const merged = { ...lobj, ...robj };
|
|
380
|
-
// Re-stringify to match schema input type
|
|
381
|
-
try {
|
|
382
|
-
out.metadata = JSON.stringify(merged);
|
|
383
|
-
}
|
|
384
|
-
catch {
|
|
385
|
-
// Fallback to right-hand side if stringify fails
|
|
386
|
-
out.metadata = typeof r === 'string' ? r : JSON.stringify(robj || {});
|
|
387
|
-
}
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
// Default: shallow overwrite with right-hand value
|
|
391
|
-
out[key] = src[key];
|
|
392
|
-
}
|
|
393
|
-
return out;
|
|
131
|
+
releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
|
|
394
132
|
}
|
|
395
133
|
// Configuration - tuned for LINEAR-style batching
|
|
396
134
|
// Higher batch size and delay allows more operations to coalesce into single HTTP call
|
|
@@ -413,17 +151,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
413
151
|
};
|
|
414
152
|
// Track executing transactions for backpressure
|
|
415
153
|
executingCount = 0;
|
|
416
|
-
// Optimistic update tracking
|
|
154
|
+
// Optimistic update tracking. The entry shape + apply/rollback rules live
|
|
155
|
+
// in `./optimistic.js`; the ledger itself stays host state because the
|
|
156
|
+
// completion paths, `getStats`, and `dispose` all read it.
|
|
417
157
|
optimisticUpdates = new Map();
|
|
418
158
|
// Stale-context notifications (CoAgent/MTPO notify-instead-of-abort) keyed by
|
|
419
159
|
// transaction id, populated from the commit ack and drained by
|
|
420
160
|
// `waitForCommitReceipt` so the receipt carries the self-heal signal.
|
|
421
161
|
commitNotifications = new Map();
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
deltaConfirmationRetries = new Map();
|
|
162
|
+
// Delta-confirmation tracking (ack watermark advance + the awaiting_delta
|
|
163
|
+
// timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
|
|
164
|
+
// constructor once `position` is bound.
|
|
165
|
+
deltaConfirmation;
|
|
427
166
|
// Connection state check - set by SyncClient to prevent rollbacks during disconnection
|
|
428
167
|
isConnectedFn = () => true;
|
|
429
168
|
// Grace timer that, when fired, fails any commit-lane transaction
|
|
@@ -444,15 +183,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
444
183
|
return this.position.applied;
|
|
445
184
|
}
|
|
446
185
|
noteAck(lastSyncId) {
|
|
447
|
-
this.
|
|
448
|
-
}
|
|
449
|
-
// Delta confirmation retry config (Replicache-style exponential backoff)
|
|
450
|
-
// Max retries before requesting full reconciliation
|
|
451
|
-
static DELTA_MAX_RETRIES = 5;
|
|
452
|
-
// Initial timeout (first attempt)
|
|
453
|
-
static DELTA_INITIAL_TIMEOUT_MS = 30_000;
|
|
454
|
-
// Max timeout cap (like Replicache's maxDelayMs of 60s)
|
|
455
|
-
static DELTA_MAX_TIMEOUT_MS = 120_000;
|
|
186
|
+
this.deltaConfirmation.noteAck(lastSyncId);
|
|
187
|
+
}
|
|
456
188
|
// Batch management
|
|
457
189
|
batchIndex = 0;
|
|
458
190
|
/**
|
|
@@ -466,6 +198,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
466
198
|
constructor(config) {
|
|
467
199
|
super();
|
|
468
200
|
this.position = config?.position ?? new SyncPosition();
|
|
201
|
+
// Bind the confirmation tracker to this queue's store/ledger/events.
|
|
202
|
+
// `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
|
|
203
|
+
// swaps stay visible to in-flight timeouts.
|
|
204
|
+
this.deltaConfirmation = new DeltaConfirmationTracker({
|
|
205
|
+
store: this.store,
|
|
206
|
+
optimisticUpdates: this.optimisticUpdates,
|
|
207
|
+
emit: (event, payload) => {
|
|
208
|
+
this.emit(event, payload);
|
|
209
|
+
},
|
|
210
|
+
isConnected: () => this.isConnectedFn(),
|
|
211
|
+
position: this.position,
|
|
212
|
+
});
|
|
469
213
|
if (config) {
|
|
470
214
|
this.config = { ...this.config, ...config };
|
|
471
215
|
}
|
|
@@ -526,6 +270,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
526
270
|
if (candidates.length === 0)
|
|
527
271
|
return Promise.resolve();
|
|
528
272
|
const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
273
|
+
if (!latest)
|
|
274
|
+
return Promise.resolve();
|
|
529
275
|
return latest.confirmation ?? Promise.resolve();
|
|
530
276
|
}
|
|
531
277
|
/**
|
|
@@ -1007,7 +753,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1007
753
|
// If there is an in-flight update for this model, merge into post-flight buffer
|
|
1008
754
|
if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
|
|
1009
755
|
const prev = this.pendingMergeByModel.get(modelKey) || {};
|
|
1010
|
-
const merged =
|
|
756
|
+
const merged = mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
|
|
1011
757
|
this.pendingMergeByModel.set(modelKey, merged);
|
|
1012
758
|
this.store.remove(transaction.id);
|
|
1013
759
|
return;
|
|
@@ -1019,7 +765,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1019
765
|
t.modelName === transaction.modelName &&
|
|
1020
766
|
!hasStaleWriteOptions(t.writeOptions));
|
|
1021
767
|
if (!preserveWatermark && pendingInQueue) {
|
|
1022
|
-
pendingInQueue.data =
|
|
768
|
+
pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
|
|
1023
769
|
this.store.remove(transaction.id);
|
|
1024
770
|
return;
|
|
1025
771
|
}
|
|
@@ -1258,7 +1004,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1258
1004
|
// wrapping in Ablo.commit() and any future wrappers.
|
|
1259
1005
|
for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
|
|
1260
1006
|
if ('diagnostics' in cur && cur.diagnostics) {
|
|
1261
|
-
return cur.diagnostics;
|
|
1007
|
+
return (cur).diagnostics;
|
|
1262
1008
|
}
|
|
1263
1009
|
cur = cur.cause;
|
|
1264
1010
|
}
|
|
@@ -1362,173 +1108,16 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1362
1108
|
/**
|
|
1363
1109
|
* LINEAR PATTERN: Confirm all awaiting transactions when delta with syncId >= threshold arrives.
|
|
1364
1110
|
* This replaces clientMutationId echoing - transactions are confirmed by sync ID threshold.
|
|
1111
|
+
* Policy + timeout maps live in the `./deltaConfirmation.js` leaf.
|
|
1365
1112
|
* @param syncId - The sync ID of the received delta
|
|
1366
1113
|
*/
|
|
1367
1114
|
onDeltaReceived(syncId) {
|
|
1368
|
-
|
|
1369
|
-
// position.advanceApplied / advancePersisted); this hook only resolves
|
|
1370
|
-
// confirmation thresholds against the incoming id.
|
|
1371
|
-
const awaitingTxs = this.store.getByStatus('awaiting_delta');
|
|
1372
|
-
const executingTxs = this.store.getByStatus('executing');
|
|
1373
|
-
// Debug: Show state when delta arrives
|
|
1374
|
-
if (awaitingTxs.length > 0 || executingTxs.length > 0) {
|
|
1375
|
-
getContext().logger.debug('tx:delta_received', {
|
|
1376
|
-
syncId,
|
|
1377
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1378
|
-
awaitingCount: awaitingTxs.length,
|
|
1379
|
-
executingCount: executingTxs.length,
|
|
1380
|
-
awaitingThresholds: awaitingTxs.map((tx) => ({
|
|
1381
|
-
txId: tx.id.slice(0, 8),
|
|
1382
|
-
model: tx.modelName,
|
|
1383
|
-
needed: tx.syncIdNeededForCompletion,
|
|
1384
|
-
willConfirm: tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion,
|
|
1385
|
-
})),
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
// Fast path: no awaiting transactions
|
|
1389
|
-
if (awaitingTxs.length === 0)
|
|
1390
|
-
return;
|
|
1391
|
-
let confirmedCount = 0;
|
|
1392
|
-
for (const tx of awaitingTxs) {
|
|
1393
|
-
// Confirm if this delta's ID meets or exceeds the threshold
|
|
1394
|
-
if (tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion) {
|
|
1395
|
-
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
1396
|
-
this.store.updateStatus(tx.id, 'completed');
|
|
1397
|
-
this.emit('transaction:completed', tx);
|
|
1398
|
-
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1399
|
-
this.optimisticUpdates.delete(tx.id);
|
|
1400
|
-
confirmedCount++;
|
|
1401
|
-
getContext().logger.debug('tx:confirm_via_delta', {
|
|
1402
|
-
txId: tx.id.slice(0, 8),
|
|
1403
|
-
model: tx.modelName,
|
|
1404
|
-
neededSyncId: tx.syncIdNeededForCompletion,
|
|
1405
|
-
receivedSyncId: syncId,
|
|
1406
|
-
});
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
// Log batch summary only if we confirmed something
|
|
1410
|
-
if (confirmedCount > 0) {
|
|
1411
|
-
// Use warn for staging visibility when transactions confirm
|
|
1412
|
-
getContext().observability.breadcrumb('Transactions confirmed via delta', 'sync.transaction', 'info', {
|
|
1413
|
-
count: confirmedCount,
|
|
1414
|
-
syncId,
|
|
1415
|
-
remainingAwaiting: awaitingTxs.length - confirmedCount,
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1115
|
+
this.deltaConfirmation.onDeltaReceived(syncId);
|
|
1418
1116
|
}
|
|
1419
|
-
// REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry +
|
|
1420
|
-
//
|
|
1421
|
-
// retry with exponential backoff and request reconciliation to catch up on missed deltas.
|
|
1422
|
-
// Only rollback on explicit server rejection, never on timeout.
|
|
1117
|
+
// REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry +
|
|
1118
|
+
// reconciliation — see DeltaConfirmationTracker in `./deltaConfirmation.js`.
|
|
1423
1119
|
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
1424
|
-
|
|
1425
|
-
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
1426
|
-
const timeoutHandle = setTimeout(async () => {
|
|
1427
|
-
const currentTx = this.store.get(tx.id);
|
|
1428
|
-
if (!currentTx || currentTx.status !== 'awaiting_delta') {
|
|
1429
|
-
this.deltaConfirmationRetries.delete(tx.id);
|
|
1430
|
-
return; // Already confirmed or failed
|
|
1431
|
-
}
|
|
1432
|
-
// If disconnected, re-schedule with same timeout (no backoff while offline)
|
|
1433
|
-
if (!this.isConnectedFn()) {
|
|
1434
|
-
// Self-healing: re-schedule the confirmation wait while offline, no
|
|
1435
|
-
// consumer action needed → debug.
|
|
1436
|
-
getContext().logger.debug('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
|
|
1437
|
-
txId: tx.id.slice(0, 8),
|
|
1438
|
-
model: tx.modelName,
|
|
1439
|
-
});
|
|
1440
|
-
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1441
|
-
this.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
const retryCount = this.deltaConfirmationRetries.get(tx.id) ?? 0;
|
|
1445
|
-
const diagnosis = this.lastSeenSyncId === 0
|
|
1446
|
-
? 'No deltas received - delta pipeline may be broken'
|
|
1447
|
-
: currentTx.syncIdNeededForCompletion &&
|
|
1448
|
-
this.lastSeenSyncId < currentTx.syncIdNeededForCompletion
|
|
1449
|
-
? 'Delta not yet received - may be lost or delayed'
|
|
1450
|
-
: 'Delta should have confirmed - possible race condition';
|
|
1451
|
-
getContext().observability.captureReconciliation({
|
|
1452
|
-
reason: 'delta_timeout',
|
|
1453
|
-
model: tx.modelName,
|
|
1454
|
-
modelId: tx.modelId,
|
|
1455
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1456
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1457
|
-
retryCount,
|
|
1458
|
-
connectionState: this.isConnectedFn() ? 'connected' : 'disconnected',
|
|
1459
|
-
});
|
|
1460
|
-
if (retryCount < TransactionQueue.DELTA_MAX_RETRIES) {
|
|
1461
|
-
// RETRY: Request reconciliation and re-schedule with exponential backoff
|
|
1462
|
-
// The server already committed this mutation — we just need the delta to arrive
|
|
1463
|
-
this.deltaConfirmationRetries.set(tx.id, retryCount + 1);
|
|
1464
|
-
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1465
|
-
// Exponential backoff: 30s → 60s → 120s → 120s → 120s (capped)
|
|
1466
|
-
const nextTimeout = Math.min(timeoutMs * 2, TransactionQueue.DELTA_MAX_TIMEOUT_MS);
|
|
1467
|
-
// Emit reconciliation request so SyncedStore can cycle the WebSocket
|
|
1468
|
-
// to trigger delta catch-up from the server
|
|
1469
|
-
this.emit('reconciliation:needed', {
|
|
1470
|
-
reason: 'delta_confirmation_timeout',
|
|
1471
|
-
txId: tx.id,
|
|
1472
|
-
model: tx.modelName,
|
|
1473
|
-
modelId: tx.modelId,
|
|
1474
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1475
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1476
|
-
retryCount: retryCount + 1,
|
|
1477
|
-
});
|
|
1478
|
-
// Self-healing retry with backoff — the server already committed; we're
|
|
1479
|
-
// just waiting on the delta. No consumer action → debug.
|
|
1480
|
-
getContext().logger.debug('[TransactionQueue] Re-scheduling with backoff', {
|
|
1481
|
-
txId: tx.id.slice(0, 8),
|
|
1482
|
-
model: tx.modelName,
|
|
1483
|
-
nextTimeoutMs: nextTimeout,
|
|
1484
|
-
retry: retryCount + 1,
|
|
1485
|
-
});
|
|
1486
|
-
this.scheduleDeltaConfirmationTimeout(tx, nextTimeout);
|
|
1487
|
-
}
|
|
1488
|
-
else {
|
|
1489
|
-
// LINEAR PATTERN: Retries exhausted — persist to IndexedDB instead of rolling back.
|
|
1490
|
-
// The transaction succeeded on the server (HTTP 200), so the data exists server-side.
|
|
1491
|
-
// Persist the awaiting state so it survives tab close. On next session, the WebSocket
|
|
1492
|
-
// reconnect + delta catch-up will naturally confirm it (like Linear's IndexedDB caching).
|
|
1493
|
-
this.deltaConfirmationRetries.delete(tx.id);
|
|
1494
|
-
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1495
|
-
getContext().observability.captureDeltaRetryExhausted({
|
|
1496
|
-
txId: tx.id,
|
|
1497
|
-
model: tx.modelName,
|
|
1498
|
-
modelId: tx.modelId,
|
|
1499
|
-
retryCount: TransactionQueue.DELTA_MAX_RETRIES,
|
|
1500
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1501
|
-
});
|
|
1502
|
-
// Emit persist event — SyncClient handles the IDB write
|
|
1503
|
-
this.emit('transaction:persist_awaiting', {
|
|
1504
|
-
txId: tx.id,
|
|
1505
|
-
model: tx.modelName,
|
|
1506
|
-
modelId: tx.modelId,
|
|
1507
|
-
operationType: tx.type,
|
|
1508
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1509
|
-
});
|
|
1510
|
-
// Also request one final reconciliation cycle
|
|
1511
|
-
this.emit('reconciliation:needed', {
|
|
1512
|
-
reason: 'delta_retries_exhausted',
|
|
1513
|
-
txId: tx.id,
|
|
1514
|
-
model: tx.modelName,
|
|
1515
|
-
modelId: tx.modelId,
|
|
1516
|
-
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1517
|
-
lastSeenSyncId: this.lastSeenSyncId,
|
|
1518
|
-
retryCount: TransactionQueue.DELTA_MAX_RETRIES,
|
|
1519
|
-
});
|
|
1520
|
-
}
|
|
1521
|
-
}, timeoutMs);
|
|
1522
|
-
this.deltaConfirmationTimeouts.set(tx.id, timeoutHandle);
|
|
1523
|
-
}
|
|
1524
|
-
// Cancel a pending delta confirmation timeout and clean up retry tracking
|
|
1525
|
-
cancelDeltaConfirmationTimeout(id) {
|
|
1526
|
-
const timeoutHandle = this.deltaConfirmationTimeouts.get(id);
|
|
1527
|
-
if (timeoutHandle) {
|
|
1528
|
-
clearTimeout(timeoutHandle);
|
|
1529
|
-
this.deltaConfirmationTimeouts.delete(id);
|
|
1530
|
-
}
|
|
1531
|
-
this.deltaConfirmationRetries.delete(id);
|
|
1120
|
+
this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1532
1121
|
}
|
|
1533
1122
|
/**
|
|
1534
1123
|
* Wait for a transaction to be confirmed via delta echo (Linear pattern)
|
|
@@ -1607,6 +1196,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1607
1196
|
try {
|
|
1608
1197
|
while (this.commitLane.length > 0) {
|
|
1609
1198
|
const tx = this.commitLane[0];
|
|
1199
|
+
if (!tx)
|
|
1200
|
+
break;
|
|
1610
1201
|
if (tx.status !== 'pending') {
|
|
1611
1202
|
this.commitLane.shift();
|
|
1612
1203
|
continue;
|
|
@@ -1932,44 +1523,20 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1932
1523
|
}
|
|
1933
1524
|
}
|
|
1934
1525
|
/**
|
|
1935
|
-
* Optimistic updates
|
|
1526
|
+
* Optimistic updates — apply/rollback rules live in `./optimistic.js`;
|
|
1527
|
+
* these delegates bind the host-owned ledger + event emitter.
|
|
1936
1528
|
*/
|
|
1937
1529
|
applyOptimisticCreate(model, transaction) {
|
|
1938
|
-
this.optimisticUpdates
|
|
1939
|
-
model,
|
|
1940
|
-
previousState: null,
|
|
1941
|
-
transaction,
|
|
1942
|
-
});
|
|
1943
|
-
this.emit('optimistic:create', { model, transaction });
|
|
1530
|
+
applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
|
|
1944
1531
|
}
|
|
1945
1532
|
applyOptimisticUpdate(model, transaction) {
|
|
1946
|
-
this.optimisticUpdates
|
|
1947
|
-
model,
|
|
1948
|
-
previousState: transaction.previousData,
|
|
1949
|
-
transaction,
|
|
1950
|
-
});
|
|
1951
|
-
this.emit('optimistic:update', { model, transaction });
|
|
1533
|
+
applyOptimisticUpdate(this.optimisticUpdates, this, model, transaction);
|
|
1952
1534
|
}
|
|
1953
1535
|
applyOptimisticDelete(model, transaction) {
|
|
1954
|
-
this.optimisticUpdates
|
|
1955
|
-
model,
|
|
1956
|
-
previousState: transaction.previousData,
|
|
1957
|
-
transaction,
|
|
1958
|
-
});
|
|
1959
|
-
this.emit('optimistic:delete', { model, transaction });
|
|
1536
|
+
applyOptimisticDelete(this.optimisticUpdates, this, model, transaction);
|
|
1960
1537
|
}
|
|
1961
1538
|
async rollbackOptimistic(transaction, reason, error) {
|
|
1962
|
-
|
|
1963
|
-
if (!optimistic)
|
|
1964
|
-
return;
|
|
1965
|
-
this.emit('optimistic:rollback', {
|
|
1966
|
-
model: optimistic.model,
|
|
1967
|
-
previousState: optimistic.previousState,
|
|
1968
|
-
transaction,
|
|
1969
|
-
reason: reason ?? 'unknown',
|
|
1970
|
-
error,
|
|
1971
|
-
});
|
|
1972
|
-
this.optimisticUpdates.delete(transaction.id);
|
|
1539
|
+
await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
|
|
1973
1540
|
}
|
|
1974
1541
|
/**
|
|
1975
1542
|
* Execute individual transaction via the unified commit path
|
|
@@ -1999,6 +1566,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1999
1566
|
const persisted = await database.getPersistedTransactions();
|
|
2000
1567
|
for (const data of persisted) {
|
|
2001
1568
|
const transaction = this.deserializeTransaction(data);
|
|
1569
|
+
if (!transaction)
|
|
1570
|
+
continue;
|
|
2002
1571
|
this.store.add(transaction);
|
|
2003
1572
|
this.enqueue(transaction);
|
|
2004
1573
|
}
|
|
@@ -2010,8 +1579,31 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2010
1579
|
});
|
|
2011
1580
|
}
|
|
2012
1581
|
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Validate + rehydrate one persisted row (the T1.8 IDB replay boundary).
|
|
1584
|
+
* Rows written by other subsystems into the same store (`'queue'`,
|
|
1585
|
+
* `'awaiting_delta'`) are skipped silently; rows that fail the
|
|
1586
|
+
* persisted-transaction schema (older SDK versions, corruption) are
|
|
1587
|
+
* DROPPED with an observability capture instead of replayed as commits.
|
|
1588
|
+
*/
|
|
2013
1589
|
deserializeTransaction(data) {
|
|
2014
|
-
|
|
1590
|
+
if (isNonReplayablePersistedRow(data))
|
|
1591
|
+
return null;
|
|
1592
|
+
const transaction = deserializePersistedTransaction(data);
|
|
1593
|
+
if (!transaction) {
|
|
1594
|
+
const rowId = typeof data === 'object' && data !== null && typeof data.id === 'string'
|
|
1595
|
+
? data.id
|
|
1596
|
+
: undefined;
|
|
1597
|
+
getContext().logger.debug('[TransactionQueue] Dropping malformed persisted transaction', {
|
|
1598
|
+
rowId,
|
|
1599
|
+
});
|
|
1600
|
+
getContext().observability.captureTransactionFailure({
|
|
1601
|
+
context: 'deserialize-persisted-transaction',
|
|
1602
|
+
error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
|
|
1603
|
+
});
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
return transaction;
|
|
2015
1607
|
}
|
|
2016
1608
|
/**
|
|
2017
1609
|
* Cancel transactions for a specific model
|
|
@@ -2027,7 +1619,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2027
1619
|
if (!transactionType || transaction.type === transactionType) {
|
|
2028
1620
|
cancelledTransactions.push(transaction);
|
|
2029
1621
|
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
2030
|
-
|
|
1622
|
+
// Sync caller: a rejected rollback (throwing optimistic:rollback
|
|
1623
|
+
// listener) must surface, not vanish — the status flip above is
|
|
1624
|
+
// already committed either way.
|
|
1625
|
+
void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error) => {
|
|
1626
|
+
getContext().observability.captureTransactionFailure({
|
|
1627
|
+
context: 'rollback-model-cancelled',
|
|
1628
|
+
error: error instanceof Error ? error : String(error),
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
2031
1631
|
}
|
|
2032
1632
|
}
|
|
2033
1633
|
}
|
|
@@ -2058,7 +1658,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2058
1658
|
const fkValue = transaction.data?.[foreignKey];
|
|
2059
1659
|
if (fkValue === parentId) {
|
|
2060
1660
|
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
2061
|
-
this.rollbackOptimistic(transaction, 'cascade_parent_deleted')
|
|
1661
|
+
void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch((error) => {
|
|
1662
|
+
getContext().observability.captureTransactionFailure({
|
|
1663
|
+
context: 'rollback-cascade-parent-deleted',
|
|
1664
|
+
error: error instanceof Error ? error : String(error),
|
|
1665
|
+
});
|
|
1666
|
+
});
|
|
2062
1667
|
cancelled++;
|
|
2063
1668
|
getContext().logger.debug('[TransactionQueue] Cascade cancelled orphaned transaction', {
|
|
2064
1669
|
txId: transaction.id.slice(0, 12),
|
|
@@ -2205,6 +1810,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2205
1810
|
if (this.processTimer) {
|
|
2206
1811
|
clearTimeout(this.processTimer);
|
|
2207
1812
|
}
|
|
1813
|
+
// Clear every armed delta-confirmation timer (one per in-flight tx,
|
|
1814
|
+
// 30–120s each) — a disposed queue must not keep the process alive or
|
|
1815
|
+
// fire confirmation callbacks against the cleared store below.
|
|
1816
|
+
this.deltaConfirmation.dispose();
|
|
1817
|
+
// Clear the offline-grace timer armed by setConnectionState('disconnected').
|
|
1818
|
+
if (this.commitOfflineGraceTimer !== null) {
|
|
1819
|
+
clearTimeout(this.commitOfflineGraceTimer);
|
|
1820
|
+
this.commitOfflineGraceTimer = null;
|
|
1821
|
+
}
|
|
2208
1822
|
// Clear store
|
|
2209
1823
|
this.store.clear();
|
|
2210
1824
|
this.optimisticUpdates.clear();
|