@abloatai/ablo 0.3.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/CHANGELOG.md +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- package/package.json +147 -0
|
@@ -0,0 +1,1895 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransactionQueue - Production-ready transaction management
|
|
3
|
+
*
|
|
4
|
+
* Key features:
|
|
5
|
+
* - Optimistic updates with rollback
|
|
6
|
+
* - Conflict resolution strategies
|
|
7
|
+
* - LINEAR-style microtask batching (transactions in same event loop share batchId)
|
|
8
|
+
* - Proper dependency injection (no singleton)
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
import { getContext } from '../context.js';
|
|
12
|
+
import { getActiveRegistry } from '../ModelRegistry.js';
|
|
13
|
+
import { MutationOperationType } from '../types/index.js';
|
|
14
|
+
import { handleMutationError } from './mutation-error-handler.js';
|
|
15
|
+
import { AbloError, AbloConnectionError } from '../errors.js';
|
|
16
|
+
/**
|
|
17
|
+
* Framework-internal keys added by `Model.toJSON()` that must never
|
|
18
|
+
* reach the wire. The server treats each top-level key as a target
|
|
19
|
+
* column, so shipping these would blow up the INSERT/UPDATE.
|
|
20
|
+
*/
|
|
21
|
+
const FRAMEWORK_KEYS = new Set(['__class', '__typename', 'clientId', 'syncStatus']);
|
|
22
|
+
/**
|
|
23
|
+
* Project a Model's serialized data onto its schema-declared fields
|
|
24
|
+
* and return a wire-safe commit payload. Two jobs:
|
|
25
|
+
*
|
|
26
|
+
* 1. Drop framework internals (`__class`, `__typename`, `clientId`,
|
|
27
|
+
* `syncStatus`) and anything not declared on the model's schema.
|
|
28
|
+
* 2. JSON.stringify values typed as `field.json()` — TEXT columns
|
|
29
|
+
* storing JSON need explicit stringification; postgres.js won't
|
|
30
|
+
* auto-serialize for non-JSONB columns.
|
|
31
|
+
*
|
|
32
|
+
* For updates (`dropUndefined: true`), `undefined` values are also
|
|
33
|
+
* stripped so they don't translate to `SET column = NULL` on the
|
|
34
|
+
* server side.
|
|
35
|
+
*
|
|
36
|
+
* Fields are read from `ModelRegistry`, populated by
|
|
37
|
+
* `registerModelsFromSchema` at SDK initialization. If the model
|
|
38
|
+
* isn't registered with field metadata (edge case — e.g., tests or
|
|
39
|
+
* manually registered models), projection falls back to identity and
|
|
40
|
+
* the caller gets whatever the Model serialized.
|
|
41
|
+
*/
|
|
42
|
+
function projectCommitPayload(modelName, source, opts) {
|
|
43
|
+
const metadata = getActiveRegistry().getMetadata(modelName);
|
|
44
|
+
const fields = metadata?.fields;
|
|
45
|
+
const out = {};
|
|
46
|
+
if (!fields) {
|
|
47
|
+
// Unknown registration — strip framework keys and ship the rest.
|
|
48
|
+
for (const [k, v] of Object.entries(source)) {
|
|
49
|
+
if (FRAMEWORK_KEYS.has(k))
|
|
50
|
+
continue;
|
|
51
|
+
if (opts.dropUndefined && v === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
out[k] = v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
for (const [key, meta] of Object.entries(fields)) {
|
|
58
|
+
if (!(key in source))
|
|
59
|
+
continue;
|
|
60
|
+
const value = source[key];
|
|
61
|
+
if (opts.dropUndefined && value === undefined)
|
|
62
|
+
continue;
|
|
63
|
+
// JSON-typed fields (`jsonb` on the server): ship as OBJECTS over
|
|
64
|
+
// the wire, not pre-stringified strings. Previously we stringified
|
|
65
|
+
// here, which round-tripped incorrectly:
|
|
66
|
+
//
|
|
67
|
+
// 1. Client stringifies `position: {x, y}` → `'{"x":...}'`
|
|
68
|
+
// 2. Server writes to jsonb column (parses string → jsonb object, fine)
|
|
69
|
+
// 3. Server's delta echoes `data: JSON.stringify(op.input)` where
|
|
70
|
+
// `op.input.position` is still the STRING from step 1
|
|
71
|
+
// 4. Client merges delta → `model.position = "{...}"` (STRING)
|
|
72
|
+
// 5. Next drag: `{ ...layer.position, x, y }` spreads the STRING
|
|
73
|
+
// char-by-char, producing corrupted char-indexed objects like
|
|
74
|
+
// `{"0":"{","1":"\"","2":"x",...,"x":null,"y":null,...}`
|
|
75
|
+
// 6. That corrupt object lands in the next commit, stored in jsonb.
|
|
76
|
+
//
|
|
77
|
+
// Sending objects avoids the round-trip mismatch: the wire carries
|
|
78
|
+
// the object through delta + commit unchanged, and `postgres-js`
|
|
79
|
+
// serializes JS objects to jsonb correctly via its own
|
|
80
|
+
// `json.serialize` (triggered by Postgres's ParameterDescription
|
|
81
|
+
// response identifying the column as type 3802 / jsonb).
|
|
82
|
+
out[key] = value;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
|
|
87
|
+
const stripModelSuffix = (modelName) => modelName.replace('Model', '');
|
|
88
|
+
/**
|
|
89
|
+
* FK-ordered create priority.
|
|
90
|
+
*
|
|
91
|
+
* Reads `config.modelCreatePriority` out of the runtime SyncEngineContext —
|
|
92
|
+
* this map is populated once at `createSyncEngine(...)` time by walking the
|
|
93
|
+
* schema's `belongsTo` graph (see `computeFKDepthPriority` in
|
|
94
|
+
* `client/createSyncEngine.ts`). The queue stays schema-agnostic: no model
|
|
95
|
+
* names appear here, and consumer applications can override specific
|
|
96
|
+
* priorities via `configOverrides.modelCreatePriority` without touching the
|
|
97
|
+
* SDK.
|
|
98
|
+
*
|
|
99
|
+
* Non-create ops (update/delete/archive/unarchive) don't need FK ordering
|
|
100
|
+
* because the row already exists, so they all share
|
|
101
|
+
* `config.defaultNonCreatePriority`.
|
|
102
|
+
*/
|
|
103
|
+
const computePriorityScore = (type, modelName) => {
|
|
104
|
+
const { modelCreatePriority, defaultCreatePriority, defaultNonCreatePriority } = getContext().config;
|
|
105
|
+
if (type !== 'create')
|
|
106
|
+
return defaultNonCreatePriority;
|
|
107
|
+
return modelCreatePriority.get(modelName) ?? defaultCreatePriority;
|
|
108
|
+
};
|
|
109
|
+
const TX_TYPE_TO_MUTATION_OP = {
|
|
110
|
+
create: MutationOperationType.CREATE,
|
|
111
|
+
update: MutationOperationType.UPDATE,
|
|
112
|
+
delete: MutationOperationType.DELETE,
|
|
113
|
+
archive: MutationOperationType.ARCHIVE,
|
|
114
|
+
unarchive: MutationOperationType.UNARCHIVE,
|
|
115
|
+
};
|
|
116
|
+
function hasStaleWriteOptions(options) {
|
|
117
|
+
return (options?.readAt !== undefined ||
|
|
118
|
+
options?.onStale !== undefined);
|
|
119
|
+
}
|
|
120
|
+
function applyStaleWriteOptions(op, transaction) {
|
|
121
|
+
const operation = op;
|
|
122
|
+
if (transaction.writeOptions?.readAt !== undefined) {
|
|
123
|
+
operation.readAt = transaction.writeOptions.readAt;
|
|
124
|
+
}
|
|
125
|
+
if (transaction.writeOptions?.onStale !== undefined) {
|
|
126
|
+
operation.onStale = transaction.writeOptions.onStale;
|
|
127
|
+
}
|
|
128
|
+
return operation;
|
|
129
|
+
}
|
|
130
|
+
function asTransportError(value) {
|
|
131
|
+
return (value && typeof value === 'object' ? value : {});
|
|
132
|
+
}
|
|
133
|
+
function extractStatusCode(error) {
|
|
134
|
+
return asTransportError(error).response?.status;
|
|
135
|
+
}
|
|
136
|
+
class TransactionStore {
|
|
137
|
+
transactions = new Map();
|
|
138
|
+
byStatus = new Map();
|
|
139
|
+
add(transaction) {
|
|
140
|
+
this.transactions.set(transaction.id, transaction);
|
|
141
|
+
if (!this.byStatus.has(transaction.status)) {
|
|
142
|
+
this.byStatus.set(transaction.status, new Set());
|
|
143
|
+
}
|
|
144
|
+
this.byStatus.get(transaction.status).add(transaction.id);
|
|
145
|
+
}
|
|
146
|
+
get(id) {
|
|
147
|
+
return this.transactions.get(id);
|
|
148
|
+
}
|
|
149
|
+
updateStatus(id, newStatus) {
|
|
150
|
+
const tx = this.transactions.get(id);
|
|
151
|
+
if (!tx)
|
|
152
|
+
return;
|
|
153
|
+
this.byStatus.get(tx.status)?.delete(id);
|
|
154
|
+
tx.status = newStatus;
|
|
155
|
+
if (!this.byStatus.has(newStatus)) {
|
|
156
|
+
this.byStatus.set(newStatus, new Set());
|
|
157
|
+
}
|
|
158
|
+
this.byStatus.get(newStatus).add(id);
|
|
159
|
+
}
|
|
160
|
+
getByStatus(status) {
|
|
161
|
+
const ids = this.byStatus.get(status) || new Set();
|
|
162
|
+
return Array.from(ids)
|
|
163
|
+
.map((id) => this.transactions.get(id))
|
|
164
|
+
.filter(Boolean);
|
|
165
|
+
}
|
|
166
|
+
remove(id) {
|
|
167
|
+
const tx = this.transactions.get(id);
|
|
168
|
+
if (!tx)
|
|
169
|
+
return;
|
|
170
|
+
this.transactions.delete(id);
|
|
171
|
+
this.byStatus.get(tx.status)?.delete(id);
|
|
172
|
+
}
|
|
173
|
+
clear() {
|
|
174
|
+
this.transactions.clear();
|
|
175
|
+
this.byStatus.clear();
|
|
176
|
+
}
|
|
177
|
+
getAll() {
|
|
178
|
+
return Array.from(this.transactions.values());
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export class TransactionQueue extends EventEmitter {
|
|
182
|
+
store = new TransactionStore();
|
|
183
|
+
// Per-instance executor binding. Set by `setMutationExecutor(...)` from the
|
|
184
|
+
// owning Ablo right after construction. Falls back to `getContext()` only
|
|
185
|
+
// when unset (preserves legacy tests / SDK consumers that haven't migrated).
|
|
186
|
+
//
|
|
187
|
+
// Why this exists: `initSyncEngine()` writes a *module-level* singleton.
|
|
188
|
+
// Constructing a second Ablo (e.g. worker + per-job peer in agent-worker)
|
|
189
|
+
// overwrites the first instance's executor. Without an instance binding,
|
|
190
|
+
// queue commits on Ablo A would dispatch through Ablo B's executor closure,
|
|
191
|
+
// which captures B's `storeHolder.store` — and once B disposes its store,
|
|
192
|
+
// that closure returns `null` for `getWs()` and every commit on A throws
|
|
193
|
+
// `ws_not_ready` forever (queue classifies it as transient → retry loop).
|
|
194
|
+
_mutationExecutor = null;
|
|
195
|
+
get mutationExecutor() {
|
|
196
|
+
return this._mutationExecutor ?? getContext().mutationExecutor;
|
|
197
|
+
}
|
|
198
|
+
executionQueue = [];
|
|
199
|
+
isProcessing = false;
|
|
200
|
+
processTimer;
|
|
201
|
+
processScheduled = false;
|
|
202
|
+
// LINEAR PATTERN: Staging area for transactions created in same event loop tick
|
|
203
|
+
// All transactions go here first, then get committed together via microtask
|
|
204
|
+
createdTransactions = [];
|
|
205
|
+
commitScheduled = false;
|
|
206
|
+
// Per-model in-flight tracking and merge buffer
|
|
207
|
+
inFlightByModel = new Set();
|
|
208
|
+
pendingMergeByModel = new Map();
|
|
209
|
+
// Commit lane: pre-built atomic multi-op envelopes from `ablo.commits.create()`.
|
|
210
|
+
// Drained serially (one envelope at a time) since each is atomic; no
|
|
211
|
+
// coalescing with model-proxy transactions.
|
|
212
|
+
commitLane = [];
|
|
213
|
+
commitStore = new Map();
|
|
214
|
+
commitProcessing = false;
|
|
215
|
+
computePriorityScore(type, modelName) {
|
|
216
|
+
return computePriorityScore(type, modelName);
|
|
217
|
+
}
|
|
218
|
+
ensureDerivedFields(transaction) {
|
|
219
|
+
if (!transaction.modelKey) {
|
|
220
|
+
transaction.modelKey = normalizeModelKey(transaction.modelName);
|
|
221
|
+
}
|
|
222
|
+
if (transaction.priorityScore === undefined) {
|
|
223
|
+
transaction.priorityScore = this.computePriorityScore(transaction.type, transaction.modelName);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Merge two GraphQL update payloads with special handling for metadata fields
|
|
227
|
+
mergeUpdateData(left, right, _modelName) {
|
|
228
|
+
const out = { ...(left || {}) };
|
|
229
|
+
const src = right || {};
|
|
230
|
+
for (const key of Object.keys(src)) {
|
|
231
|
+
// Special case: metadata payloads may be JSON strings; merge objects instead of clobbering
|
|
232
|
+
if (key === 'metadata') {
|
|
233
|
+
const l = out.metadata;
|
|
234
|
+
const r = src.metadata;
|
|
235
|
+
// If both sides undefined/null, continue
|
|
236
|
+
if (l == null && r == null) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// Normalize to objects
|
|
240
|
+
const toObj = (v) => {
|
|
241
|
+
if (v == null)
|
|
242
|
+
return {};
|
|
243
|
+
if (typeof v === 'string') {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(v);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return {};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (typeof v === 'object')
|
|
252
|
+
return v;
|
|
253
|
+
return {};
|
|
254
|
+
};
|
|
255
|
+
const lobj = toObj(l);
|
|
256
|
+
const robj = toObj(r);
|
|
257
|
+
const merged = { ...lobj, ...robj };
|
|
258
|
+
// Re-stringify to match schema input type
|
|
259
|
+
try {
|
|
260
|
+
out.metadata = JSON.stringify(merged);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Fallback to right-hand side if stringify fails
|
|
264
|
+
out.metadata = typeof r === 'string' ? r : JSON.stringify(robj || {});
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// Default: shallow overwrite with right-hand value
|
|
269
|
+
out[key] = src[key];
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
// Configuration - tuned for LINEAR-style batching
|
|
274
|
+
// Higher batch size and delay allows more operations to coalesce into single HTTP call
|
|
275
|
+
config = {
|
|
276
|
+
maxBatchSize: 50, // Increased from 10 - matches Linear's batch size
|
|
277
|
+
batchDelay: 150, // Increased from 50ms - more time to coalesce rapid operations
|
|
278
|
+
maxRetries: 3,
|
|
279
|
+
conflictResolution: {
|
|
280
|
+
strategy: 'last-write-wins',
|
|
281
|
+
},
|
|
282
|
+
enablePersistence: true,
|
|
283
|
+
enableOptimistic: true,
|
|
284
|
+
// Backpressure: don't schedule more batches if too many transactions are executing
|
|
285
|
+
maxExecutingTransactions: 100,
|
|
286
|
+
// Delta confirmation initial timeout - first retry fires at 30s
|
|
287
|
+
// On timeout: retries with exponential backoff (30s → 60s → 120s) instead of rolling back
|
|
288
|
+
deltaConfirmationTimeout: 30000,
|
|
289
|
+
retryBackoff: { baseMs: 200, capMs: 1500 },
|
|
290
|
+
commitOfflineGraceMs: 30_000,
|
|
291
|
+
};
|
|
292
|
+
// Track executing transactions for backpressure
|
|
293
|
+
executingCount = 0;
|
|
294
|
+
// Optimistic update tracking
|
|
295
|
+
optimisticUpdates = new Map();
|
|
296
|
+
// LINEAR PATTERN: Track delta confirmation timeouts for awaiting_delta transactions
|
|
297
|
+
// Following Replicache/PowerSync pattern: retry with backoff instead of rolling back
|
|
298
|
+
deltaConfirmationTimeouts = new Map();
|
|
299
|
+
// Track retry attempts per transaction for exponential backoff
|
|
300
|
+
deltaConfirmationRetries = new Map();
|
|
301
|
+
// Connection state check - set by SyncClient to prevent rollbacks during disconnection
|
|
302
|
+
isConnectedFn = () => true;
|
|
303
|
+
// Grace timer that, when fired, fails any commit-lane transaction
|
|
304
|
+
// still awaiting an ack. Started on `setConnectionState('disconnected')`,
|
|
305
|
+
// cleared on `'connected'`. The reconnect-retry behavior of the queue
|
|
306
|
+
// is preserved for brief blips; this only catches persistent disconnects.
|
|
307
|
+
commitOfflineGraceTimer = null;
|
|
308
|
+
// Track the highest syncId received from WebSocket deltas
|
|
309
|
+
// Used to immediately confirm transactions when HTTP response arrives AFTER the delta
|
|
310
|
+
// (fixes race condition where WebSocket delta arrives before HTTP response)
|
|
311
|
+
lastSeenSyncId = 0;
|
|
312
|
+
// Delta confirmation retry config (Replicache-style exponential backoff)
|
|
313
|
+
// Max retries before requesting full reconciliation
|
|
314
|
+
static DELTA_MAX_RETRIES = 5;
|
|
315
|
+
// Initial timeout (first attempt)
|
|
316
|
+
static DELTA_INITIAL_TIMEOUT_MS = 30_000;
|
|
317
|
+
// Max timeout cap (like Replicache's maxDelayMs of 60s)
|
|
318
|
+
static DELTA_MAX_TIMEOUT_MS = 120_000;
|
|
319
|
+
// Batch management
|
|
320
|
+
batchIndex = 0;
|
|
321
|
+
/**
|
|
322
|
+
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
323
|
+
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
324
|
+
* listeners on `transaction:completed` / `transaction:failed`. Kept off
|
|
325
|
+
* the Transaction row so the store's iteration order stays plain-data
|
|
326
|
+
* and serialization-friendly.
|
|
327
|
+
*/
|
|
328
|
+
confirmationResolvers = new Map();
|
|
329
|
+
constructor(config) {
|
|
330
|
+
super();
|
|
331
|
+
if (config) {
|
|
332
|
+
this.config = { ...this.config, ...config };
|
|
333
|
+
}
|
|
334
|
+
// Centralized fan-in for `tx.confirmation`. Completion/failure are
|
|
335
|
+
// emitted from ~10 sites (delta confirm, immediate confirm, batch
|
|
336
|
+
// success, permanent error, max_retries_exhausted, …). Subscribing
|
|
337
|
+
// once here keeps every emit site intact and guarantees the call-site
|
|
338
|
+
// promise always settles, regardless of which path produced the
|
|
339
|
+
// terminal state.
|
|
340
|
+
this.on('transaction:completed', (tx) => {
|
|
341
|
+
const r = this.confirmationResolvers.get(tx.id);
|
|
342
|
+
if (r) {
|
|
343
|
+
this.confirmationResolvers.delete(tx.id);
|
|
344
|
+
r.resolve();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
this.on('transaction:failed', ({ transaction, error }) => {
|
|
348
|
+
const r = this.confirmationResolvers.get(transaction.id);
|
|
349
|
+
if (r) {
|
|
350
|
+
this.confirmationResolvers.delete(transaction.id);
|
|
351
|
+
r.reject(error);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Look up the in-flight `confirmation` promise for a (model, id) pair.
|
|
357
|
+
* Returns the promise from the most-recent live transaction matching
|
|
358
|
+
* the given model+id, or `Promise.resolve()` if none is open (which
|
|
359
|
+
* means either "already confirmed" or "never staged" — both safe
|
|
360
|
+
* outcomes for the routing-helper grace-window use case).
|
|
361
|
+
*
|
|
362
|
+
* Looks across `pending`, `executing`, and `awaiting_delta` — these
|
|
363
|
+
* are the three non-terminal statuses where rollback is still
|
|
364
|
+
* possible. Skips `completed` (already settled) and `failed` /
|
|
365
|
+
* `rolled_back` (already rejected; the call site missed the
|
|
366
|
+
* `confirmation` window and should rely on `onMutationFailure` toast
|
|
367
|
+
* instead).
|
|
368
|
+
*
|
|
369
|
+
* Distinct from `tx.confirmation` on a known transaction — used by
|
|
370
|
+
* call sites that hold a Model reference (returned by
|
|
371
|
+
* `ablo.<model>.create()`) but never see the underlying transaction.
|
|
372
|
+
*/
|
|
373
|
+
confirmationFor(modelName, modelId) {
|
|
374
|
+
const candidates = [
|
|
375
|
+
...this.store.getByStatus('pending'),
|
|
376
|
+
...this.store.getByStatus('executing'),
|
|
377
|
+
...this.store.getByStatus('awaiting_delta'),
|
|
378
|
+
].filter((tx) => tx.modelName === modelName && tx.modelId === modelId);
|
|
379
|
+
if (candidates.length === 0)
|
|
380
|
+
return Promise.resolve();
|
|
381
|
+
const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
382
|
+
return latest.confirmation ?? Promise.resolve();
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Attach a hot `confirmation` promise to a freshly created transaction.
|
|
386
|
+
* Must be called BEFORE the transaction is staged so the call site can
|
|
387
|
+
* `await tx.confirmation` synchronously after the create/update/delete
|
|
388
|
+
* call returns. Idempotent: returns early if the tx already has one.
|
|
389
|
+
*
|
|
390
|
+
* The unhandled-rejection trap is mandatory — most call sites won't
|
|
391
|
+
* `await confirmation`, and Node/browser would otherwise crash on the
|
|
392
|
+
* rejection. Consumers who *do* want failure visibility just attach a
|
|
393
|
+
* `.then`/`.catch` and the trap becomes a no-op.
|
|
394
|
+
*/
|
|
395
|
+
attachConfirmation(tx) {
|
|
396
|
+
if (tx.confirmation)
|
|
397
|
+
return;
|
|
398
|
+
tx.confirmation = new Promise((resolve, reject) => {
|
|
399
|
+
this.confirmationResolvers.set(tx.id, { resolve, reject });
|
|
400
|
+
});
|
|
401
|
+
tx.confirmation.catch(() => {
|
|
402
|
+
// Swallow unhandled rejections — explicit consumers attach their own
|
|
403
|
+
// handler; silent failure is the leak we're already fixing elsewhere.
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Set connection state checker - prevents rollbacks during disconnection.
|
|
408
|
+
* When disconnected, timeouts re-schedule instead of rolling back.
|
|
409
|
+
*/
|
|
410
|
+
setConnectionChecker(fn) {
|
|
411
|
+
this.isConnectedFn = fn;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Drive the offline-grace timer for in-flight commit-lane transactions.
|
|
415
|
+
*
|
|
416
|
+
* On `'disconnected'`: start a one-shot timer of
|
|
417
|
+
* `config.commitOfflineGraceMs`. If the timer fires (disconnect
|
|
418
|
+
* persisted past grace), iterate every commit-lane transaction with
|
|
419
|
+
* `status ∈ {'pending', 'executing'}` and emit
|
|
420
|
+
* `transaction:failed:${id}` with an `AbloConnectionError`. That
|
|
421
|
+
* lets `waitForCommitReceipt` reject in seconds instead of hanging
|
|
422
|
+
* forever — which is what wedged the 2026-05-15 subagent run.
|
|
423
|
+
*
|
|
424
|
+
* On `'connected'`: clear any pending grace timer. Brief blips are
|
|
425
|
+
* absorbed transparently; the existing reconnect-retry path in
|
|
426
|
+
* `processCommitLane` / `flushOfflineQueue` handles the resumption.
|
|
427
|
+
*
|
|
428
|
+
* Called from SyncClient's `setConnectionState` after the
|
|
429
|
+
* `'connection:disconnected'` / `'connection:established'` events.
|
|
430
|
+
*/
|
|
431
|
+
setConnectionState(state) {
|
|
432
|
+
if (state === 'connected') {
|
|
433
|
+
if (this.commitOfflineGraceTimer !== null) {
|
|
434
|
+
clearTimeout(this.commitOfflineGraceTimer);
|
|
435
|
+
this.commitOfflineGraceTimer = null;
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// state === 'disconnected'
|
|
440
|
+
if (this.commitOfflineGraceTimer !== null)
|
|
441
|
+
return; // already armed
|
|
442
|
+
const graceMs = this.config.commitOfflineGraceMs;
|
|
443
|
+
this.commitOfflineGraceTimer = setTimeout(() => {
|
|
444
|
+
this.commitOfflineGraceTimer = null;
|
|
445
|
+
this.failInFlightCommitsOnOffline(graceMs);
|
|
446
|
+
}, graceMs);
|
|
447
|
+
}
|
|
448
|
+
failInFlightCommitsOnOffline(graceMs) {
|
|
449
|
+
const inFlight = [];
|
|
450
|
+
for (const [id, tx] of this.commitStore.entries()) {
|
|
451
|
+
if (tx.status === 'pending' || tx.status === 'executing') {
|
|
452
|
+
inFlight.push(id);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (inFlight.length === 0)
|
|
456
|
+
return;
|
|
457
|
+
getContext().logger.warn(`[TransactionQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
|
|
458
|
+
for (const id of inFlight) {
|
|
459
|
+
const tx = this.commitStore.get(id);
|
|
460
|
+
if (!tx)
|
|
461
|
+
continue;
|
|
462
|
+
const err = new AbloConnectionError(`commit ack abandoned after ${graceMs}ms offline`, { code: 'commit_offline_grace_expired' });
|
|
463
|
+
tx.status = 'failed';
|
|
464
|
+
tx.error = err;
|
|
465
|
+
this.emit(`transaction:failed:${id}`, { error: err });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Bind the executor for this queue instance. Called by the owning Ablo
|
|
470
|
+
* right after `BaseSyncedStore` is constructed so the executor's
|
|
471
|
+
* `storeHolder.store` closure resolves to *this* Ablo's WS — not whichever
|
|
472
|
+
* Ablo most recently called `initSyncEngine()`.
|
|
473
|
+
*/
|
|
474
|
+
setMutationExecutor(executor) {
|
|
475
|
+
this._mutationExecutor = executor;
|
|
476
|
+
}
|
|
477
|
+
// ============================================================================
|
|
478
|
+
// LINEAR PATTERN: Microtask-based Transaction Staging
|
|
479
|
+
// ============================================================================
|
|
480
|
+
//
|
|
481
|
+
// All transactions first go to `createdTransactions` staging area.
|
|
482
|
+
// A microtask commits them all together with the same batchIndex.
|
|
483
|
+
// This ensures that bulk operations (like importing 100 layers) are batched efficiently.
|
|
484
|
+
//
|
|
485
|
+
// Flow:
|
|
486
|
+
// 1. create()/update()/delete() calls stageTransaction()
|
|
487
|
+
// 2. stageTransaction() adds to createdTransactions and schedules microtask
|
|
488
|
+
// 3. Microtask runs commitCreatedTransactions() after current sync code completes
|
|
489
|
+
// 4. All staged transactions get same batchIndex and move to executionQueue
|
|
490
|
+
// ============================================================================
|
|
491
|
+
/**
|
|
492
|
+
* Stage a transaction for commit (Linear pattern)
|
|
493
|
+
* Transactions staged in the same event loop tick will be committed together
|
|
494
|
+
*/
|
|
495
|
+
stageTransaction(transaction) {
|
|
496
|
+
this.createdTransactions.push(transaction);
|
|
497
|
+
this.scheduleCommit();
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Schedule commit of staged transactions via microtask
|
|
501
|
+
* This ensures all synchronous transaction creates are batched together
|
|
502
|
+
*/
|
|
503
|
+
scheduleCommit() {
|
|
504
|
+
if (this.commitScheduled)
|
|
505
|
+
return;
|
|
506
|
+
this.commitScheduled = true;
|
|
507
|
+
// Use queueMicrotask to run after current sync code completes
|
|
508
|
+
// All transactions created in same event loop will be committed together
|
|
509
|
+
const schedule = typeof queueMicrotask === 'function'
|
|
510
|
+
? queueMicrotask
|
|
511
|
+
: (cb) => Promise.resolve().then(cb);
|
|
512
|
+
schedule(() => {
|
|
513
|
+
this.commitCreatedTransactions();
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Commit all staged transactions to the execution queue (Linear pattern)
|
|
518
|
+
* All transactions get the same batchIndex for efficient batching
|
|
519
|
+
*/
|
|
520
|
+
commitCreatedTransactions() {
|
|
521
|
+
this.commitScheduled = false;
|
|
522
|
+
if (this.createdTransactions.length === 0)
|
|
523
|
+
return;
|
|
524
|
+
// Increment batch index - all transactions in this commit share it
|
|
525
|
+
this.batchIndex++;
|
|
526
|
+
const currentBatchIndex = this.batchIndex;
|
|
527
|
+
// Log batch commit for performance monitoring
|
|
528
|
+
getContext().logger.debug('[TransactionQueue] commitCreatedTransactions', {
|
|
529
|
+
count: this.createdTransactions.length,
|
|
530
|
+
batchIndex: currentBatchIndex,
|
|
531
|
+
types: this.createdTransactions.map((t) => `${t.type}:${t.modelName}`),
|
|
532
|
+
});
|
|
533
|
+
// Move all staged transactions to execution queue
|
|
534
|
+
const staged = this.createdTransactions;
|
|
535
|
+
this.createdTransactions = [];
|
|
536
|
+
for (const transaction of staged) {
|
|
537
|
+
// Assign batch ID based on current batch index
|
|
538
|
+
transaction.batchId = `batch_${currentBatchIndex}`;
|
|
539
|
+
this.enqueue(transaction);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Batch flush all pending transactions via commit (fast path on reconnect)
|
|
543
|
+
async flushOfflineQueue() {
|
|
544
|
+
// Kick the commit lane too — pending atomic envelopes from
|
|
545
|
+
// `commits.create()` were left at the head of the lane while the WS
|
|
546
|
+
// was down. Fire-and-forget; processCommitLane self-serializes.
|
|
547
|
+
void this.processCommitLane();
|
|
548
|
+
// Collect pending transactions in created order
|
|
549
|
+
const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
|
|
550
|
+
if (pending.length === 0)
|
|
551
|
+
return;
|
|
552
|
+
// Build operations list
|
|
553
|
+
const operations = pending.map((tx) => {
|
|
554
|
+
this.ensureDerivedFields(tx);
|
|
555
|
+
return applyStaleWriteOptions({
|
|
556
|
+
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
557
|
+
model: tx.modelKey,
|
|
558
|
+
id: tx.modelId,
|
|
559
|
+
input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
|
|
560
|
+
}, tx);
|
|
561
|
+
});
|
|
562
|
+
try {
|
|
563
|
+
const res = await this.mutationExecutor.commit(operations);
|
|
564
|
+
// Mark all as completed
|
|
565
|
+
for (const tx of pending) {
|
|
566
|
+
this.store.updateStatus(tx.id, 'completed');
|
|
567
|
+
this.emit('transaction:completed', tx);
|
|
568
|
+
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
569
|
+
this.optimisticUpdates.delete(tx.id);
|
|
570
|
+
}
|
|
571
|
+
// Simple perf note
|
|
572
|
+
getContext().logger.debug('txn:commit', 0, {
|
|
573
|
+
count: pending.length,
|
|
574
|
+
lastSyncId: res?.lastSyncId,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
// If batch fails, fall back to normal processing
|
|
579
|
+
// Only log if we're online (if we're offline, this is expected)
|
|
580
|
+
const isOffline = !getContext().onlineStatus.isOnline();
|
|
581
|
+
const isNetworkError = err instanceof Error &&
|
|
582
|
+
(err.message.includes('Failed to fetch') ||
|
|
583
|
+
err.message.includes('Network request failed') ||
|
|
584
|
+
err.message.includes('NetworkError'));
|
|
585
|
+
if (!isOffline || !isNetworkError) {
|
|
586
|
+
getContext().observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
|
|
587
|
+
error: err instanceof Error ? err.message : String(err),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
// Enqueue pending ones to executionQueue
|
|
591
|
+
for (const tx of pending) {
|
|
592
|
+
this.enqueue(tx);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Create operation with optimistic update
|
|
598
|
+
*/
|
|
599
|
+
async create(model, context, writeOptions) {
|
|
600
|
+
const actualModelName = model.getModelName();
|
|
601
|
+
const transaction = {
|
|
602
|
+
id: this.generateId(),
|
|
603
|
+
type: 'create',
|
|
604
|
+
modelName: actualModelName,
|
|
605
|
+
modelId: model.id,
|
|
606
|
+
modelKey: normalizeModelKey(actualModelName),
|
|
607
|
+
priorityScore: this.computePriorityScore('create', actualModelName),
|
|
608
|
+
data: this.extractCreateData(model),
|
|
609
|
+
// CREATE rollback removes the row — there is no prior state to
|
|
610
|
+
// restore, so allocating a `toJSON()` snapshot here was waste.
|
|
611
|
+
previousData: null,
|
|
612
|
+
context,
|
|
613
|
+
status: 'pending',
|
|
614
|
+
createdAt: Date.now(),
|
|
615
|
+
attempts: 0,
|
|
616
|
+
priority: 'normal',
|
|
617
|
+
writeOptions,
|
|
618
|
+
};
|
|
619
|
+
this.attachConfirmation(transaction);
|
|
620
|
+
this.store.add(transaction);
|
|
621
|
+
if (this.config.enableOptimistic) {
|
|
622
|
+
this.applyOptimisticCreate(model, transaction);
|
|
623
|
+
}
|
|
624
|
+
// Microtask coalescer (`scheduleCommit`) collapses all creates in
|
|
625
|
+
// this tick into one wire commit with one `batchIndex` — see
|
|
626
|
+
// `commitCreatedTransactions`. No batch API needed at the call site.
|
|
627
|
+
this.stageTransaction(transaction);
|
|
628
|
+
this.emit('transaction:created', transaction);
|
|
629
|
+
return transaction;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Update operation with conflict detection
|
|
633
|
+
* @param precomputedChanges - Optional pre-captured changes (avoids re-reading from model)
|
|
634
|
+
*/
|
|
635
|
+
async update(model, context, precomputedChanges, writeOptions) {
|
|
636
|
+
const actualModelName = model.getModelName();
|
|
637
|
+
// Use pre-computed changes if provided, otherwise extract from model
|
|
638
|
+
const updateInput = precomputedChanges
|
|
639
|
+
? this.mapChangesToInput(actualModelName, precomputedChanges)
|
|
640
|
+
: this.extractUpdateData(model);
|
|
641
|
+
const previousData = this.extractPreviousData(model, updateInput);
|
|
642
|
+
const modelKey = normalizeModelKey(actualModelName);
|
|
643
|
+
const priorityScore = this.computePriorityScore('update', actualModelName);
|
|
644
|
+
const transaction = {
|
|
645
|
+
id: this.generateId(),
|
|
646
|
+
type: 'update',
|
|
647
|
+
modelName: actualModelName,
|
|
648
|
+
modelId: model.id,
|
|
649
|
+
modelKey,
|
|
650
|
+
priorityScore,
|
|
651
|
+
data: updateInput,
|
|
652
|
+
previousData,
|
|
653
|
+
context,
|
|
654
|
+
status: 'pending',
|
|
655
|
+
createdAt: Date.now(),
|
|
656
|
+
attempts: 0,
|
|
657
|
+
priority: this.isReorderPayload(updateInput) ? 'high' : 'normal',
|
|
658
|
+
writeOptions,
|
|
659
|
+
};
|
|
660
|
+
this.attachConfirmation(transaction);
|
|
661
|
+
this.store.add(transaction);
|
|
662
|
+
// Apply optimistic update
|
|
663
|
+
if (this.config.enableOptimistic) {
|
|
664
|
+
this.applyOptimisticUpdate(model, transaction);
|
|
665
|
+
}
|
|
666
|
+
// LINEAR PATTERN: Stage transaction for microtask commit
|
|
667
|
+
// Multiple updates in same event loop will be batched together
|
|
668
|
+
// enqueue() will still apply its coalescing logic for same-entity updates
|
|
669
|
+
this.stageTransaction(transaction);
|
|
670
|
+
this.emit('transaction:created', transaction);
|
|
671
|
+
return transaction;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Delete operation with cascade handling
|
|
675
|
+
*/
|
|
676
|
+
async delete(model, context, writeOptions) {
|
|
677
|
+
// 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
|
|
678
|
+
const actualModelName = model.getModelName();
|
|
679
|
+
// Skip Activity delete transactions - activities are permanent audit records
|
|
680
|
+
if (actualModelName === 'Activity') {
|
|
681
|
+
getContext().logger.debug('TransactionQueue.delete() skipping Activity deletion - permanent audit records', { modelId: model.id });
|
|
682
|
+
const modelKey = normalizeModelKey(actualModelName);
|
|
683
|
+
const priorityScore = this.computePriorityScore('delete', actualModelName);
|
|
684
|
+
const mockTransaction = {
|
|
685
|
+
id: this.generateId(),
|
|
686
|
+
type: 'delete',
|
|
687
|
+
modelName: actualModelName,
|
|
688
|
+
modelId: model.id,
|
|
689
|
+
modelKey,
|
|
690
|
+
priorityScore,
|
|
691
|
+
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
692
|
+
context,
|
|
693
|
+
status: 'completed',
|
|
694
|
+
createdAt: Date.now(),
|
|
695
|
+
attempts: 0,
|
|
696
|
+
priority: 'high',
|
|
697
|
+
writeOptions,
|
|
698
|
+
// Activity deletes complete synchronously (audit-record skip path).
|
|
699
|
+
// Pre-resolved so consumers can still `await tx.confirmation` uniformly.
|
|
700
|
+
confirmation: Promise.resolve(),
|
|
701
|
+
};
|
|
702
|
+
// Apply optimistic delete for UI feedback
|
|
703
|
+
if (this.config.enableOptimistic) {
|
|
704
|
+
this.applyOptimisticDelete(model, mockTransaction);
|
|
705
|
+
}
|
|
706
|
+
this.emit('transaction:created', mockTransaction);
|
|
707
|
+
this.emit('transaction:completed', mockTransaction);
|
|
708
|
+
return mockTransaction;
|
|
709
|
+
}
|
|
710
|
+
const modelKey = normalizeModelKey(actualModelName);
|
|
711
|
+
const priorityScore = this.computePriorityScore('delete', actualModelName);
|
|
712
|
+
const transaction = {
|
|
713
|
+
id: this.generateId(),
|
|
714
|
+
type: 'delete',
|
|
715
|
+
modelName: actualModelName,
|
|
716
|
+
modelId: model.id,
|
|
717
|
+
modelKey,
|
|
718
|
+
priorityScore,
|
|
719
|
+
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
720
|
+
context,
|
|
721
|
+
status: 'pending',
|
|
722
|
+
createdAt: Date.now(),
|
|
723
|
+
attempts: 0,
|
|
724
|
+
priority: 'high', // Deletes are high priority
|
|
725
|
+
writeOptions,
|
|
726
|
+
};
|
|
727
|
+
this.attachConfirmation(transaction);
|
|
728
|
+
this.store.add(transaction);
|
|
729
|
+
// Cancel any pending/in-flight updates for this model to prevent "no rows" errors
|
|
730
|
+
// when the delete executes before the update (race condition fix)
|
|
731
|
+
this.cancelTransactionsForModel(model.id, 'update');
|
|
732
|
+
this.pendingMergeByModel.delete(`${actualModelName}:${model.id}`);
|
|
733
|
+
this.inFlightByModel.delete(`${actualModelName}:${model.id}`);
|
|
734
|
+
// Apply optimistic delete
|
|
735
|
+
if (this.config.enableOptimistic) {
|
|
736
|
+
this.applyOptimisticDelete(model, transaction);
|
|
737
|
+
}
|
|
738
|
+
// LINEAR PATTERN: Stage transaction for microtask commit
|
|
739
|
+
// All deletes in same event loop will be batched together
|
|
740
|
+
this.stageTransaction(transaction);
|
|
741
|
+
this.emit('transaction:created', transaction);
|
|
742
|
+
return transaction;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Upload attachment — delegates to attachment-uploader.ts
|
|
746
|
+
*/
|
|
747
|
+
async uploadAttachment(_file, options, _context) {
|
|
748
|
+
return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Batch upload attachments — delegates to MutationExecutor
|
|
752
|
+
*/
|
|
753
|
+
async batchUploadAttachments(_files, items, _context) {
|
|
754
|
+
return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Archive operation
|
|
758
|
+
*/
|
|
759
|
+
async archive(model, context, writeOptions) {
|
|
760
|
+
// 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
|
|
761
|
+
const actualModelName = model.getModelName();
|
|
762
|
+
const modelKey = normalizeModelKey(actualModelName);
|
|
763
|
+
const priorityScore = this.computePriorityScore('archive', actualModelName);
|
|
764
|
+
const transaction = {
|
|
765
|
+
id: this.generateId(),
|
|
766
|
+
type: 'archive',
|
|
767
|
+
modelName: actualModelName,
|
|
768
|
+
modelId: model.id,
|
|
769
|
+
modelKey,
|
|
770
|
+
priorityScore,
|
|
771
|
+
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
772
|
+
context,
|
|
773
|
+
status: 'pending',
|
|
774
|
+
createdAt: Date.now(),
|
|
775
|
+
attempts: 0,
|
|
776
|
+
priority: 'normal',
|
|
777
|
+
writeOptions,
|
|
778
|
+
};
|
|
779
|
+
this.attachConfirmation(transaction);
|
|
780
|
+
this.store.add(transaction);
|
|
781
|
+
// LINEAR PATTERN: Stage transaction for microtask commit
|
|
782
|
+
this.stageTransaction(transaction);
|
|
783
|
+
this.emit('transaction:created', transaction);
|
|
784
|
+
return transaction;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Unarchive operation
|
|
788
|
+
*/
|
|
789
|
+
async unarchive(model, context) {
|
|
790
|
+
// 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
|
|
791
|
+
const actualModelName = model.getModelName();
|
|
792
|
+
const modelKey = normalizeModelKey(actualModelName);
|
|
793
|
+
const priorityScore = this.computePriorityScore('unarchive', actualModelName);
|
|
794
|
+
const transaction = {
|
|
795
|
+
id: this.generateId(),
|
|
796
|
+
type: 'unarchive',
|
|
797
|
+
modelName: actualModelName,
|
|
798
|
+
modelId: model.id,
|
|
799
|
+
modelKey,
|
|
800
|
+
priorityScore,
|
|
801
|
+
previousData: model.toJSON ? model.toJSON() : { ...model },
|
|
802
|
+
context,
|
|
803
|
+
status: 'pending',
|
|
804
|
+
createdAt: Date.now(),
|
|
805
|
+
attempts: 0,
|
|
806
|
+
priority: 'normal',
|
|
807
|
+
};
|
|
808
|
+
this.attachConfirmation(transaction);
|
|
809
|
+
this.store.add(transaction);
|
|
810
|
+
// LINEAR PATTERN: Stage transaction for microtask commit
|
|
811
|
+
this.stageTransaction(transaction);
|
|
812
|
+
this.emit('transaction:created', transaction);
|
|
813
|
+
return transaction;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Enqueue transaction for execution
|
|
817
|
+
*/
|
|
818
|
+
enqueue(transaction) {
|
|
819
|
+
this.ensureDerivedFields(transaction);
|
|
820
|
+
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
821
|
+
// LINEAR PATTERN: Simplified coalescing for updates
|
|
822
|
+
// Staging already batches all transactions in same event loop tick
|
|
823
|
+
// We only need to handle: (1) in-flight merging, (2) same-entity merging
|
|
824
|
+
if (transaction.type === 'update') {
|
|
825
|
+
const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
|
|
826
|
+
// If there is an in-flight update for this model, merge into post-flight buffer
|
|
827
|
+
if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
|
|
828
|
+
const prev = this.pendingMergeByModel.get(modelKey) || {};
|
|
829
|
+
const merged = this.mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
|
|
830
|
+
this.pendingMergeByModel.set(modelKey, merged);
|
|
831
|
+
this.store.remove(transaction.id);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// If there's a pending update for same model in execution queue, merge into it
|
|
835
|
+
const pendingInQueue = this.executionQueue.find((t) => t.id !== transaction.id &&
|
|
836
|
+
t.type === 'update' &&
|
|
837
|
+
t.modelId === transaction.modelId &&
|
|
838
|
+
t.modelName === transaction.modelName &&
|
|
839
|
+
!hasStaleWriteOptions(t.writeOptions));
|
|
840
|
+
if (!preserveWatermark && pendingInQueue) {
|
|
841
|
+
pendingInQueue.data = this.mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
|
|
842
|
+
this.store.remove(transaction.id);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Add to execution queue based on priority
|
|
847
|
+
if (transaction.priority === 'high') {
|
|
848
|
+
this.executionQueue.unshift(transaction);
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
this.executionQueue.push(transaction);
|
|
852
|
+
}
|
|
853
|
+
this.scheduleProcessing(transaction.priority === 'high');
|
|
854
|
+
}
|
|
855
|
+
scheduleProcessing(immediate = false) {
|
|
856
|
+
if (this.processScheduled)
|
|
857
|
+
return;
|
|
858
|
+
// BACKPRESSURE: Don't schedule if too many transactions are already executing
|
|
859
|
+
// This prevents overwhelming the server with concurrent requests (Linear pattern)
|
|
860
|
+
if (this.executingCount >= this.config.maxExecutingTransactions) {
|
|
861
|
+
getContext().logger.debug('[TransactionQueue] Backpressure: delaying batch, too many executing', {
|
|
862
|
+
executingCount: this.executingCount,
|
|
863
|
+
max: this.config.maxExecutingTransactions,
|
|
864
|
+
});
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
this.processScheduled = true;
|
|
868
|
+
if (immediate || (this.config.batchDelay ?? 0) <= 0) {
|
|
869
|
+
const schedule = typeof queueMicrotask === 'function'
|
|
870
|
+
? queueMicrotask
|
|
871
|
+
: (cb) => Promise.resolve().then(cb);
|
|
872
|
+
schedule(() => {
|
|
873
|
+
this.processScheduled = false;
|
|
874
|
+
void this.processBatch();
|
|
875
|
+
});
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const delay = Math.max(0, this.config.batchDelay);
|
|
879
|
+
this.processTimer = setTimeout(() => {
|
|
880
|
+
this.processTimer = undefined;
|
|
881
|
+
this.processScheduled = false;
|
|
882
|
+
void this.processBatch();
|
|
883
|
+
}, delay);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Process batch of transactions using LINEAR-style unified batch execution.
|
|
887
|
+
*
|
|
888
|
+
* Key optimization: Instead of making separate calls per operation type/model,
|
|
889
|
+
* we collect ALL batchable operations and send them in a SINGLE commit call.
|
|
890
|
+
* The sync-server handles mixed types atomically inside one transaction.
|
|
891
|
+
*
|
|
892
|
+
* This reduces N round-trips to 1, dramatically improving batch latency.
|
|
893
|
+
*/
|
|
894
|
+
async processBatch() {
|
|
895
|
+
const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
896
|
+
if (this.isProcessing || this.executionQueue.length === 0) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
this.isProcessing = true;
|
|
900
|
+
// Declare batch outside try so it's accessible in finally for backpressure tracking
|
|
901
|
+
let batch = [];
|
|
902
|
+
await getContext().observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
|
|
903
|
+
try {
|
|
904
|
+
// Sort executionQueue by FK priority before batch selection
|
|
905
|
+
// This ensures parent entities (Layout, SlideLayout) are always processed
|
|
906
|
+
// before their children (SlideLayoutLayer) across batch boundaries
|
|
907
|
+
this.executionQueue.sort((a, b) => {
|
|
908
|
+
// Ensure derived fields exist (covers restored/persisted transactions)
|
|
909
|
+
this.ensureDerivedFields(a);
|
|
910
|
+
this.ensureDerivedFields(b);
|
|
911
|
+
return a.priorityScore - b.priorityScore;
|
|
912
|
+
});
|
|
913
|
+
// Get batch (now guaranteed to have parent entities before children)
|
|
914
|
+
batch = this.executionQueue.splice(0, this.config.maxBatchSize);
|
|
915
|
+
// Track executing count for backpressure
|
|
916
|
+
this.executingCount += batch.length;
|
|
917
|
+
// Mark all as executing
|
|
918
|
+
for (const tx of batch) {
|
|
919
|
+
const key = `${tx.modelName}:${tx.modelId}`;
|
|
920
|
+
if (tx.type === 'update')
|
|
921
|
+
this.inFlightByModel.add(key);
|
|
922
|
+
this.store.updateStatus(tx.id, 'executing');
|
|
923
|
+
}
|
|
924
|
+
// Build ALL operations for unified commit (SINGLE WS round-trip)
|
|
925
|
+
const batchOps = [];
|
|
926
|
+
for (const tx of batch) {
|
|
927
|
+
// Per-op `transactionId` carries the local tx UUID through
|
|
928
|
+
// the wire so the server can stamp it on the resulting
|
|
929
|
+
// sync delta. The receive path (`SyncClient.applyDeltaBatchToPool`)
|
|
930
|
+
// matches it via `OptimisticEchoTracker.consumeEcho` to suppress
|
|
931
|
+
// double-applying optimistic mutations. Distinct from the
|
|
932
|
+
// batch-level idempotency key in mutation_log.
|
|
933
|
+
const op = applyStaleWriteOptions({
|
|
934
|
+
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
935
|
+
model: tx.modelKey,
|
|
936
|
+
id: tx.modelId,
|
|
937
|
+
input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
|
|
938
|
+
transactionId: tx.id,
|
|
939
|
+
}, tx);
|
|
940
|
+
batchOps.push({ tx, op });
|
|
941
|
+
}
|
|
942
|
+
// Execute unified commit for ALL operations (SINGLE WS round-trip)
|
|
943
|
+
if (batchOps.length > 0) {
|
|
944
|
+
const operations = batchOps.map(({ op }) => op);
|
|
945
|
+
try {
|
|
946
|
+
// LINEAR PATTERN: Capture lastSyncId from server response for threshold-based confirmation
|
|
947
|
+
//
|
|
948
|
+
// Idempotency note: the default HTTP executor derives a
|
|
949
|
+
// stable `Idempotency-Key` from the operations array
|
|
950
|
+
// itself (sorted sha256), so retries of the SAME batch
|
|
951
|
+
// hit the server's `mutation_log` replay path without
|
|
952
|
+
// requiring us to thread a key through the microtask
|
|
953
|
+
// boundary here. Keeping this path await-free preserves
|
|
954
|
+
// the coalescing test's tight bound on batch count.
|
|
955
|
+
const result = await this.mutationExecutor.commit(operations);
|
|
956
|
+
const lastSyncId = result?.lastSyncId ?? 0;
|
|
957
|
+
// Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
|
|
958
|
+
if (lastSyncId === 0) {
|
|
959
|
+
getContext().observability.captureCommitZeroSyncId({
|
|
960
|
+
operationCount: operations.length,
|
|
961
|
+
operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
// LINEAR PATTERN: Mark as awaiting_delta with syncId threshold
|
|
965
|
+
// Transactions will be confirmed when any delta with id >= lastSyncId arrives
|
|
966
|
+
for (const { tx } of batchOps) {
|
|
967
|
+
tx.syncIdNeededForCompletion = lastSyncId;
|
|
968
|
+
// Safety net: when lastSyncId is 0, DELETE transactions should be confirmed
|
|
969
|
+
// immediately. DELETEs are idempotent — if no delta was emitted, the entity
|
|
970
|
+
// is already gone and the intent was achieved. Parking DELETEs in awaiting_delta
|
|
971
|
+
// with threshold 0 causes 30s reconciliation delays.
|
|
972
|
+
if (lastSyncId === 0 && tx.type === 'delete') {
|
|
973
|
+
this.store.updateStatus(tx.id, 'completed');
|
|
974
|
+
this.emit('transaction:completed', tx);
|
|
975
|
+
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
976
|
+
this.optimisticUpdates.delete(tx.id);
|
|
977
|
+
getContext().logger.debug('tx:confirm_delete_zero_syncid', {
|
|
978
|
+
txId: tx.id.slice(0, 8),
|
|
979
|
+
model: tx.modelName,
|
|
980
|
+
reason: 'delete_idempotent_no_delta',
|
|
981
|
+
});
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
// FIX: Check if delta already arrived before HTTP response (race condition)
|
|
985
|
+
// WebSocket can be faster than HTTP, so the delta might already be here
|
|
986
|
+
// Guard: only do immediate confirm if lastSyncId > 0 (valid server response)
|
|
987
|
+
if (lastSyncId > 0 && this.lastSeenSyncId >= lastSyncId) {
|
|
988
|
+
// Delta already arrived! Confirm immediately without timeout
|
|
989
|
+
this.store.updateStatus(tx.id, 'completed');
|
|
990
|
+
this.emit('transaction:completed', tx);
|
|
991
|
+
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
992
|
+
this.optimisticUpdates.delete(tx.id);
|
|
993
|
+
getContext().logger.debug('tx:confirm_immediate', {
|
|
994
|
+
txId: tx.id.slice(0, 8),
|
|
995
|
+
model: tx.modelName,
|
|
996
|
+
neededSyncId: lastSyncId,
|
|
997
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
998
|
+
reason: 'delta_arrived_before_http',
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
// Delta hasn't arrived yet, wait for it
|
|
1003
|
+
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
1004
|
+
getContext().logger.debug('tx:awaiting_delta', {
|
|
1005
|
+
txId: tx.id.slice(0, 8),
|
|
1006
|
+
model: tx.modelName,
|
|
1007
|
+
neededSyncId: lastSyncId,
|
|
1008
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1009
|
+
gap: lastSyncId - this.lastSeenSyncId,
|
|
1010
|
+
});
|
|
1011
|
+
// Schedule timeout-based rollback for unconfirmed transactions
|
|
1012
|
+
this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
const errorMessage = error.message || '';
|
|
1018
|
+
// Surface the raw server rejection for the whole batch so
|
|
1019
|
+
// cascaded failures (e.g. Layout FK violation that rolls
|
|
1020
|
+
// back a 6-op transaction) are attributable to a specific
|
|
1021
|
+
// cause instead of each op showing as a generic permanent
|
|
1022
|
+
// error downstream.
|
|
1023
|
+
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1024
|
+
// SyncWebSocket attaches a `diagnostics` snapshot to its
|
|
1025
|
+
// "not connected" / "closed while in flight" rejections.
|
|
1026
|
+
// Surface it here so the warn line attributes the drop to
|
|
1027
|
+
// a specific cause (handshake reject, heartbeat zombie,
|
|
1028
|
+
// session expiry, …) instead of just "AbloConnectionError".
|
|
1029
|
+
const readDiagnostics = (e) => {
|
|
1030
|
+
let cur = e;
|
|
1031
|
+
// Walk up to 3 wrap layers (current err → its cause → its
|
|
1032
|
+
// cause's cause) so diagnostics survive AbloConnectionError
|
|
1033
|
+
// wrapping in Ablo.commit() and any future wrappers.
|
|
1034
|
+
for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
|
|
1035
|
+
if ('diagnostics' in cur && cur.diagnostics) {
|
|
1036
|
+
return cur.diagnostics;
|
|
1037
|
+
}
|
|
1038
|
+
cur = cur.cause;
|
|
1039
|
+
}
|
|
1040
|
+
return undefined;
|
|
1041
|
+
};
|
|
1042
|
+
const diagnostics = readDiagnostics(error);
|
|
1043
|
+
getContext().logger.warn('[TransactionQueue] Batch commit rejected', {
|
|
1044
|
+
batchSize: batchOps.length,
|
|
1045
|
+
models: batchOps.map(({ op }) => `${op.type}:${op.model}`),
|
|
1046
|
+
errorType: abloErr?.type ?? error?.name,
|
|
1047
|
+
errorCode: abloErr?.code,
|
|
1048
|
+
httpStatus: abloErr?.httpStatus,
|
|
1049
|
+
requestId: abloErr?.requestId,
|
|
1050
|
+
message: errorMessage,
|
|
1051
|
+
diagnostics,
|
|
1052
|
+
});
|
|
1053
|
+
// LINEAR PATTERN: Handle "no rows in result set" gracefully
|
|
1054
|
+
// This error means the entity was already deleted - for UPDATE/DELETE ops, this is success
|
|
1055
|
+
// The intent was achieved (the data doesn't exist), so treat as completed
|
|
1056
|
+
if (errorMessage.includes('no rows in result set')) {
|
|
1057
|
+
getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
|
|
1058
|
+
batchSize: batchOps.length,
|
|
1059
|
+
});
|
|
1060
|
+
for (const { tx, op } of batchOps) {
|
|
1061
|
+
if (op.type === 'UPDATE' || op.type === 'DELETE') {
|
|
1062
|
+
// Entity gone = intent achieved, mark as completed
|
|
1063
|
+
this.store.updateStatus(tx.id, 'completed');
|
|
1064
|
+
this.emit('transaction:completed', tx);
|
|
1065
|
+
getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
|
|
1066
|
+
txId: tx.id.slice(0, 12),
|
|
1067
|
+
model: tx.modelName,
|
|
1068
|
+
type: op.type,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
// CREATE operations on non-existent parent are real failures
|
|
1073
|
+
await this.handleFailure(tx, error);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
// Handle other batch failures - mark all as failed
|
|
1079
|
+
for (const { tx } of batchOps) {
|
|
1080
|
+
await this.handleFailure(tx, error);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// Handle post-execution merge for updates
|
|
1086
|
+
for (const tx of batch) {
|
|
1087
|
+
const key = `${tx.modelName}:${tx.modelId}`;
|
|
1088
|
+
if (tx.type === 'update') {
|
|
1089
|
+
this.inFlightByModel.delete(key);
|
|
1090
|
+
const pending = this.pendingMergeByModel.get(key);
|
|
1091
|
+
if (pending && Object.keys(pending).length > 0) {
|
|
1092
|
+
// Create a single merged follow-up transaction
|
|
1093
|
+
const followUp = {
|
|
1094
|
+
id: this.generateId(),
|
|
1095
|
+
type: 'update',
|
|
1096
|
+
modelName: tx.modelName,
|
|
1097
|
+
modelId: tx.modelId,
|
|
1098
|
+
modelKey: tx.modelKey ?? normalizeModelKey(tx.modelName),
|
|
1099
|
+
data: pending,
|
|
1100
|
+
previousData: undefined,
|
|
1101
|
+
context: tx.context,
|
|
1102
|
+
status: 'pending',
|
|
1103
|
+
createdAt: Date.now(),
|
|
1104
|
+
attempts: 0,
|
|
1105
|
+
priority: 'normal',
|
|
1106
|
+
priorityScore: this.computePriorityScore('update', tx.modelName),
|
|
1107
|
+
};
|
|
1108
|
+
this.pendingMergeByModel.delete(key);
|
|
1109
|
+
this.store.add(followUp);
|
|
1110
|
+
this.enqueue(followUp);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
finally {
|
|
1116
|
+
this.isProcessing = false;
|
|
1117
|
+
// Decrement executing count for backpressure tracking
|
|
1118
|
+
this.executingCount -= batch.length;
|
|
1119
|
+
// Process next batch if needed
|
|
1120
|
+
if (this.executionQueue.length > 0) {
|
|
1121
|
+
this.scheduleProcessing(true);
|
|
1122
|
+
}
|
|
1123
|
+
const batchEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
1124
|
+
getContext().logger.debug('txn:batch', batchEnd - batchStart, {
|
|
1125
|
+
maxBatchSize: this.config.maxBatchSize,
|
|
1126
|
+
remaining: this.executionQueue.length,
|
|
1127
|
+
executingCount: this.executingCount,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
}, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* LINEAR PATTERN: Confirm all awaiting transactions when delta with syncId >= threshold arrives.
|
|
1134
|
+
* This replaces clientMutationId echoing - transactions are confirmed by sync ID threshold.
|
|
1135
|
+
* @param syncId - The sync ID of the received delta
|
|
1136
|
+
*/
|
|
1137
|
+
onDeltaReceived(syncId) {
|
|
1138
|
+
const prevLastSeen = this.lastSeenSyncId;
|
|
1139
|
+
// Track highest syncId seen (fixes race: delta arrives before HTTP response)
|
|
1140
|
+
if (syncId > this.lastSeenSyncId) {
|
|
1141
|
+
this.lastSeenSyncId = syncId;
|
|
1142
|
+
getContext().logger.debug('tx:highwater_update', {
|
|
1143
|
+
prev: prevLastSeen,
|
|
1144
|
+
new: syncId,
|
|
1145
|
+
delta: syncId - prevLastSeen,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
const awaitingTxs = this.store.getByStatus('awaiting_delta');
|
|
1149
|
+
const executingTxs = this.store.getByStatus('executing');
|
|
1150
|
+
// Debug: Show state when delta arrives
|
|
1151
|
+
if (awaitingTxs.length > 0 || executingTxs.length > 0) {
|
|
1152
|
+
getContext().logger.debug('tx:delta_received', {
|
|
1153
|
+
syncId,
|
|
1154
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1155
|
+
awaitingCount: awaitingTxs.length,
|
|
1156
|
+
executingCount: executingTxs.length,
|
|
1157
|
+
awaitingThresholds: awaitingTxs.map((tx) => ({
|
|
1158
|
+
txId: tx.id.slice(0, 8),
|
|
1159
|
+
model: tx.modelName,
|
|
1160
|
+
needed: tx.syncIdNeededForCompletion,
|
|
1161
|
+
willConfirm: tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion,
|
|
1162
|
+
})),
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
// Fast path: no awaiting transactions
|
|
1166
|
+
if (awaitingTxs.length === 0)
|
|
1167
|
+
return;
|
|
1168
|
+
let confirmedCount = 0;
|
|
1169
|
+
for (const tx of awaitingTxs) {
|
|
1170
|
+
// Confirm if this delta's ID meets or exceeds the threshold
|
|
1171
|
+
if (tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion) {
|
|
1172
|
+
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
1173
|
+
this.store.updateStatus(tx.id, 'completed');
|
|
1174
|
+
this.emit('transaction:completed', tx);
|
|
1175
|
+
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1176
|
+
this.optimisticUpdates.delete(tx.id);
|
|
1177
|
+
confirmedCount++;
|
|
1178
|
+
getContext().logger.debug('tx:confirm_via_delta', {
|
|
1179
|
+
txId: tx.id.slice(0, 8),
|
|
1180
|
+
model: tx.modelName,
|
|
1181
|
+
neededSyncId: tx.syncIdNeededForCompletion,
|
|
1182
|
+
receivedSyncId: syncId,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// Log batch summary only if we confirmed something
|
|
1187
|
+
if (confirmedCount > 0) {
|
|
1188
|
+
// Use warn for staging visibility when transactions confirm
|
|
1189
|
+
getContext().observability.breadcrumb('Transactions confirmed via delta', 'sync.transaction', 'info', {
|
|
1190
|
+
count: confirmedCount,
|
|
1191
|
+
syncId,
|
|
1192
|
+
remainingAwaiting: awaitingTxs.length - confirmedCount,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry + reconciliation
|
|
1197
|
+
// Instead of rolling back on timeout (which destroys confirmed server state),
|
|
1198
|
+
// retry with exponential backoff and request reconciliation to catch up on missed deltas.
|
|
1199
|
+
// Only rollback on explicit server rejection, never on timeout.
|
|
1200
|
+
scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
|
|
1201
|
+
// Cancel any existing timeout for this transaction
|
|
1202
|
+
this.cancelDeltaConfirmationTimeout(tx.id);
|
|
1203
|
+
const timeoutHandle = setTimeout(async () => {
|
|
1204
|
+
const currentTx = this.store.get(tx.id);
|
|
1205
|
+
if (!currentTx || currentTx.status !== 'awaiting_delta') {
|
|
1206
|
+
this.deltaConfirmationRetries.delete(tx.id);
|
|
1207
|
+
return; // Already confirmed or failed
|
|
1208
|
+
}
|
|
1209
|
+
// If disconnected, re-schedule with same timeout (no backoff while offline)
|
|
1210
|
+
if (!this.isConnectedFn()) {
|
|
1211
|
+
getContext().logger.warn('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
|
|
1212
|
+
txId: tx.id.slice(0, 8),
|
|
1213
|
+
model: tx.modelName,
|
|
1214
|
+
});
|
|
1215
|
+
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1216
|
+
this.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const retryCount = this.deltaConfirmationRetries.get(tx.id) ?? 0;
|
|
1220
|
+
const diagnosis = this.lastSeenSyncId === 0
|
|
1221
|
+
? 'No deltas received - delta pipeline may be broken'
|
|
1222
|
+
: currentTx.syncIdNeededForCompletion &&
|
|
1223
|
+
this.lastSeenSyncId < currentTx.syncIdNeededForCompletion
|
|
1224
|
+
? 'Delta not yet received - may be lost or delayed'
|
|
1225
|
+
: 'Delta should have confirmed - possible race condition';
|
|
1226
|
+
getContext().observability.captureReconciliation({
|
|
1227
|
+
reason: 'delta_timeout',
|
|
1228
|
+
model: tx.modelName,
|
|
1229
|
+
modelId: tx.modelId,
|
|
1230
|
+
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1231
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1232
|
+
retryCount,
|
|
1233
|
+
connectionState: this.isConnectedFn() ? 'connected' : 'disconnected',
|
|
1234
|
+
});
|
|
1235
|
+
if (retryCount < TransactionQueue.DELTA_MAX_RETRIES) {
|
|
1236
|
+
// RETRY: Request reconciliation and re-schedule with exponential backoff
|
|
1237
|
+
// The server already committed this mutation — we just need the delta to arrive
|
|
1238
|
+
this.deltaConfirmationRetries.set(tx.id, retryCount + 1);
|
|
1239
|
+
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1240
|
+
// Exponential backoff: 30s → 60s → 120s → 120s → 120s (capped)
|
|
1241
|
+
const nextTimeout = Math.min(timeoutMs * 2, TransactionQueue.DELTA_MAX_TIMEOUT_MS);
|
|
1242
|
+
// Emit reconciliation request so SyncedStore can cycle the WebSocket
|
|
1243
|
+
// to trigger delta catch-up from the server
|
|
1244
|
+
this.emit('reconciliation:needed', {
|
|
1245
|
+
reason: 'delta_confirmation_timeout',
|
|
1246
|
+
txId: tx.id,
|
|
1247
|
+
model: tx.modelName,
|
|
1248
|
+
modelId: tx.modelId,
|
|
1249
|
+
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1250
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1251
|
+
retryCount: retryCount + 1,
|
|
1252
|
+
});
|
|
1253
|
+
getContext().logger.warn('[TransactionQueue] Re-scheduling with backoff', {
|
|
1254
|
+
txId: tx.id.slice(0, 8),
|
|
1255
|
+
model: tx.modelName,
|
|
1256
|
+
nextTimeoutMs: nextTimeout,
|
|
1257
|
+
retry: retryCount + 1,
|
|
1258
|
+
});
|
|
1259
|
+
this.scheduleDeltaConfirmationTimeout(tx, nextTimeout);
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
// LINEAR PATTERN: Retries exhausted — persist to IndexedDB instead of rolling back.
|
|
1263
|
+
// The transaction succeeded on the server (HTTP 200), so the data exists server-side.
|
|
1264
|
+
// Persist the awaiting state so it survives tab close. On next session, the WebSocket
|
|
1265
|
+
// reconnect + delta catch-up will naturally confirm it (like Linear's IndexedDB caching).
|
|
1266
|
+
this.deltaConfirmationRetries.delete(tx.id);
|
|
1267
|
+
this.deltaConfirmationTimeouts.delete(tx.id);
|
|
1268
|
+
getContext().observability.captureDeltaRetryExhausted({
|
|
1269
|
+
txId: tx.id,
|
|
1270
|
+
model: tx.modelName,
|
|
1271
|
+
modelId: tx.modelId,
|
|
1272
|
+
retryCount: TransactionQueue.DELTA_MAX_RETRIES,
|
|
1273
|
+
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1274
|
+
});
|
|
1275
|
+
// Emit persist event — SyncClient handles the IDB write
|
|
1276
|
+
this.emit('transaction:persist_awaiting', {
|
|
1277
|
+
txId: tx.id,
|
|
1278
|
+
model: tx.modelName,
|
|
1279
|
+
modelId: tx.modelId,
|
|
1280
|
+
operationType: tx.type,
|
|
1281
|
+
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1282
|
+
});
|
|
1283
|
+
// Also request one final reconciliation cycle
|
|
1284
|
+
this.emit('reconciliation:needed', {
|
|
1285
|
+
reason: 'delta_retries_exhausted',
|
|
1286
|
+
txId: tx.id,
|
|
1287
|
+
model: tx.modelName,
|
|
1288
|
+
modelId: tx.modelId,
|
|
1289
|
+
syncIdNeeded: currentTx.syncIdNeededForCompletion,
|
|
1290
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1291
|
+
retryCount: TransactionQueue.DELTA_MAX_RETRIES,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}, timeoutMs);
|
|
1295
|
+
this.deltaConfirmationTimeouts.set(tx.id, timeoutHandle);
|
|
1296
|
+
}
|
|
1297
|
+
// Cancel a pending delta confirmation timeout and clean up retry tracking
|
|
1298
|
+
cancelDeltaConfirmationTimeout(id) {
|
|
1299
|
+
const timeoutHandle = this.deltaConfirmationTimeouts.get(id);
|
|
1300
|
+
if (timeoutHandle) {
|
|
1301
|
+
clearTimeout(timeoutHandle);
|
|
1302
|
+
this.deltaConfirmationTimeouts.delete(id);
|
|
1303
|
+
}
|
|
1304
|
+
this.deltaConfirmationRetries.delete(id);
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Wait for a transaction to be confirmed via delta echo (Linear pattern)
|
|
1308
|
+
* Reuses existing timeout mechanism from scheduleDeltaConfirmationTimeout
|
|
1309
|
+
*/
|
|
1310
|
+
waitForConfirmation(transactionId) {
|
|
1311
|
+
return new Promise((resolve, reject) => {
|
|
1312
|
+
// Check if already completed
|
|
1313
|
+
const tx = this.store.get(transactionId);
|
|
1314
|
+
if (tx?.status === 'completed') {
|
|
1315
|
+
resolve();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const onCompleted = () => {
|
|
1319
|
+
cleanup();
|
|
1320
|
+
resolve();
|
|
1321
|
+
};
|
|
1322
|
+
const onFailed = ({ error }) => {
|
|
1323
|
+
cleanup();
|
|
1324
|
+
reject(error);
|
|
1325
|
+
};
|
|
1326
|
+
const cleanup = () => {
|
|
1327
|
+
this.off(`transaction:completed:${transactionId}`, onCompleted);
|
|
1328
|
+
this.off(`transaction:failed:${transactionId}`, onFailed);
|
|
1329
|
+
};
|
|
1330
|
+
// Listen to existing events (timeout already handled by scheduleDeltaConfirmationTimeout)
|
|
1331
|
+
this.on(`transaction:completed:${transactionId}`, onCompleted);
|
|
1332
|
+
this.on(`transaction:failed:${transactionId}`, onFailed);
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
// Public: check if a clientMutationId exists in this queue (helps identify self-echo deltas)
|
|
1336
|
+
hasClientMutationId(id) {
|
|
1337
|
+
return !!this.store.get(id) || this.commitStore.has(id);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Enqueue a raw multi-op atomic commit envelope (the `ablo.commits.create`
|
|
1341
|
+
* path). Operations are pre-built by the caller; the queue's job is
|
|
1342
|
+
* retry-on-reconnect + idempotent dedup, NOT optimistic apply or FK
|
|
1343
|
+
* ordering. Same idempotency key (clientTxId) is dropped on the floor
|
|
1344
|
+
* if already in flight — server-side `mutation_log` handles cross-session
|
|
1345
|
+
* dedup; this guard handles same-session double-enqueue.
|
|
1346
|
+
*/
|
|
1347
|
+
enqueueCommit(clientTxId, operations, options = {}) {
|
|
1348
|
+
if (this.commitStore.has(clientTxId))
|
|
1349
|
+
return;
|
|
1350
|
+
const tx = {
|
|
1351
|
+
id: clientTxId,
|
|
1352
|
+
kind: 'commit',
|
|
1353
|
+
operations: [...operations],
|
|
1354
|
+
causedByTaskId: options.causedByTaskId ?? null,
|
|
1355
|
+
status: 'pending',
|
|
1356
|
+
createdAt: Date.now(),
|
|
1357
|
+
attempts: 0,
|
|
1358
|
+
};
|
|
1359
|
+
this.commitStore.set(clientTxId, tx);
|
|
1360
|
+
this.commitLane.push(tx);
|
|
1361
|
+
void this.processCommitLane();
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Drain pending commit-lane envelopes serially. Transient failures
|
|
1365
|
+
* (network, ws_not_ready) leave the head-of-queue tx in `pending` and
|
|
1366
|
+
* break — reconnect handler re-kicks via `flushOfflineQueue`.
|
|
1367
|
+
* Permanent failures emit `transaction:failed:<id>` and drop the tx.
|
|
1368
|
+
*/
|
|
1369
|
+
async processCommitLane() {
|
|
1370
|
+
if (this.commitProcessing)
|
|
1371
|
+
return;
|
|
1372
|
+
this.commitProcessing = true;
|
|
1373
|
+
try {
|
|
1374
|
+
while (this.commitLane.length > 0) {
|
|
1375
|
+
const tx = this.commitLane[0];
|
|
1376
|
+
if (tx.status !== 'pending') {
|
|
1377
|
+
this.commitLane.shift();
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
tx.status = 'executing';
|
|
1381
|
+
tx.attempts += 1;
|
|
1382
|
+
try {
|
|
1383
|
+
const result = await this.mutationExecutor.commit(tx.operations, {
|
|
1384
|
+
idempotencyKey: tx.id,
|
|
1385
|
+
causedByTaskId: tx.causedByTaskId ?? undefined,
|
|
1386
|
+
});
|
|
1387
|
+
tx.lastSyncId = result?.lastSyncId ?? 0;
|
|
1388
|
+
tx.status = 'completed';
|
|
1389
|
+
this.commitLane.shift();
|
|
1390
|
+
this.emit('transaction:completed', tx);
|
|
1391
|
+
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1392
|
+
}
|
|
1393
|
+
catch (err) {
|
|
1394
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1395
|
+
if (!this.isPermanentError(error)) {
|
|
1396
|
+
// Transient — leave at head, retry on next kick (reconnect or
|
|
1397
|
+
// next enqueueCommit). Don't tight-loop while WS is down.
|
|
1398
|
+
tx.status = 'pending';
|
|
1399
|
+
getContext().logger.debug('[TransactionQueue] commit lane transient', {
|
|
1400
|
+
txId: tx.id.slice(0, 12),
|
|
1401
|
+
attempts: tx.attempts,
|
|
1402
|
+
message: error.message,
|
|
1403
|
+
});
|
|
1404
|
+
break;
|
|
1405
|
+
}
|
|
1406
|
+
tx.status = 'failed';
|
|
1407
|
+
tx.error = error;
|
|
1408
|
+
this.commitLane.shift();
|
|
1409
|
+
getContext().logger.warn('[TransactionQueue] commit lane permanent error', {
|
|
1410
|
+
txId: tx.id.slice(0, 12),
|
|
1411
|
+
attempts: tx.attempts,
|
|
1412
|
+
message: error.message,
|
|
1413
|
+
});
|
|
1414
|
+
this.emit('transaction:failed', { transaction: tx, error, permanent: true });
|
|
1415
|
+
this.emit(`transaction:failed:${tx.id}`, { error });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
finally {
|
|
1420
|
+
this.commitProcessing = false;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Promise-based confirmation for a commit-lane transaction. Resolves
|
|
1425
|
+
* with the server-side `lastSyncId` once `mutation_result` lands;
|
|
1426
|
+
* rejects on permanent failure. Backs the `wait: 'confirmed'` semantics
|
|
1427
|
+
* of `ablo.commits.create()`.
|
|
1428
|
+
*/
|
|
1429
|
+
waitForCommitReceipt(clientTxId) {
|
|
1430
|
+
return new Promise((resolve, reject) => {
|
|
1431
|
+
const existing = this.commitStore.get(clientTxId);
|
|
1432
|
+
if (existing?.status === 'completed') {
|
|
1433
|
+
resolve({ lastSyncId: existing.lastSyncId ?? 0 });
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (existing?.status === 'failed' && existing.error) {
|
|
1437
|
+
reject(existing.error);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const onCompleted = (tx) => {
|
|
1441
|
+
cleanup();
|
|
1442
|
+
resolve({ lastSyncId: tx.lastSyncId ?? 0 });
|
|
1443
|
+
};
|
|
1444
|
+
const onFailed = ({ error }) => {
|
|
1445
|
+
cleanup();
|
|
1446
|
+
reject(error);
|
|
1447
|
+
};
|
|
1448
|
+
const cleanup = () => {
|
|
1449
|
+
this.off(`transaction:completed:${clientTxId}`, onCompleted);
|
|
1450
|
+
this.off(`transaction:failed:${clientTxId}`, onFailed);
|
|
1451
|
+
};
|
|
1452
|
+
this.on(`transaction:completed:${clientTxId}`, onCompleted);
|
|
1453
|
+
this.on(`transaction:failed:${clientTxId}`, onFailed);
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
isReorderPayload(data) {
|
|
1457
|
+
if (!data || typeof data !== 'object')
|
|
1458
|
+
return false;
|
|
1459
|
+
return 'order' in data || 'orderKey' in data || 'position' in data;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Determine if an error is transient (retryable) vs permanent (non-retryable).
|
|
1463
|
+
*
|
|
1464
|
+
* IMPORTANT: Uses a BLOCKLIST approach for safety - only retry on known transient errors.
|
|
1465
|
+
* Any unknown error type defaults to permanent (don't retry) to prevent infinite loops.
|
|
1466
|
+
*
|
|
1467
|
+
* Transient errors (will retry):
|
|
1468
|
+
* - Network failures, connection errors, timeouts
|
|
1469
|
+
* - Server errors (5xx status codes)
|
|
1470
|
+
* - Rate limiting (429)
|
|
1471
|
+
*
|
|
1472
|
+
* Permanent errors (won't retry - includes but not limited to):
|
|
1473
|
+
* - Validation errors, constraint violations
|
|
1474
|
+
* - Not found, unauthorized, forbidden
|
|
1475
|
+
* - Any other business logic error from the server
|
|
1476
|
+
*/
|
|
1477
|
+
isPermanentError(error) {
|
|
1478
|
+
// Typed connection error (e.g. ws_not_ready, transport timeout) is
|
|
1479
|
+
// always transient — the message text varies ("SyncWebSocket not
|
|
1480
|
+
// connected", "commit timed out after ...") and string-matching them
|
|
1481
|
+
// is brittle. Class identity is the right signal.
|
|
1482
|
+
if (error instanceof AbloConnectionError) {
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
const message = error?.message?.toLowerCase() || '';
|
|
1486
|
+
// Network/connection errors are transient - retry these
|
|
1487
|
+
const isNetworkError = message.includes('failed to fetch') ||
|
|
1488
|
+
message.includes('network error') ||
|
|
1489
|
+
message.includes('networkerror') ||
|
|
1490
|
+
message.includes('connection refused') ||
|
|
1491
|
+
message.includes('connection reset') ||
|
|
1492
|
+
message.includes('timeout') ||
|
|
1493
|
+
message.includes('econnrefused') ||
|
|
1494
|
+
message.includes('econnreset') ||
|
|
1495
|
+
message.includes('etimedout') ||
|
|
1496
|
+
message.includes('socket hang up');
|
|
1497
|
+
if (isNetworkError) {
|
|
1498
|
+
return false; // Transient - retry
|
|
1499
|
+
}
|
|
1500
|
+
// Check HTTP status codes
|
|
1501
|
+
const status = extractStatusCode(error);
|
|
1502
|
+
// 5xx server errors and 429 rate limiting are transient - retry
|
|
1503
|
+
if (status !== undefined) {
|
|
1504
|
+
if (status >= 500 || status === 429) {
|
|
1505
|
+
return false; // Transient - retry
|
|
1506
|
+
}
|
|
1507
|
+
// Any other status code (4xx except 429) is permanent
|
|
1508
|
+
return true;
|
|
1509
|
+
}
|
|
1510
|
+
// GraphQL errors with HTTP 200 but error payload are permanent
|
|
1511
|
+
// These are validation/business logic errors that won't change on retry
|
|
1512
|
+
const responseErrors = asTransportError(error).response?.errors;
|
|
1513
|
+
if (Array.isArray(responseErrors) && responseErrors.length > 0) {
|
|
1514
|
+
return true; // Permanent - don't retry
|
|
1515
|
+
}
|
|
1516
|
+
// Default: treat unknown errors as permanent to prevent infinite loops
|
|
1517
|
+
// This is the safe default - better to fail fast than retry forever
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Handle transaction failure
|
|
1522
|
+
*/
|
|
1523
|
+
async handleFailure(transaction, error) {
|
|
1524
|
+
transaction.attempts++;
|
|
1525
|
+
// Check if this is a permanent error that should NOT be retried
|
|
1526
|
+
if (this.isPermanentError(error)) {
|
|
1527
|
+
// Elevated to warn — permanent errors mean user writes were rejected
|
|
1528
|
+
// by the server, so the user should be able to see WHY in the
|
|
1529
|
+
// console (not just via Sentry). Include the typed AbloError fields
|
|
1530
|
+
// so the cause is visible: `type`/`code`/`httpStatus` are what
|
|
1531
|
+
// distinguish e.g. FK-violation (AbloValidationError) from auth
|
|
1532
|
+
// expiry (AbloAuthenticationError).
|
|
1533
|
+
try {
|
|
1534
|
+
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1535
|
+
getContext().logger.warn('[TransactionQueue] Permanent error - rolling back', {
|
|
1536
|
+
txId: transaction.id.slice(0, 8),
|
|
1537
|
+
type: transaction.type,
|
|
1538
|
+
model: transaction.modelName,
|
|
1539
|
+
modelId: transaction.modelId.slice(0, 12),
|
|
1540
|
+
errorType: abloErr?.type ?? error?.name,
|
|
1541
|
+
errorCode: abloErr?.code,
|
|
1542
|
+
httpStatus: abloErr?.httpStatus,
|
|
1543
|
+
requestId: abloErr?.requestId,
|
|
1544
|
+
message: error?.message,
|
|
1545
|
+
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
catch { }
|
|
1549
|
+
// Mark as failed immediately and rollback
|
|
1550
|
+
this.store.updateStatus(transaction.id, 'failed');
|
|
1551
|
+
if (this.config.enableOptimistic) {
|
|
1552
|
+
await this.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
1553
|
+
}
|
|
1554
|
+
this.emit('transaction:failed', { transaction, error, permanent: true });
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (transaction.attempts < this.config.maxRetries) {
|
|
1558
|
+
// Backoff for retryable server responses (HTTP 429/503).
|
|
1559
|
+
// Exponential with jitter, capped — tunable via
|
|
1560
|
+
// `TransactionQueueConfig.retryBackoff`.
|
|
1561
|
+
try {
|
|
1562
|
+
const status = extractStatusCode(error);
|
|
1563
|
+
if (status === 429 || status === 503) {
|
|
1564
|
+
const { baseMs, capMs } = this.config.retryBackoff;
|
|
1565
|
+
const delay = Math.min(capMs, Math.floor(baseMs * Math.pow(2, transaction.attempts - 1)));
|
|
1566
|
+
const jitter = Math.floor(Math.random() * 100);
|
|
1567
|
+
await new Promise((r) => setTimeout(r, delay + jitter));
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
catch { }
|
|
1571
|
+
// Retry
|
|
1572
|
+
this.store.updateStatus(transaction.id, 'pending');
|
|
1573
|
+
this.enqueue(transaction);
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
// Mark as failed and rollback
|
|
1577
|
+
this.store.updateStatus(transaction.id, 'failed');
|
|
1578
|
+
if (this.config.enableOptimistic) {
|
|
1579
|
+
await this.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
|
|
1580
|
+
}
|
|
1581
|
+
this.emit('transaction:failed', { transaction, error });
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Conflict resolution
|
|
1586
|
+
*/
|
|
1587
|
+
async handleConflict(transaction, serverData) {
|
|
1588
|
+
const { strategy, resolver } = this.config.conflictResolution;
|
|
1589
|
+
switch (strategy) {
|
|
1590
|
+
case 'last-write-wins':
|
|
1591
|
+
// Server wins, cancel transaction
|
|
1592
|
+
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
1593
|
+
await this.rollbackOptimistic(transaction, 'conflict_server_wins');
|
|
1594
|
+
break;
|
|
1595
|
+
case 'merge':
|
|
1596
|
+
// Merge changes
|
|
1597
|
+
const merged = this.mergeData(transaction.data, serverData);
|
|
1598
|
+
transaction.data = merged;
|
|
1599
|
+
this.enqueue(transaction);
|
|
1600
|
+
break;
|
|
1601
|
+
case 'reject':
|
|
1602
|
+
// Client wins, re-execute
|
|
1603
|
+
this.enqueue(transaction);
|
|
1604
|
+
break;
|
|
1605
|
+
case 'custom':
|
|
1606
|
+
if (resolver) {
|
|
1607
|
+
const resolved = resolver(transaction.data, serverData);
|
|
1608
|
+
transaction.data = resolved;
|
|
1609
|
+
this.enqueue(transaction);
|
|
1610
|
+
}
|
|
1611
|
+
break;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Optimistic updates
|
|
1616
|
+
*/
|
|
1617
|
+
applyOptimisticCreate(model, transaction) {
|
|
1618
|
+
this.optimisticUpdates.set(transaction.id, {
|
|
1619
|
+
model,
|
|
1620
|
+
previousState: null,
|
|
1621
|
+
transaction,
|
|
1622
|
+
});
|
|
1623
|
+
this.emit('optimistic:create', { model, transaction });
|
|
1624
|
+
}
|
|
1625
|
+
applyOptimisticUpdate(model, transaction) {
|
|
1626
|
+
this.optimisticUpdates.set(transaction.id, {
|
|
1627
|
+
model,
|
|
1628
|
+
previousState: transaction.previousData,
|
|
1629
|
+
transaction,
|
|
1630
|
+
});
|
|
1631
|
+
this.emit('optimistic:update', { model, transaction });
|
|
1632
|
+
}
|
|
1633
|
+
applyOptimisticDelete(model, transaction) {
|
|
1634
|
+
this.optimisticUpdates.set(transaction.id, {
|
|
1635
|
+
model,
|
|
1636
|
+
previousState: transaction.previousData,
|
|
1637
|
+
transaction,
|
|
1638
|
+
});
|
|
1639
|
+
this.emit('optimistic:delete', { model, transaction });
|
|
1640
|
+
}
|
|
1641
|
+
async rollbackOptimistic(transaction, reason, error) {
|
|
1642
|
+
const optimistic = this.optimisticUpdates.get(transaction.id);
|
|
1643
|
+
if (!optimistic)
|
|
1644
|
+
return;
|
|
1645
|
+
this.emit('optimistic:rollback', {
|
|
1646
|
+
model: optimistic.model,
|
|
1647
|
+
previousState: optimistic.previousState,
|
|
1648
|
+
transaction,
|
|
1649
|
+
reason: reason ?? 'unknown',
|
|
1650
|
+
error,
|
|
1651
|
+
});
|
|
1652
|
+
this.optimisticUpdates.delete(transaction.id);
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Execute individual transaction via the unified commit path
|
|
1656
|
+
*/
|
|
1657
|
+
async executeTransaction(transaction) {
|
|
1658
|
+
const { type, modelName, modelId, data } = transaction;
|
|
1659
|
+
const schemaName = stripModelSuffix(modelName);
|
|
1660
|
+
const mutationType = TX_TYPE_TO_MUTATION_OP[type];
|
|
1661
|
+
const model = normalizeModelKey(modelName);
|
|
1662
|
+
const input = (type === 'create' || type === 'update') ? data : undefined;
|
|
1663
|
+
try {
|
|
1664
|
+
await this.mutationExecutor.commit([
|
|
1665
|
+
applyStaleWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
|
|
1666
|
+
]);
|
|
1667
|
+
}
|
|
1668
|
+
catch (error) {
|
|
1669
|
+
handleMutationError(error, `${type}-mutation`, schemaName, modelId);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Persistence
|
|
1674
|
+
*/
|
|
1675
|
+
async loadPersistedTransactions(database) {
|
|
1676
|
+
if (!this.config.enablePersistence)
|
|
1677
|
+
return;
|
|
1678
|
+
try {
|
|
1679
|
+
const persisted = await database.getPersistedTransactions();
|
|
1680
|
+
for (const data of persisted) {
|
|
1681
|
+
const transaction = this.deserializeTransaction(data);
|
|
1682
|
+
this.store.add(transaction);
|
|
1683
|
+
this.enqueue(transaction);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
catch (error) {
|
|
1687
|
+
getContext().observability.captureTransactionFailure({
|
|
1688
|
+
context: 'load-persisted-transactions',
|
|
1689
|
+
error: error instanceof Error ? error : String(error),
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
deserializeTransaction(data) {
|
|
1694
|
+
return { ...data, status: 'pending' };
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Cancel transactions for a specific model
|
|
1698
|
+
*/
|
|
1699
|
+
cancelTransactionsForModel(modelId, transactionType) {
|
|
1700
|
+
const cancelledTransactions = [];
|
|
1701
|
+
const allTransactions = [
|
|
1702
|
+
...this.store.getByStatus('pending'),
|
|
1703
|
+
...this.store.getByStatus('executing'),
|
|
1704
|
+
];
|
|
1705
|
+
for (const transaction of allTransactions) {
|
|
1706
|
+
if (transaction.modelId === modelId) {
|
|
1707
|
+
if (!transactionType || transaction.type === transactionType) {
|
|
1708
|
+
cancelledTransactions.push(transaction);
|
|
1709
|
+
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
1710
|
+
this.rollbackOptimistic(transaction, 'model_cancelled');
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return cancelledTransactions;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* LINEAR PATTERN: Cancel transactions for child entities by foreign key
|
|
1718
|
+
*
|
|
1719
|
+
* Used by SyncedStore for cascade cancellation when a parent is deleted.
|
|
1720
|
+
* This keeps FK relationship knowledge in ModelRegistry/SyncedStore,
|
|
1721
|
+
* while TransactionQueue just handles the cancellation mechanics.
|
|
1722
|
+
*
|
|
1723
|
+
* @param childModelName - The child model type (e.g., 'SlideLayer')
|
|
1724
|
+
* @param foreignKey - The FK property name (e.g., 'slideId')
|
|
1725
|
+
* @param parentId - The deleted parent's ID
|
|
1726
|
+
* @returns Number of transactions cancelled
|
|
1727
|
+
*/
|
|
1728
|
+
cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
|
|
1729
|
+
let cancelled = 0;
|
|
1730
|
+
const allTransactions = [
|
|
1731
|
+
...this.store.getByStatus('pending'),
|
|
1732
|
+
...this.store.getByStatus('executing'),
|
|
1733
|
+
...this.store.getByStatus('awaiting_delta'),
|
|
1734
|
+
];
|
|
1735
|
+
for (const transaction of allTransactions) {
|
|
1736
|
+
if (transaction.modelName === childModelName) {
|
|
1737
|
+
// Check if this transaction's data contains the parent FK
|
|
1738
|
+
const fkValue = transaction.data?.[foreignKey];
|
|
1739
|
+
if (fkValue === parentId) {
|
|
1740
|
+
this.store.updateStatus(transaction.id, 'rolled_back');
|
|
1741
|
+
this.rollbackOptimistic(transaction, 'cascade_parent_deleted');
|
|
1742
|
+
cancelled++;
|
|
1743
|
+
getContext().logger.debug('[TransactionQueue] Cascade cancelled orphaned transaction', {
|
|
1744
|
+
txId: transaction.id.slice(0, 12),
|
|
1745
|
+
model: childModelName,
|
|
1746
|
+
foreignKey,
|
|
1747
|
+
parentId: parentId.slice(0, 12),
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return cancelled;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Get count of outstanding transactions
|
|
1756
|
+
*/
|
|
1757
|
+
getOutstandingTransactionCount() {
|
|
1758
|
+
return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Utilities
|
|
1762
|
+
*/
|
|
1763
|
+
generateId() {
|
|
1764
|
+
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
1765
|
+
}
|
|
1766
|
+
mergeData(local, remote) {
|
|
1767
|
+
return { ...(remote || {}), ...(local || {}) };
|
|
1768
|
+
}
|
|
1769
|
+
extractCreateData(model) {
|
|
1770
|
+
return projectCommitPayload(model.getModelName(), model.toJSON(), { dropUndefined: false });
|
|
1771
|
+
}
|
|
1772
|
+
mapChangesToInput(modelName, changes) {
|
|
1773
|
+
return projectCommitPayload(modelName, changes, { dropUndefined: true });
|
|
1774
|
+
}
|
|
1775
|
+
extractUpdateData(model) {
|
|
1776
|
+
return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true });
|
|
1777
|
+
}
|
|
1778
|
+
buildUpdateInput(modelName, changes) {
|
|
1779
|
+
return projectCommitPayload(modelName, changes, { dropUndefined: true });
|
|
1780
|
+
}
|
|
1781
|
+
// Derive previous values for changed fields to support accurate rollback.
|
|
1782
|
+
//
|
|
1783
|
+
// The previous Slide-specific branch reaching into `_data` was removed:
|
|
1784
|
+
// the field name in the comment (`_localChanges`) didn't match the
|
|
1785
|
+
// code (`_data`), no `Slide` class still defines either field, and
|
|
1786
|
+
// hardcoded model-name checks don't belong in a generic queue. If a
|
|
1787
|
+
// model ever needs to surface previous-state outside `modifiedProperties`,
|
|
1788
|
+
// expose a typed `getPreviousData()` accessor on Model and call that.
|
|
1789
|
+
extractPreviousData(model, updateInput) {
|
|
1790
|
+
const prev = { id: model.id };
|
|
1791
|
+
if (model.modifiedProperties instanceof Map && model.modifiedProperties.size > 0) {
|
|
1792
|
+
for (const [key, change] of model.modifiedProperties) {
|
|
1793
|
+
// Only include keys that are part of this update if provided
|
|
1794
|
+
if (updateInput && !(key in updateInput))
|
|
1795
|
+
continue;
|
|
1796
|
+
prev[key] = change.old;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return prev;
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Public API
|
|
1803
|
+
*/
|
|
1804
|
+
getStats() {
|
|
1805
|
+
return {
|
|
1806
|
+
pending: this.store.getByStatus('pending').length,
|
|
1807
|
+
executing: this.store.getByStatus('executing').length,
|
|
1808
|
+
completed: this.store.getByStatus('completed').length,
|
|
1809
|
+
failed: this.store.getByStatus('failed').length,
|
|
1810
|
+
optimistic: this.optimisticUpdates.size,
|
|
1811
|
+
totalTransactions: this.store.getAll().length,
|
|
1812
|
+
batchIndex: this.batchIndex,
|
|
1813
|
+
config: { ...this.config },
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Get detailed debug info for the sync debug page
|
|
1818
|
+
* Exposes internal state that helps diagnose delta confirmation issues
|
|
1819
|
+
*/
|
|
1820
|
+
getDebugInfo() {
|
|
1821
|
+
const awaitingDelta = this.store.getByStatus('awaiting_delta');
|
|
1822
|
+
return {
|
|
1823
|
+
lastSeenSyncId: this.lastSeenSyncId,
|
|
1824
|
+
awaitingDeltaCount: awaitingDelta.length,
|
|
1825
|
+
awaitingDeltaTransactions: awaitingDelta.map((tx) => ({
|
|
1826
|
+
id: tx.id.slice(0, 8),
|
|
1827
|
+
type: tx.type,
|
|
1828
|
+
modelName: tx.modelName,
|
|
1829
|
+
modelId: tx.modelId.slice(0, 8),
|
|
1830
|
+
syncIdNeeded: tx.syncIdNeededForCompletion,
|
|
1831
|
+
createdAt: tx.createdAt,
|
|
1832
|
+
age: Date.now() - tx.createdAt,
|
|
1833
|
+
})),
|
|
1834
|
+
pendingTransactions: this.store.getByStatus('pending').map((tx) => ({
|
|
1835
|
+
id: tx.id.slice(0, 8),
|
|
1836
|
+
type: tx.type,
|
|
1837
|
+
modelName: tx.modelName,
|
|
1838
|
+
modelId: tx.modelId.slice(0, 8),
|
|
1839
|
+
})),
|
|
1840
|
+
executingTransactions: this.store.getByStatus('executing').map((tx) => ({
|
|
1841
|
+
id: tx.id.slice(0, 8),
|
|
1842
|
+
type: tx.type,
|
|
1843
|
+
modelName: tx.modelName,
|
|
1844
|
+
modelId: tx.modelId.slice(0, 8),
|
|
1845
|
+
})),
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Set configuration
|
|
1850
|
+
*/
|
|
1851
|
+
setConfig(config) {
|
|
1852
|
+
this.config = { ...this.config, ...config };
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Handle incoming sync delta - simplified for permanent IDs
|
|
1856
|
+
*/
|
|
1857
|
+
handleSyncDelta(delta) {
|
|
1858
|
+
// With permanent IDs, no reconciliation needed!
|
|
1859
|
+
// Just emit the delta for ObjectPool to handle directly
|
|
1860
|
+
this.emit('sync:delta', {
|
|
1861
|
+
id: delta.id,
|
|
1862
|
+
modelName: delta.modelName,
|
|
1863
|
+
action: delta.action,
|
|
1864
|
+
data: delta.data,
|
|
1865
|
+
});
|
|
1866
|
+
return true;
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Cleanup and dispose resources
|
|
1870
|
+
*/
|
|
1871
|
+
dispose() {
|
|
1872
|
+
// Cancel all active optimistic updates
|
|
1873
|
+
for (const [, optimistic] of this.optimisticUpdates) {
|
|
1874
|
+
this.emit('optimistic:rollback', {
|
|
1875
|
+
model: optimistic.model,
|
|
1876
|
+
previousState: optimistic.previousState,
|
|
1877
|
+
transaction: optimistic.transaction,
|
|
1878
|
+
reason: 'dispose',
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
// Clear processing
|
|
1882
|
+
if (this.processTimer) {
|
|
1883
|
+
clearTimeout(this.processTimer);
|
|
1884
|
+
}
|
|
1885
|
+
// Clear store
|
|
1886
|
+
this.store.clear();
|
|
1887
|
+
this.optimisticUpdates.clear();
|
|
1888
|
+
this.executionQueue = [];
|
|
1889
|
+
// Clear event listeners
|
|
1890
|
+
this.removeAllListeners();
|
|
1891
|
+
// Reset state
|
|
1892
|
+
this.isProcessing = false;
|
|
1893
|
+
this.batchIndex = 0;
|
|
1894
|
+
}
|
|
1895
|
+
}
|