@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,1440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ablo — The one-liner consumer API.
|
|
3
|
+
*
|
|
4
|
+
* Hides all internal wiring (ObjectPool, Database, SyncClient, WebSocket,
|
|
5
|
+
* bootstrap, offline queue, DI adapters) behind a single function call.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { Ablo } from '@ablo/sync-engine/client';
|
|
9
|
+
* import { schema } from './schema';
|
|
10
|
+
*
|
|
11
|
+
* const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
12
|
+
*
|
|
13
|
+
* const tasks = sync.tasks.list({ where: { status: 'todo' } });
|
|
14
|
+
* await sync.tasks.create({ title: 'Fix bug' });
|
|
15
|
+
* await sync.tasks.update(taskId, { status: 'done' });
|
|
16
|
+
* await sync.tasks.delete(taskId);
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { AbloBusyError, AbloError, AbloConnectionError, AbloValidationError, translateHttpError } from '../errors.js';
|
|
20
|
+
import { LoadStrategy, PropertyType } from '../types/index.js';
|
|
21
|
+
import { initSyncEngine } from '../context.js';
|
|
22
|
+
import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
|
|
23
|
+
import { alwaysOnline } from '../adapters/alwaysOnline.js';
|
|
24
|
+
import { validateAbloOptions } from './validateAbloOptions.js';
|
|
25
|
+
import { createInternalComponents } from './createInternalComponents.js';
|
|
26
|
+
import { resolveParticipantIdentity } from './identity.js';
|
|
27
|
+
import { Model } from '../Model.js';
|
|
28
|
+
import { BaseSyncedStore } from '../BaseSyncedStore.js';
|
|
29
|
+
import { createPresenceStream } from '../sync/createPresenceStream.js';
|
|
30
|
+
import { createIntentStream } from '../sync/createIntentStream.js';
|
|
31
|
+
import { createSnapshot } from '../sync/createSnapshot.js';
|
|
32
|
+
import { createParticipantManager } from '../sync/participants.js';
|
|
33
|
+
import { createProtocolClient, } from './ApiClient.js';
|
|
34
|
+
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveAuthToken, resolveBaseURL, } from './auth.js';
|
|
35
|
+
import { shouldUseInMemoryPersistence, } from './persistence.js';
|
|
36
|
+
import { createModelProxy } from './createModelProxy.js';
|
|
37
|
+
// ── Config derivation from schema ─────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Compute a create-priority map from schema `belongsTo` relations using
|
|
40
|
+
* Tarjan's strongly-connected-components algorithm.
|
|
41
|
+
*
|
|
42
|
+
* The FK graph has an edge `child → parent` for every `belongsTo`. Tarjan
|
|
43
|
+
* runs a single linear DFS that simultaneously (a) detects cycles by
|
|
44
|
+
* grouping mutually-reachable nodes into SCCs and (b) emits those SCCs
|
|
45
|
+
* in reverse topological order of the condensation graph. In this edge
|
|
46
|
+
* convention a "sink" SCC has no outgoing edges — i.e. no parents — so
|
|
47
|
+
* it is an *FK root* (`organizations`, `themes`, etc.). Tarjan emits
|
|
48
|
+
* roots first and leaves last, exactly the order in which rows must be
|
|
49
|
+
* inserted to satisfy FK constraints.
|
|
50
|
+
*
|
|
51
|
+
* Priorities are assigned by emit order: SCC #0 → 10, SCC #1 → 20, …
|
|
52
|
+
* Members of the same SCC share a priority, so insertion order wins the
|
|
53
|
+
* tiebreak inside a cycle (this matters for cyclic schemas like
|
|
54
|
+
* `slideDecks ↔ layouts`, where one direction is the user's chosen
|
|
55
|
+
* "soft" edge — only the consumer's mutator sequence knows which one).
|
|
56
|
+
*
|
|
57
|
+
* This algorithm is iteration-order-independent: starting the DFS from
|
|
58
|
+
* any node yields the same SCC partitioning, and SCCs always come out
|
|
59
|
+
* in valid topological order. The previous DFS-with-memoization
|
|
60
|
+
* heuristic broke under cycles by treating the back-edge as depth 0,
|
|
61
|
+
* which made priorities depend on which node the walk happened to
|
|
62
|
+
* enter the cycle at.
|
|
63
|
+
*
|
|
64
|
+
* Schema authors can mark one side of a cycle with
|
|
65
|
+
* `belongsTo(target, fk, { defer: true })`. Those edges are excluded
|
|
66
|
+
* from the dependency graph entirely, which deterministically breaks
|
|
67
|
+
* the cycle and turns the SCC into a chain — the marked child gets a
|
|
68
|
+
* strictly higher priority than its parent instead of being tied with
|
|
69
|
+
* it. Pair with a Postgres `DEFERRABLE INITIALLY DEFERRED` constraint
|
|
70
|
+
* if you want the database side of the cycle to also relax. See
|
|
71
|
+
* {@link BelongsToOptions.defer}.
|
|
72
|
+
*
|
|
73
|
+
* The returned map is keyed by {@link ModelDef.typename} (falling back
|
|
74
|
+
* to the schema key), because that is what `Model.getModelName()`
|
|
75
|
+
* returns at transaction time — keying by schema key would silently
|
|
76
|
+
* miss the lookup and every model would fall through to
|
|
77
|
+
* `defaultCreatePriority`.
|
|
78
|
+
*
|
|
79
|
+
* Reference: Tarjan, R. (1972), "Depth-first search and linear graph
|
|
80
|
+
* algorithms." Linear in V + E.
|
|
81
|
+
*/
|
|
82
|
+
export function computeFKDepthPriority(schema) {
|
|
83
|
+
// schemaKey → typename (wire name used at transaction time)
|
|
84
|
+
const keyToTypename = new Map();
|
|
85
|
+
for (const [key, def] of Object.entries(schema.models)) {
|
|
86
|
+
keyToTypename.set(key, def.typename ?? key);
|
|
87
|
+
}
|
|
88
|
+
// Adjacency: schemaKey → parent schema keys pulled from `belongsTo`.
|
|
89
|
+
// Parents not in the schema (e.g. external types) are dropped so the
|
|
90
|
+
// graph stays closed. Edges marked `{ defer: true }` are also
|
|
91
|
+
// dropped — the schema author has declared this side of a cycle to
|
|
92
|
+
// be the "soft" one (insert with null FK, patch later), so the
|
|
93
|
+
// dependency-graph walker treats it as if the edge weren't there.
|
|
94
|
+
// That breaks the cycle deterministically and lets the other side
|
|
95
|
+
// become a strict topological predecessor.
|
|
96
|
+
const parentsOf = new Map();
|
|
97
|
+
for (const [key, def] of Object.entries(schema.models)) {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const rel of Object.values(def.relations)) {
|
|
100
|
+
if (rel.type !== 'belongsTo')
|
|
101
|
+
continue;
|
|
102
|
+
if (!keyToTypename.has(rel.target))
|
|
103
|
+
continue;
|
|
104
|
+
if (rel.options?.defer === true)
|
|
105
|
+
continue;
|
|
106
|
+
out.push(rel.target);
|
|
107
|
+
}
|
|
108
|
+
parentsOf.set(key, out);
|
|
109
|
+
}
|
|
110
|
+
// Tarjan SCC bookkeeping
|
|
111
|
+
const dfsIndex = new Map();
|
|
112
|
+
const lowlink = new Map();
|
|
113
|
+
const onStack = new Set();
|
|
114
|
+
const stack = [];
|
|
115
|
+
const sccs = [];
|
|
116
|
+
let counter = 0;
|
|
117
|
+
function strongconnect(v) {
|
|
118
|
+
dfsIndex.set(v, counter);
|
|
119
|
+
lowlink.set(v, counter);
|
|
120
|
+
counter++;
|
|
121
|
+
stack.push(v);
|
|
122
|
+
onStack.add(v);
|
|
123
|
+
for (const w of parentsOf.get(v) ?? []) {
|
|
124
|
+
if (!dfsIndex.has(w)) {
|
|
125
|
+
strongconnect(w);
|
|
126
|
+
lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(w)));
|
|
127
|
+
}
|
|
128
|
+
else if (onStack.has(w)) {
|
|
129
|
+
// Back-edge into the active DFS path — w is in the same SCC as v.
|
|
130
|
+
lowlink.set(v, Math.min(lowlink.get(v), dfsIndex.get(w)));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// v is the root of an SCC: pop everything down to v inclusive.
|
|
134
|
+
if (lowlink.get(v) === dfsIndex.get(v)) {
|
|
135
|
+
const component = [];
|
|
136
|
+
let w;
|
|
137
|
+
do {
|
|
138
|
+
w = stack.pop();
|
|
139
|
+
onStack.delete(w);
|
|
140
|
+
component.push(w);
|
|
141
|
+
} while (w !== v);
|
|
142
|
+
sccs.push(component);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const key of keyToTypename.keys()) {
|
|
146
|
+
if (!dfsIndex.has(key))
|
|
147
|
+
strongconnect(key);
|
|
148
|
+
}
|
|
149
|
+
// Tarjan emits SCCs in reverse topological order of the condensation.
|
|
150
|
+
// In our edge convention (child→parent), reverse-topo of the
|
|
151
|
+
// condensation means root-SCCs (no outgoing edges = no parents)
|
|
152
|
+
// first, leaf-SCCs (deepest descendants) last. We could just use
|
|
153
|
+
// emit-order as the priority — but that gives independent sibling
|
|
154
|
+
// SCCs different priorities, which is semantically wrong: siblings
|
|
155
|
+
// don't depend on each other and shouldn't be ordered relative to
|
|
156
|
+
// each other.
|
|
157
|
+
//
|
|
158
|
+
// Instead, do one more pass to compute *longest-path depth* on the
|
|
159
|
+
// condensation DAG: depth(SCC) = max(depth(parent SCC)) + 1, or 0
|
|
160
|
+
// for SCCs with no in-schema parents. SCCs at the same depth get
|
|
161
|
+
// the same priority — siblings stay tied, insertion order in the
|
|
162
|
+
// queue breaks the tie. Priority = (depth + 1) * 10.
|
|
163
|
+
//
|
|
164
|
+
// We can compute this in a single pass over the SCCs because
|
|
165
|
+
// Tarjan's emit-order *is* a valid topological order of the
|
|
166
|
+
// condensation: when we process sccs[i], every parent SCC has
|
|
167
|
+
// already been assigned a depth.
|
|
168
|
+
const nodeToSccIdx = new Map();
|
|
169
|
+
sccs.forEach((scc, i) => {
|
|
170
|
+
for (const node of scc)
|
|
171
|
+
nodeToSccIdx.set(node, i);
|
|
172
|
+
});
|
|
173
|
+
const sccDepth = new Map();
|
|
174
|
+
sccs.forEach((scc, i) => {
|
|
175
|
+
let maxParentDepth = -1;
|
|
176
|
+
for (const node of scc) {
|
|
177
|
+
for (const parent of parentsOf.get(node) ?? []) {
|
|
178
|
+
const parentSccIdx = nodeToSccIdx.get(parent);
|
|
179
|
+
if (parentSccIdx === undefined)
|
|
180
|
+
continue;
|
|
181
|
+
if (parentSccIdx === i)
|
|
182
|
+
continue; // intra-SCC edge — not a dep
|
|
183
|
+
const d = sccDepth.get(parentSccIdx);
|
|
184
|
+
if (d !== undefined && d > maxParentDepth)
|
|
185
|
+
maxParentDepth = d;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
sccDepth.set(i, maxParentDepth + 1);
|
|
189
|
+
});
|
|
190
|
+
const out = new Map();
|
|
191
|
+
sccs.forEach((scc, i) => {
|
|
192
|
+
const priority = (sccDepth.get(i) + 1) * 10;
|
|
193
|
+
for (const key of scc) {
|
|
194
|
+
out.set(keyToTypename.get(key), priority);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
function deriveConfigFromSchema(schema) {
|
|
200
|
+
// Commit payload projection is done directly inside `TransactionQueue`
|
|
201
|
+
// — see `projectCommitPayload` there. Each model's field metadata
|
|
202
|
+
// rides on `ModelRegistry` (populated by `registerModelsFromSchema`),
|
|
203
|
+
// so there's no config-layer shim: the queue asks the registry for
|
|
204
|
+
// the declared fields and serializes accordingly.
|
|
205
|
+
return {
|
|
206
|
+
modelCreatePriority: computeFKDepthPriority(schema),
|
|
207
|
+
defaultCreatePriority: 40,
|
|
208
|
+
defaultNonCreatePriority: 50,
|
|
209
|
+
essentialFields: {},
|
|
210
|
+
classNameFallbackMap: {},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// ── Auto model registration from schema ───────────────────────────────────
|
|
214
|
+
function registerModelsFromSchema(schema, registry) {
|
|
215
|
+
registry.startBatch();
|
|
216
|
+
for (const [schemaKey, modelDef] of Object.entries(schema.models)) {
|
|
217
|
+
// Use typename as the model name — this is the wire-format name that
|
|
218
|
+
// the server sends in bootstrap responses and sync deltas. The pool's
|
|
219
|
+
// typeIndex, the ModelRegistry, and getModelName() all use this name.
|
|
220
|
+
// Schema key (camelCase plural) is only for the consumer-facing proxy API.
|
|
221
|
+
const modelName = modelDef.typename ?? schemaKey;
|
|
222
|
+
// Collect JSON sub-property fields to generate ${field}Json getters
|
|
223
|
+
const jsonSubFields = [];
|
|
224
|
+
for (const [fieldName, zodType] of Object.entries(modelDef.shape)) {
|
|
225
|
+
const inner = unwrapZodType(zodType);
|
|
226
|
+
if (isZodObject(inner)) {
|
|
227
|
+
jsonSubFields.push({ fieldName, subSchema: inner });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Create a dynamic Model subclass with JSON sub-property getters
|
|
231
|
+
const isLazy = modelDef.lazyObservable === true;
|
|
232
|
+
const fieldNames = Object.keys(modelDef.shape);
|
|
233
|
+
const computed = modelDef.computed;
|
|
234
|
+
const DynamicModel = createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed, isLazy);
|
|
235
|
+
// Respect the schema's load strategy so lazy models skip IDB hydration + bootstrap
|
|
236
|
+
const loadStrategy = modelDef.load === 'lazy' || modelDef.load === 'manual'
|
|
237
|
+
? LoadStrategy.lazy
|
|
238
|
+
: LoadStrategy.instant;
|
|
239
|
+
registry.registerModel(modelName, DynamicModel, {
|
|
240
|
+
loadStrategy,
|
|
241
|
+
fields: modelDef.fields,
|
|
242
|
+
autoFill: modelDef.autoFill,
|
|
243
|
+
requiredFields: modelDef.requiredFields,
|
|
244
|
+
});
|
|
245
|
+
// Collect the set of fields that should get an IDB secondary index.
|
|
246
|
+
//
|
|
247
|
+
// Matches Linear's opt-in model (see wzhudev/reverse-linear-sync-engine):
|
|
248
|
+
// `@Reference(..., { indexed: true })`. Only `belongsTo` relations that
|
|
249
|
+
// explicitly set `{ index: true }` in their options get an IDB secondary
|
|
250
|
+
// index. Every other FK (and every scalar) is resolved via in-memory
|
|
251
|
+
// ObjectPool scans, which are fast enough at org-scope sizes (~10k rows)
|
|
252
|
+
// and reactive via MobX.
|
|
253
|
+
//
|
|
254
|
+
// Auto-indexing every belongsTo was wrong: it bloated write amplification
|
|
255
|
+
// for the vast majority of FKs that are never queried by fk. Indexing
|
|
256
|
+
// every scalar (like the legacy Go backend did) is even worse.
|
|
257
|
+
const indexedFields = new Set();
|
|
258
|
+
for (const relDef of Object.values(modelDef.relations)) {
|
|
259
|
+
if (relDef.type === 'belongsTo' && relDef.foreignKey && relDef.options?.index === true) {
|
|
260
|
+
indexedFields.add(relDef.foreignKey);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Register fields as properties (from Zod shape).
|
|
264
|
+
for (const [fieldName, rawZodType] of Object.entries(modelDef.shape)) {
|
|
265
|
+
const zodType = rawZodType;
|
|
266
|
+
const isOptional = zodType.isOptional?.() ?? false;
|
|
267
|
+
// A field is indexed if it's the FK of a `belongsTo({ index: true })`
|
|
268
|
+
// relation. Legacy `description === 'indexed'` still works for
|
|
269
|
+
// consumers using `field.*().indexed()`.
|
|
270
|
+
const isIndexed = indexedFields.has(fieldName) || zodType.description === 'indexed';
|
|
271
|
+
// JSON-typed fields (per the schema's wire-type tag) are opaque
|
|
272
|
+
// blobs from MobX's perspective — chart specs, ProseMirror docs,
|
|
273
|
+
// style maps. Deep observability on them recursively walks every
|
|
274
|
+
// nested property and creates an atom for each leaf, producing a
|
|
275
|
+
// microtask storm on every commit/streaming update. `ref` tracks
|
|
276
|
+
// only reassignment, which is how blob consumers actually use them.
|
|
277
|
+
const wireType = modelDef.fields?.[fieldName]?.type;
|
|
278
|
+
const observability = wireType === 'json' ? 'ref' : undefined;
|
|
279
|
+
registry.registerProperty(modelName, fieldName, {
|
|
280
|
+
type: PropertyType.property,
|
|
281
|
+
indexed: isIndexed,
|
|
282
|
+
optional: isOptional,
|
|
283
|
+
observability,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
// Register relations
|
|
287
|
+
for (const [relName, relDef] of Object.entries(modelDef.relations)) {
|
|
288
|
+
if (relDef.type === 'belongsTo') {
|
|
289
|
+
registry.registerReference(modelName, relName, {
|
|
290
|
+
referencedModel: () => {
|
|
291
|
+
const targetModel = registry.getModelByName(relDef.target);
|
|
292
|
+
return targetModel ?? DynamicModel;
|
|
293
|
+
},
|
|
294
|
+
indexed: true,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else if (relDef.type === 'hasMany') {
|
|
298
|
+
// Generate a getter on the parent model that returns all children
|
|
299
|
+
// matching the FK via Model.getStore().getByForeignKey(). The FK
|
|
300
|
+
// index on the target model is registered by deriveSyncPlanFromSchema.
|
|
301
|
+
const targetName = relDef.target;
|
|
302
|
+
const foreignKey = relDef.foreignKey;
|
|
303
|
+
const orderByField = relDef._orderBy;
|
|
304
|
+
// Resolve the target typename from the schema (might differ from the key)
|
|
305
|
+
const targetDef = schema.models[targetName];
|
|
306
|
+
const targetTypename = targetDef?.typename ?? targetName;
|
|
307
|
+
Object.defineProperty(DynamicModel.prototype, relName, {
|
|
308
|
+
get() {
|
|
309
|
+
const store = Model.getStore();
|
|
310
|
+
if (!store)
|
|
311
|
+
return [];
|
|
312
|
+
const results = store.getByForeignKey(targetTypename, foreignKey, this.id);
|
|
313
|
+
if (orderByField && results.length > 1) {
|
|
314
|
+
return [...results].sort((a, b) => {
|
|
315
|
+
// `orderByField` is a runtime string from the schema's
|
|
316
|
+
// hasMany({ orderBy }) — Models have dynamic typed
|
|
317
|
+
// fields produced by createDynamicModelClass, so the
|
|
318
|
+
// static type doesn't carry an index signature for
|
|
319
|
+
// arbitrary field reads. `Reflect.get` is the typed
|
|
320
|
+
// bridge — returns `unknown`, narrowed below.
|
|
321
|
+
const va = Reflect.get(a, orderByField);
|
|
322
|
+
const vb = Reflect.get(b, orderByField);
|
|
323
|
+
if (typeof va === 'number' && typeof vb === 'number')
|
|
324
|
+
return va - vb;
|
|
325
|
+
if (typeof va === 'string' && typeof vb === 'string')
|
|
326
|
+
return va.localeCompare(vb);
|
|
327
|
+
return 0;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return results;
|
|
331
|
+
},
|
|
332
|
+
enumerable: true,
|
|
333
|
+
configurable: true,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
registry.endBatch();
|
|
339
|
+
}
|
|
340
|
+
// ── JSON sub-property helpers ─────────────────────────────────────────────
|
|
341
|
+
/**
|
|
342
|
+
* Unwrap a Zod schema through .optional(), .nullable(), .default(),
|
|
343
|
+
* .readonly() to find the innermost type. Needed to detect whether a
|
|
344
|
+
* field.json() call wraps a ZodObject (has sub-properties) or a plain
|
|
345
|
+
* type (ZodUnknown, ZodArray, etc.).
|
|
346
|
+
*
|
|
347
|
+
* Uses Zod's public `.unwrap()` API per wrapper type — no `_def`
|
|
348
|
+
* digging. Bounded loop guards against pathological self-referential
|
|
349
|
+
* wrappers.
|
|
350
|
+
*/
|
|
351
|
+
function unwrapZodType(schema) {
|
|
352
|
+
let current = schema;
|
|
353
|
+
for (let i = 0; i < 10; i++) {
|
|
354
|
+
if (current instanceof z.ZodOptional) {
|
|
355
|
+
current = current.unwrap();
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (current instanceof z.ZodNullable) {
|
|
359
|
+
current = current.unwrap();
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (current instanceof z.ZodDefault) {
|
|
363
|
+
// v4 deprecates removeDefault in favor of unwrap, but the
|
|
364
|
+
// installed @types declarations only expose removeDefault on
|
|
365
|
+
// ZodDefault. Use it — it's the same runtime function.
|
|
366
|
+
current = current.unwrap();
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (current instanceof z.ZodReadonly) {
|
|
370
|
+
current = current.unwrap();
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
return current;
|
|
376
|
+
}
|
|
377
|
+
/** Type guard: is this a ZodObject with a .shape property? */
|
|
378
|
+
function isZodObject(schema) {
|
|
379
|
+
return schema instanceof z.ZodObject;
|
|
380
|
+
}
|
|
381
|
+
/** Create a Model subclass for a schema-defined model */
|
|
382
|
+
function createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed, lazyObservable = false) {
|
|
383
|
+
const ModelClass = class extends Model {
|
|
384
|
+
_modelName = modelName;
|
|
385
|
+
constructor(data) {
|
|
386
|
+
super(data);
|
|
387
|
+
// Gate `propertyChanged`-via-`observe` tracking during initial
|
|
388
|
+
// hydration. M1 installs a MobX `observe()` listener per schema
|
|
389
|
+
// property that forwards writes to `propertyChanged()` so direct
|
|
390
|
+
// assignments like `layer.position = newPos` still round-trip
|
|
391
|
+
// through the transaction queue. During construction we're writing
|
|
392
|
+
// wire data, NOT user edits — flagging this as "constructing" lets
|
|
393
|
+
// the listener early-return on those writes so `modifiedProperties`
|
|
394
|
+
// doesn't get polluted with every field of every hydrated model.
|
|
395
|
+
//
|
|
396
|
+
// The listener is installed by `makeObservable()` below (inside
|
|
397
|
+
// M1), so writes that happen BEFORE that line won't fire it; this
|
|
398
|
+
// flag is defensive in case a subclass or call path reorders the
|
|
399
|
+
// steps later.
|
|
400
|
+
this._isConstructing = true;
|
|
401
|
+
// MobX 6 requires fields to exist as own properties BEFORE makeObservable().
|
|
402
|
+
// Model base only sets id/createdAt/updatedAt. Schema fields (title, userId, etc.)
|
|
403
|
+
// must be initialized here so M1's annotations can find them.
|
|
404
|
+
for (const field of fieldNames) {
|
|
405
|
+
if (!(field in this)) {
|
|
406
|
+
this[field] = data?.[field] ?? undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Per-field MobX observability opt-in via `lazyObservable: true` on
|
|
410
|
+
// the model definition. Defaults to plain objects — reactivity comes
|
|
411
|
+
// from the QueryView "entry replaced" pattern, which is cheap for
|
|
412
|
+
// read-only list UIs but invisible to in-place field mutations.
|
|
413
|
+
//
|
|
414
|
+
// Multiplayer editors need live field-level reactivity so remote
|
|
415
|
+
// deltas AND local drag/resize/rename mutations surface through
|
|
416
|
+
// `observer()` components without the whole pool entry being
|
|
417
|
+
// replaced. Without observability, `layer.position.x = 500` emits
|
|
418
|
+
// nothing and the UI lags until some unrelated state change triggers
|
|
419
|
+
// a pass (toolbar close, deselect).
|
|
420
|
+
//
|
|
421
|
+
// Delegates to `Model.makeObservable()` (the inherited method) so
|
|
422
|
+
// MobX annotations are derived from the same registry that M1 reads.
|
|
423
|
+
// That means computed getters, reference collections, custom
|
|
424
|
+
// getters/setters, and property-change tracking all integrate
|
|
425
|
+
// correctly — reimplementing `makeObservable` inline here would miss
|
|
426
|
+
// those seams.
|
|
427
|
+
if (lazyObservable) {
|
|
428
|
+
this.makeObservable();
|
|
429
|
+
}
|
|
430
|
+
this._isConstructing = false;
|
|
431
|
+
}
|
|
432
|
+
getModelName() {
|
|
433
|
+
return this._modelName;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
// Generate ${field}Json getters for JSON fields with sub-properties.
|
|
437
|
+
//
|
|
438
|
+
// The getter reads the raw JSON string from the instance (set via
|
|
439
|
+
// updateFromData), parses it, applies Zod defaults, and caches by
|
|
440
|
+
// raw value. This replaces the hand-coded metadataObject + sub-property
|
|
441
|
+
// getter pattern that 11+ Ablo models currently repeat.
|
|
442
|
+
//
|
|
443
|
+
// Example: field named 'metadata' with sub-schema { icon: z.string().default('presentation') }
|
|
444
|
+
// → model.metadataJson returns { icon: 'presentation', ... } (typed, cached)
|
|
445
|
+
for (const { fieldName, subSchema } of jsonSubFields) {
|
|
446
|
+
const getterName = `${fieldName}Json`;
|
|
447
|
+
const cacheKey = `__${fieldName}JsonCache`;
|
|
448
|
+
Object.defineProperty(ModelClass.prototype, getterName, {
|
|
449
|
+
get() {
|
|
450
|
+
const raw = this[fieldName];
|
|
451
|
+
// Cache check: same raw value → same parsed result
|
|
452
|
+
const cache = this[cacheKey];
|
|
453
|
+
if (cache && cache.raw === raw)
|
|
454
|
+
return cache.parsed;
|
|
455
|
+
// Parse: handle string (from DB/wire), object (already parsed), null/undefined
|
|
456
|
+
let input;
|
|
457
|
+
try {
|
|
458
|
+
if (typeof raw === 'string') {
|
|
459
|
+
input = JSON.parse(raw);
|
|
460
|
+
}
|
|
461
|
+
else if (raw && typeof raw === 'object') {
|
|
462
|
+
input = raw;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
input = {};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
input = {};
|
|
470
|
+
}
|
|
471
|
+
// Apply Zod parse for type coercion + defaults. safeParse so
|
|
472
|
+
// malformed metadata doesn't crash — falls back to all defaults.
|
|
473
|
+
const result = subSchema.safeParse(input);
|
|
474
|
+
const parsed = result.success ? result.data : subSchema.safeParse({}).data ?? {};
|
|
475
|
+
this[cacheKey] = { raw, parsed };
|
|
476
|
+
return parsed;
|
|
477
|
+
},
|
|
478
|
+
enumerable: true,
|
|
479
|
+
configurable: true,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
// Install schema-declared computed getters on the prototype.
|
|
483
|
+
// Each getter receives `this` (the model instance) and returns the computed value.
|
|
484
|
+
if (computed) {
|
|
485
|
+
for (const [name, fn] of Object.entries(computed)) {
|
|
486
|
+
Object.defineProperty(ModelClass.prototype, name, {
|
|
487
|
+
get() {
|
|
488
|
+
return fn(this);
|
|
489
|
+
},
|
|
490
|
+
enumerable: true,
|
|
491
|
+
configurable: true,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return ModelClass;
|
|
496
|
+
}
|
|
497
|
+
// ── Default console logger ────────────────────────────────────────────────
|
|
498
|
+
const consoleLogger = {
|
|
499
|
+
debug: (...args) => { if (typeof console !== 'undefined')
|
|
500
|
+
console.debug('[sync]', ...args); },
|
|
501
|
+
info: (...args) => { if (typeof console !== 'undefined')
|
|
502
|
+
console.info('[sync]', ...args); },
|
|
503
|
+
warn: (...args) => { if (typeof console !== 'undefined')
|
|
504
|
+
console.warn('[sync]', ...args); },
|
|
505
|
+
error: (...args) => { if (typeof console !== 'undefined')
|
|
506
|
+
console.error('[sync]', ...args); },
|
|
507
|
+
};
|
|
508
|
+
// `readProcessEnv` lives in `./auth` alongside the other resolvers
|
|
509
|
+
// that read it. Re-exported there for use elsewhere in the file.
|
|
510
|
+
// ── Default mutation executor (wire: `commit` frame over WebSocket) ──────
|
|
511
|
+
/**
|
|
512
|
+
* Derive a stable `Idempotency-Key` from the batch's operation set.
|
|
513
|
+
*
|
|
514
|
+
* Retries of the same batch compute the same key — a reconnecting
|
|
515
|
+
* client that rebuilds the identical mutations from its offline queue
|
|
516
|
+
* sends the identical key, so the server's `mutation_log` replay path
|
|
517
|
+
* returns the cached response instead of re-executing the mutators.
|
|
518
|
+
*
|
|
519
|
+
* Content-addressed: sort operations by (model, id, type) then sha256
|
|
520
|
+
* the serialized form. Separator-safe — adjacent fields are delimited
|
|
521
|
+
* by a character (`\x1e`, the ASCII record separator) that cannot
|
|
522
|
+
* appear in a JSON string literal. Output length is 70 chars — safely
|
|
523
|
+
* under Stripe's documented 255-char cap.
|
|
524
|
+
*
|
|
525
|
+
* Uses the Web Crypto API (cross-runtime: Node 20+ and browsers), same
|
|
526
|
+
* primitive as the offline queue's AES-GCM encryption.
|
|
527
|
+
*
|
|
528
|
+
* @internal — exported as unexported file-local; callers go through
|
|
529
|
+
* the executor's own `Idempotency-Key` plumbing.
|
|
530
|
+
*/
|
|
531
|
+
async function deriveOperationsIdempotencyKey(operations) {
|
|
532
|
+
const normalized = [...operations]
|
|
533
|
+
.map((op) => ({
|
|
534
|
+
type: op.type,
|
|
535
|
+
model: op.model,
|
|
536
|
+
id: op.id,
|
|
537
|
+
input: op.input ?? null,
|
|
538
|
+
}))
|
|
539
|
+
.sort((a, b) => {
|
|
540
|
+
if (a.model !== b.model)
|
|
541
|
+
return a.model < b.model ? -1 : 1;
|
|
542
|
+
if (a.id !== b.id)
|
|
543
|
+
return a.id < b.id ? -1 : 1;
|
|
544
|
+
return a.type < b.type ? -1 : a.type > b.type ? 1 : 0;
|
|
545
|
+
});
|
|
546
|
+
const encoded = new TextEncoder().encode(JSON.stringify(normalized));
|
|
547
|
+
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
|
548
|
+
const bytes = new Uint8Array(digest);
|
|
549
|
+
let hex = '';
|
|
550
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
551
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
552
|
+
}
|
|
553
|
+
return `batch-${hex}`;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Default mutation executor: sends `{ type: 'commit', payload: ... }` over
|
|
557
|
+
* the sync engine's own WebSocket.
|
|
558
|
+
*
|
|
559
|
+
* Transport ownership follows the Zero / Liveblocks pattern — the engine
|
|
560
|
+
* owns its socket end-to-end and the executor is internal. Apps pass URLs
|
|
561
|
+
* and auth; they do NOT inject transport callbacks. That's why this
|
|
562
|
+
* factory takes a `getWs` closure instead of a full SyncWebSocket: the WS
|
|
563
|
+
* doesn't exist when the executor is constructed (it's created later in
|
|
564
|
+
* `Ablo` during `BaseSyncedStore` init), so we resolve it
|
|
565
|
+
* lazily at commit time. Same trick Zero uses internally — see
|
|
566
|
+
* `packages/zero-client/src/client/zero.ts` where `Pusher`/`Puller` are
|
|
567
|
+
* constructed before the socket then wired up at connect time.
|
|
568
|
+
*
|
|
569
|
+
* `options.idempotencyKey` becomes the wire-level `clientTxId` when set,
|
|
570
|
+
* matching Stripe-style retry semantics. Otherwise the SDK generates one.
|
|
571
|
+
*/
|
|
572
|
+
function createDefaultMutationExecutor(getWs) {
|
|
573
|
+
async function commit(operations, options) {
|
|
574
|
+
const ws = getWs();
|
|
575
|
+
if (!ws?.sendCommit) {
|
|
576
|
+
throw new AbloConnectionError('SyncWebSocket not ready for commit. The engine must finish bootstrap ' +
|
|
577
|
+
'before mutations can be sent.', { code: 'ws_not_ready' });
|
|
578
|
+
}
|
|
579
|
+
const clientTxId = options?.idempotencyKey ??
|
|
580
|
+
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
581
|
+
? crypto.randomUUID()
|
|
582
|
+
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
|
|
583
|
+
try {
|
|
584
|
+
return await ws.sendCommit(operations, clientTxId, options?.timeout, options?.causedByTaskId);
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
// Wrap transport-level failures as connection errors so the
|
|
588
|
+
// TransactionQueue's retry classifier treats them as transient
|
|
589
|
+
// (matches the old HTTP path's network-error handling).
|
|
590
|
+
if (err instanceof AbloError)
|
|
591
|
+
throw err;
|
|
592
|
+
if (err instanceof Error) {
|
|
593
|
+
if (/not connected|timed out|connection|ECONN/i.test(err.message)) {
|
|
594
|
+
const wrapped = new AbloConnectionError(err.message, { cause: err });
|
|
595
|
+
// Preserve any `diagnostics` snapshot the underlying SyncWebSocket
|
|
596
|
+
// attached to the rejection. Without this, the wrapped error
|
|
597
|
+
// bottoms out at "AbloConnectionError: not connected" with no
|
|
598
|
+
// attribution to which close code / heartbeat trip / session
|
|
599
|
+
// error caused it. See SyncWebSocket.notConnectedError().
|
|
600
|
+
if (err &&
|
|
601
|
+
typeof err === 'object' &&
|
|
602
|
+
'diagnostics' in err &&
|
|
603
|
+
err.diagnostics) {
|
|
604
|
+
wrapped.diagnostics = err.diagnostics;
|
|
605
|
+
}
|
|
606
|
+
throw wrapped;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
throw err;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
commit,
|
|
614
|
+
executeCreate: (model, id, input, _txId, options) => commit([{ type: 'CREATE', model: model.toLowerCase(), id, input }], options).then(() => { }),
|
|
615
|
+
executeUpdate: (model, id, data, _txId, options) => commit([{ type: 'UPDATE', model: model.toLowerCase(), id, input: data }], options),
|
|
616
|
+
executeDelete: (model, id, _txId, options) => commit([{ type: 'DELETE', model: model.toLowerCase(), id }], options).then(() => { }),
|
|
617
|
+
executeArchive: (model, id, _txId, options) => commit([{ type: 'ARCHIVE', model: model.toLowerCase(), id }], options).then(() => { }),
|
|
618
|
+
executeUnarchive: (model, id, _txId, options) => commit([{ type: 'UNARCHIVE', model: model.toLowerCase(), id }], options).then(() => { }),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
// ── Default mutation dispatcher (for offline flush) ───────────────────────
|
|
622
|
+
function createDefaultMutationDispatcher(executor) {
|
|
623
|
+
return {
|
|
624
|
+
async dispatch(opName, variables) {
|
|
625
|
+
const prefixes = ['Create', 'Update', 'Delete', 'Archive', 'Unarchive'];
|
|
626
|
+
for (const prefix of prefixes) {
|
|
627
|
+
if (opName.startsWith(prefix)) {
|
|
628
|
+
const model = opName.slice(prefix.length);
|
|
629
|
+
const v = variables;
|
|
630
|
+
const input = (prefix === 'Create' || prefix === 'Update')
|
|
631
|
+
? v.input
|
|
632
|
+
: undefined;
|
|
633
|
+
await executor.commit([{
|
|
634
|
+
type: prefix.toUpperCase(),
|
|
635
|
+
model: model.toLowerCase(),
|
|
636
|
+
id: v.id ?? '',
|
|
637
|
+
input,
|
|
638
|
+
}]);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
export function Ablo(options) {
|
|
646
|
+
if (options.schema == null) {
|
|
647
|
+
return createProtocolClient(options);
|
|
648
|
+
}
|
|
649
|
+
const internalOptions = options;
|
|
650
|
+
const env = readProcessEnv();
|
|
651
|
+
const authInput = { options, env };
|
|
652
|
+
const configuredApiKey = resolveApiKey(authInput);
|
|
653
|
+
const configuredAuthToken = resolveAuthToken(authInput);
|
|
654
|
+
assertBrowserSafety({
|
|
655
|
+
apiKey: configuredApiKey,
|
|
656
|
+
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
657
|
+
});
|
|
658
|
+
const { logger = consoleLogger } = internalOptions;
|
|
659
|
+
const schema = options.schema;
|
|
660
|
+
const url = resolveBaseURL(authInput);
|
|
661
|
+
// 1. Derive config from schema
|
|
662
|
+
// 1. Derive config from schema, then layer caller-supplied overrides on top.
|
|
663
|
+
// `configOverrides` is a shallow merge: caller takes precedence per key.
|
|
664
|
+
const config = {
|
|
665
|
+
...deriveConfigFromSchema(schema),
|
|
666
|
+
...internalOptions.configOverrides,
|
|
667
|
+
};
|
|
668
|
+
// 2. Create the mutation executor + dispatcher.
|
|
669
|
+
//
|
|
670
|
+
// The default executor sends `{ type: 'commit', ... }` over the
|
|
671
|
+
// engine's WebSocket. The WS doesn't exist yet at this point (it's
|
|
672
|
+
// created later when `BaseSyncedStore` initializes), so the default
|
|
673
|
+
// takes a lazy getter that resolves the live WS at commit time.
|
|
674
|
+
// `storeForTransport` is captured by the closure and assigned below
|
|
675
|
+
// once the store is built — JS closures close over bindings, not
|
|
676
|
+
// values, so by the time the first commit fires the store is live.
|
|
677
|
+
//
|
|
678
|
+
// Caller-supplied executors are still honored for advanced cases
|
|
679
|
+
// (test mocks, alternative transports) but the public `<AbloProvider>`
|
|
680
|
+
// surface will mark this option `@internal` — apps should almost
|
|
681
|
+
// never need to override transport. See Zero's `ClientOptions`
|
|
682
|
+
// (packages/zero-client/src/client/options.ts) and Liveblocks'
|
|
683
|
+
// `ClientOptions` (packages/liveblocks-core/src/client.ts) for the
|
|
684
|
+
// reference shape: URLs + auth + declarative mutators, never a
|
|
685
|
+
// pluggable commit transport.
|
|
686
|
+
// Captured-by-reference binding — assigned below after BaseSyncedStore
|
|
687
|
+
// is constructed. The default executor's `getWs` closure reads it
|
|
688
|
+
// lazily at commit time.
|
|
689
|
+
// The store is created later with full generics (`Schema<S>`), so type
|
|
690
|
+
// it here as the same generic — narrower default doesn't accept it.
|
|
691
|
+
const storeHolder = { store: null };
|
|
692
|
+
const executor = internalOptions.mutationExecutor ??
|
|
693
|
+
createDefaultMutationExecutor(() => {
|
|
694
|
+
const ws = storeHolder.store?.getSyncWebSocket() ?? null;
|
|
695
|
+
return ws;
|
|
696
|
+
});
|
|
697
|
+
const dispatcher = internalOptions.mutationDispatcher ?? createDefaultMutationDispatcher(executor);
|
|
698
|
+
// 3. Initialize SDK context (one call — hides all DI wiring).
|
|
699
|
+
// Each provider can be overridden individually; the noop defaults
|
|
700
|
+
// are preserved for the zero-config consumer path.
|
|
701
|
+
initSyncEngine({
|
|
702
|
+
logger,
|
|
703
|
+
observability: internalOptions.observability ?? noopObservability,
|
|
704
|
+
analytics: internalOptions.analytics ?? noopAnalytics,
|
|
705
|
+
sessionErrorDetector: internalOptions.sessionErrorDetector ?? defaultSessionErrorDetector,
|
|
706
|
+
onlineStatus: internalOptions.onlineStatus ??
|
|
707
|
+
(shouldUseInMemoryPersistence(options)
|
|
708
|
+
? alwaysOnline()
|
|
709
|
+
: browserOnlineStatus),
|
|
710
|
+
config,
|
|
711
|
+
mutationExecutor: executor,
|
|
712
|
+
mutationDispatcher: dispatcher,
|
|
713
|
+
});
|
|
714
|
+
// 4. Create internal components (user never sees these). See
|
|
715
|
+
// `./createInternalComponents.ts` for the construction order
|
|
716
|
+
// and what each component does. Model registration happens
|
|
717
|
+
// here because `registerModelsFromSchema` lives in this file —
|
|
718
|
+
// the schema-to-Model-class translation depends on private
|
|
719
|
+
// helpers (`createDynamicModelClass`, `unwrapZodType`, etc.)
|
|
720
|
+
// that aren't worth pulling into the components module.
|
|
721
|
+
const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({ schema, url, options: internalOptions });
|
|
722
|
+
registerModelsFromSchema(schema, modelRegistry);
|
|
723
|
+
// 5. BaseSyncedStore handles the initialization orchestration
|
|
724
|
+
// (open DB → hydrate IDB → connect WS → fetch bootstrap → hydrate again →
|
|
725
|
+
// ready) and exposes the observable `syncStatus` we expose on the engine.
|
|
726
|
+
//
|
|
727
|
+
// Phase 2: pass the schema into the store so `deriveSyncPlanFromSchema`
|
|
728
|
+
// can auto-populate version vector keys, FK indexes, and enrichment
|
|
729
|
+
// rules from the declarative `belongsTo({ index, enrich })` annotations.
|
|
730
|
+
// Consumers using class-based subclasses with `new SyncedStore(...)`
|
|
731
|
+
// directly can pass explicit config arrays instead.
|
|
732
|
+
const store = new BaseSyncedStore({
|
|
733
|
+
syncClient,
|
|
734
|
+
database,
|
|
735
|
+
objectPool,
|
|
736
|
+
modelRegistry,
|
|
737
|
+
schema,
|
|
738
|
+
url,
|
|
739
|
+
});
|
|
740
|
+
// Wire the store back into the default executor's lazy getter (see
|
|
741
|
+
// `storeHolder` above). The executor was constructed before the store
|
|
742
|
+
// existed; this late binding closes the loop so commits dispatch over
|
|
743
|
+
// the engine's WebSocket once it opens.
|
|
744
|
+
storeHolder.store = store;
|
|
745
|
+
// Bind THIS executor to THIS Ablo's TransactionQueue. Without this,
|
|
746
|
+
// the queue resolves `mutationExecutor` from the module-level
|
|
747
|
+
// `getContext()`, which `initSyncEngine()` overwrites on every Ablo
|
|
748
|
+
// construction. In multi-Ablo flows (e.g. agent-worker's worker +
|
|
749
|
+
// per-job peer) the second `initSyncEngine()` call would silently
|
|
750
|
+
// redirect the first Ablo's queue through the second Ablo's executor
|
|
751
|
+
// closure — and when the second Ablo disposes, its `storeHolder.store`
|
|
752
|
+
// becomes null, so the first Ablo's commits start throwing
|
|
753
|
+
// `ws_not_ready` forever (terminal AgentJob writes hang on retry).
|
|
754
|
+
syncClient.getTransactionQueue().setMutationExecutor(executor);
|
|
755
|
+
// Active turn id, set by `beginTurn(...)`, cleared on close. While
|
|
756
|
+
// set, every batch commit attaches `causedByTaskId` so server
|
|
757
|
+
// delta rows get stamped with it. Single-turn-at-a-time per Ablo
|
|
758
|
+
// — opening a second turn overwrites the active id without closing
|
|
759
|
+
// the prior. Callers who need parallel turns construct multiple
|
|
760
|
+
// Ablo instances, matching the SyncAgent semantics.
|
|
761
|
+
let activeTurnId = null;
|
|
762
|
+
// Presence + intent streams — built eagerly so `engine.presence`
|
|
763
|
+
// and `engine.intents` return the same reference for the engine's
|
|
764
|
+
// lifetime. The transport doesn't exist yet (BaseSyncedStore.initialize
|
|
765
|
+
// creates it during ready()), so both streams are constructed in
|
|
766
|
+
// deferred-attach mode and wired after initialize() resolves below.
|
|
767
|
+
// Calls before attach mutate local state but skip the wire send.
|
|
768
|
+
// Identity routing: agents identify by agentId, users by user.id.
|
|
769
|
+
// The server stamps `isAgent` on outbound presence frames from the
|
|
770
|
+
// connection's authenticated identity prefix, but the local `self`
|
|
771
|
+
// entry uses the kind we know at construction.
|
|
772
|
+
const participantId = (internalOptions.kind === 'agent' ? internalOptions.agentId : internalOptions.user?.id) ?? '';
|
|
773
|
+
const presenceStream = createPresenceStream({
|
|
774
|
+
participantId,
|
|
775
|
+
syncGroups: internalOptions.syncGroups ?? [],
|
|
776
|
+
isAgent: internalOptions.kind === 'agent',
|
|
777
|
+
});
|
|
778
|
+
const intentStream = createIntentStream({ participantId });
|
|
779
|
+
const participantManager = createParticipantManager({
|
|
780
|
+
ready,
|
|
781
|
+
getTransport: () => store.getSyncWebSocket() ?? null,
|
|
782
|
+
presence: presenceStream,
|
|
783
|
+
intents: intentStream,
|
|
784
|
+
schema,
|
|
785
|
+
});
|
|
786
|
+
// 6. Validate options up front — fail loudly on obviously wrong inputs so
|
|
787
|
+
// strangers don't get silent empty results. Validation errors are written
|
|
788
|
+
// into `store.syncStatus` (the single source of truth).
|
|
789
|
+
const kind = internalOptions.kind ?? 'user';
|
|
790
|
+
const _validationError = validateAbloOptions({
|
|
791
|
+
options: internalOptions,
|
|
792
|
+
url,
|
|
793
|
+
configuredApiKey,
|
|
794
|
+
configuredAuthToken,
|
|
795
|
+
});
|
|
796
|
+
if (_validationError) {
|
|
797
|
+
logger.error(_validationError.message);
|
|
798
|
+
store.syncStatus.state = 'error';
|
|
799
|
+
store.syncStatus.error = _validationError;
|
|
800
|
+
}
|
|
801
|
+
// 7. The ready() promise drives the BaseSyncedStore.initialize() generator
|
|
802
|
+
// to completion. First call kicks off the initialization; subsequent
|
|
803
|
+
// calls return the same promise (idempotent).
|
|
804
|
+
//
|
|
805
|
+
// Status is tracked in store.syncStatus (MobX observable) — the single
|
|
806
|
+
// source of truth. No duplicate closure variables.
|
|
807
|
+
let _readyPromise = null;
|
|
808
|
+
let _refreshScheduler = null;
|
|
809
|
+
let currentCapabilityToken = internalOptions.capabilityToken ?? configuredAuthToken ?? undefined;
|
|
810
|
+
// Wire the cap token into HydrationCoordinator's HTTP path. Without
|
|
811
|
+
// this, `ablo.<model>.load(...)` / `ablo.<model>.retrieve(...)` go
|
|
812
|
+
// through `postQuery` with `credentials: 'include'` only — fine in
|
|
813
|
+
// browsers (session cookies), but Node consumers (agent-worker)
|
|
814
|
+
// have no cookies and the request lands with no credential at all.
|
|
815
|
+
// The WS path was already wired (token rides the upgrade URL); this
|
|
816
|
+
// closes the gap on HTTP. Closure-over-binding so cap rotation
|
|
817
|
+
// (`applyRotatedToken` in the refresh scheduler below) propagates.
|
|
818
|
+
hydration.setCapabilityTokenProvider(() => currentCapabilityToken ?? null);
|
|
819
|
+
async function ready() {
|
|
820
|
+
if (_readyPromise)
|
|
821
|
+
return _readyPromise;
|
|
822
|
+
if (_validationError) {
|
|
823
|
+
_readyPromise = Promise.reject(_validationError);
|
|
824
|
+
return _readyPromise;
|
|
825
|
+
}
|
|
826
|
+
_readyPromise = (async () => {
|
|
827
|
+
try {
|
|
828
|
+
// Resolve participant identity + scope. Three branches —
|
|
829
|
+
// hosted-cloud apiKey exchange, self-derived from capability
|
|
830
|
+
// token, or legacy explicit options. See `./identity.ts`.
|
|
831
|
+
const resolved = await resolveParticipantIdentity({
|
|
832
|
+
options: internalOptions,
|
|
833
|
+
internalOptions,
|
|
834
|
+
url,
|
|
835
|
+
kind,
|
|
836
|
+
configuredApiKey,
|
|
837
|
+
configuredAuthToken,
|
|
838
|
+
bootstrapHelper,
|
|
839
|
+
logger,
|
|
840
|
+
applyRotatedToken: (token) => {
|
|
841
|
+
currentCapabilityToken = token;
|
|
842
|
+
bootstrapHelper.setAuthToken(token);
|
|
843
|
+
const ws = store.getSyncWebSocket();
|
|
844
|
+
ws?.setCapabilityToken(token);
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
const { userId, accountScope, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
|
|
848
|
+
// Fail-loud guard: detect the degenerate "no real sync groups
|
|
849
|
+
// resolved" state before opening the WS. Same class of bug as
|
|
850
|
+
// the schema-drift `[commit] dropped stale field` warning —
|
|
851
|
+
// sensible-looking default that's functionally broken: the
|
|
852
|
+
// SDK ends up subscribing only to the server-side
|
|
853
|
+
// `['default']` fallback (bootstrap.ts:45, Hub.ts:480), no
|
|
854
|
+
// delta has that tag, live fan-out silently never delivers.
|
|
855
|
+
// For human users (kind:'user') this is almost certainly a
|
|
856
|
+
// misconfiguration upstream — either the caller didn't pass
|
|
857
|
+
// `syncGroups`, or auth resolution didn't derive them, or
|
|
858
|
+
// both. Warn loudly so the next debugging session starts here
|
|
859
|
+
// instead of with "live updates don't work, hard reload fixes
|
|
860
|
+
// it."
|
|
861
|
+
const resolvedSyncGroups = syncGroups ?? [];
|
|
862
|
+
if (participantKind === 'user' &&
|
|
863
|
+
(resolvedSyncGroups.length === 0 ||
|
|
864
|
+
(resolvedSyncGroups.length === 1 && resolvedSyncGroups[0] === 'default'))) {
|
|
865
|
+
logger.warn('Ablo({kind:"user"}) initialized with degenerate syncGroups — ' +
|
|
866
|
+
'this client will receive zero deltas through the live WS path. ' +
|
|
867
|
+
'Either pass `syncGroups` explicitly (typically ' +
|
|
868
|
+
'`["org:${orgId}", "user:${userId}"]`) or verify your auth ' +
|
|
869
|
+
'provider populates them. See packages/sync-engine/src/client/identity.ts.', { participantKind, resolvedSyncGroups });
|
|
870
|
+
}
|
|
871
|
+
currentCapabilityToken = capabilityToken;
|
|
872
|
+
bootstrapHelper.setAuthToken(capabilityToken);
|
|
873
|
+
if (resolved.refreshScheduler) {
|
|
874
|
+
_refreshScheduler = resolved.refreshScheduler;
|
|
875
|
+
}
|
|
876
|
+
// Drive the generator to completion. Each yielded promise is awaited
|
|
877
|
+
// then fed back — this is standard generator consumption.
|
|
878
|
+
//
|
|
879
|
+
// The store.initialize() generator updates store.syncStatus as it
|
|
880
|
+
// progresses (syncing → idle on success, error on failure), so the
|
|
881
|
+
// consumer's `sync.syncStatus` observable reflects real-time state.
|
|
882
|
+
// Resolve bootstrap mode: explicit option wins; otherwise
|
|
883
|
+
// agents default to 'none' (transactional participant — see
|
|
884
|
+
// option doc) and everyone else defaults to 'full'.
|
|
885
|
+
const resolvedBootstrapMode = internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
|
|
886
|
+
const gen = store.initialize({
|
|
887
|
+
userId,
|
|
888
|
+
organizationId: accountScope,
|
|
889
|
+
teamIds,
|
|
890
|
+
kind: participantKind,
|
|
891
|
+
capabilityToken,
|
|
892
|
+
syncGroups,
|
|
893
|
+
bootstrapMode: resolvedBootstrapMode,
|
|
894
|
+
});
|
|
895
|
+
let current = gen.next();
|
|
896
|
+
while (!current.done) {
|
|
897
|
+
const yielded = current.value;
|
|
898
|
+
const resolved = yielded instanceof Promise ? await yielded : yielded;
|
|
899
|
+
current = gen.next(resolved);
|
|
900
|
+
}
|
|
901
|
+
const result = current.value;
|
|
902
|
+
if (!result.success) {
|
|
903
|
+
throw result.error ?? new Error('Sync engine initialization failed');
|
|
904
|
+
}
|
|
905
|
+
// Wire presence + intents to the now-open transport.
|
|
906
|
+
// `getSyncWebSocket()` returns non-null after a successful
|
|
907
|
+
// initialize() — the WS is created during the generator's
|
|
908
|
+
// connect step.
|
|
909
|
+
const ws = store.getSyncWebSocket();
|
|
910
|
+
if (ws) {
|
|
911
|
+
presenceStream.attach(ws);
|
|
912
|
+
intentStream.attach(ws);
|
|
913
|
+
}
|
|
914
|
+
logger.info('Sync engine ready', { models: Object.keys(schema.models).length });
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
918
|
+
// Make sure syncStatus reflects the failure for observer() components
|
|
919
|
+
store.syncStatus.state = 'error';
|
|
920
|
+
store.syncStatus.error = error;
|
|
921
|
+
logger.error('Sync engine failed to initialize', { error: error.message });
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
})();
|
|
925
|
+
return _readyPromise;
|
|
926
|
+
}
|
|
927
|
+
// 9. Optional auto-start for convenience. Opt-in because silent background
|
|
928
|
+
// init has historically been the #1 source of "why isn't my data loading"
|
|
929
|
+
// bug reports. Explicit `await sync.ready()` is the default — errors
|
|
930
|
+
// surface immediately instead of being swallowed.
|
|
931
|
+
if (!_validationError && internalOptions.autoStart) {
|
|
932
|
+
void ready().catch(() => {
|
|
933
|
+
// Error is captured in store.syncStatus; consumers should check
|
|
934
|
+
// `sync.syncStatus.state === 'error'` to detect failures.
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
// 9b. waitForFlush — drains pending mutations using the store's
|
|
938
|
+
// pendingChanges counter (already maintained by BaseSyncedStore based
|
|
939
|
+
// on TransactionQueue events). Polls every 50ms; uses the existing
|
|
940
|
+
// observable rather than introducing a new event channel.
|
|
941
|
+
async function waitForFlush(timeoutMs) {
|
|
942
|
+
const start = Date.now();
|
|
943
|
+
while (store.syncStatus.pendingChanges > 0) {
|
|
944
|
+
if (timeoutMs !== undefined && Date.now() - start > timeoutMs) {
|
|
945
|
+
throw new AbloConnectionError(`Flush timeout: ${store.syncStatus.pendingChanges} pending mutations after ${timeoutMs}ms`, { code: 'flush_timeout' });
|
|
946
|
+
}
|
|
947
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
951
|
+
function authHeaders() {
|
|
952
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
953
|
+
if (currentCapabilityToken) {
|
|
954
|
+
headers.Authorization = `Bearer ${currentCapabilityToken}`;
|
|
955
|
+
}
|
|
956
|
+
else if (configuredAuthToken) {
|
|
957
|
+
headers.Authorization = `Bearer ${configuredAuthToken}`;
|
|
958
|
+
}
|
|
959
|
+
return headers;
|
|
960
|
+
}
|
|
961
|
+
function createClientTxId(idempotencyKey) {
|
|
962
|
+
if (idempotencyKey && idempotencyKey.length > 0)
|
|
963
|
+
return idempotencyKey;
|
|
964
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
965
|
+
? crypto.randomUUID()
|
|
966
|
+
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
967
|
+
}
|
|
968
|
+
function createResourceId() {
|
|
969
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
970
|
+
? crypto.randomUUID()
|
|
971
|
+
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
972
|
+
}
|
|
973
|
+
function normalizeIntentId(intent) {
|
|
974
|
+
if (typeof intent === 'string')
|
|
975
|
+
return intent;
|
|
976
|
+
return intent?.id;
|
|
977
|
+
}
|
|
978
|
+
function normalizeCommitOperation(op, defaults) {
|
|
979
|
+
const resource = op.resource ?? op.target?.resource;
|
|
980
|
+
if (!resource) {
|
|
981
|
+
throw new AbloValidationError('Commit operation requires `resource` or `target.resource`.', { code: 'commit_operation_resource_required' });
|
|
982
|
+
}
|
|
983
|
+
const type = op.action.toUpperCase();
|
|
984
|
+
const id = op.id ?? op.target?.id ?? '';
|
|
985
|
+
return {
|
|
986
|
+
type,
|
|
987
|
+
model: resource.toLowerCase(),
|
|
988
|
+
id,
|
|
989
|
+
input: op.data ?? undefined,
|
|
990
|
+
transactionId: op.transactionId ?? undefined,
|
|
991
|
+
readAt: op.readAt ?? defaults.readAt ?? undefined,
|
|
992
|
+
onStale: op.onStale ?? defaults.onStale ?? undefined,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function normalizeCommitOperations(commitOptions) {
|
|
996
|
+
if (commitOptions.operation && commitOptions.operations) {
|
|
997
|
+
throw new AbloValidationError('Pass either `operation` or `operations`, not both.', { code: 'commit_operations_ambiguous' });
|
|
998
|
+
}
|
|
999
|
+
const inputOperations = commitOptions.operation
|
|
1000
|
+
? [commitOptions.operation]
|
|
1001
|
+
: commitOptions.operations ?? [];
|
|
1002
|
+
if (inputOperations.length === 0) {
|
|
1003
|
+
throw new AbloValidationError('Commit requires at least one operation.', { code: 'commit_operation_required' });
|
|
1004
|
+
}
|
|
1005
|
+
return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
|
|
1006
|
+
}
|
|
1007
|
+
function resourceIntentFromActive(intent) {
|
|
1008
|
+
return {
|
|
1009
|
+
id: intent.id,
|
|
1010
|
+
actor: intent.heldBy,
|
|
1011
|
+
participantKind: intent.participantKind,
|
|
1012
|
+
action: intent.reason,
|
|
1013
|
+
field: intent.target.field,
|
|
1014
|
+
expiresAt: intent.expiresAt,
|
|
1015
|
+
target: {
|
|
1016
|
+
resource: intent.target.type,
|
|
1017
|
+
id: intent.target.id,
|
|
1018
|
+
path: intent.target.path,
|
|
1019
|
+
range: intent.target.range,
|
|
1020
|
+
field: intent.target.field,
|
|
1021
|
+
meta: intent.target.meta,
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
function targetMatchesResource(target, intent) {
|
|
1026
|
+
if (target.resource &&
|
|
1027
|
+
intent.target.type.toLowerCase() !== target.resource.toLowerCase()) {
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
if (target.id && intent.target.id !== target.id)
|
|
1031
|
+
return false;
|
|
1032
|
+
if (target.field && intent.target.field !== target.field)
|
|
1033
|
+
return false;
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
function listResourceIntents(target) {
|
|
1037
|
+
return intentStream.others
|
|
1038
|
+
.filter((intent) => (target ? targetMatchesResource(target, intent) : true))
|
|
1039
|
+
.map(resourceIntentFromActive);
|
|
1040
|
+
}
|
|
1041
|
+
function busyError(target, intents, code) {
|
|
1042
|
+
const label = [target.resource, target.id, target.field].filter(Boolean).join('/');
|
|
1043
|
+
const holder = intents[0];
|
|
1044
|
+
const suffix = holder
|
|
1045
|
+
? ` held by ${holder.actor} (${holder.action})`
|
|
1046
|
+
: ' held by another participant';
|
|
1047
|
+
return new AbloBusyError(`Resource is busy: ${label || 'target'}${suffix}.`, { code, intents });
|
|
1048
|
+
}
|
|
1049
|
+
function waitForResourceIdle(target, options) {
|
|
1050
|
+
if (listResourceIntents(target).length === 0)
|
|
1051
|
+
return Promise.resolve();
|
|
1052
|
+
return new Promise((resolve, reject) => {
|
|
1053
|
+
let settled = false;
|
|
1054
|
+
let timeoutId;
|
|
1055
|
+
let unsubscribe;
|
|
1056
|
+
const cleanup = () => {
|
|
1057
|
+
if (timeoutId)
|
|
1058
|
+
clearTimeout(timeoutId);
|
|
1059
|
+
if (unsubscribe)
|
|
1060
|
+
unsubscribe();
|
|
1061
|
+
options?.signal?.removeEventListener('abort', onAbort);
|
|
1062
|
+
};
|
|
1063
|
+
const finish = (fn) => {
|
|
1064
|
+
if (settled)
|
|
1065
|
+
return;
|
|
1066
|
+
settled = true;
|
|
1067
|
+
cleanup();
|
|
1068
|
+
fn();
|
|
1069
|
+
};
|
|
1070
|
+
const check = () => {
|
|
1071
|
+
if (listResourceIntents(target).length === 0) {
|
|
1072
|
+
finish(resolve);
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
const onAbort = () => {
|
|
1076
|
+
finish(() => reject(new AbloConnectionError('Intent wait aborted.', {
|
|
1077
|
+
code: 'intent_wait_aborted',
|
|
1078
|
+
cause: options?.signal?.reason,
|
|
1079
|
+
})));
|
|
1080
|
+
};
|
|
1081
|
+
if (options?.signal?.aborted) {
|
|
1082
|
+
onAbort();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
unsubscribe = intentStream.subscribe(check);
|
|
1086
|
+
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
1087
|
+
if (options?.timeout != null) {
|
|
1088
|
+
timeoutId = setTimeout(() => {
|
|
1089
|
+
finish(() => reject(busyError(target, listResourceIntents(target), 'resource_busy_timeout')));
|
|
1090
|
+
}, options.timeout);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
async function applyBusyPolicy(target, options) {
|
|
1095
|
+
const policy = options?.ifBusy ?? 'return';
|
|
1096
|
+
if (policy === 'return')
|
|
1097
|
+
return;
|
|
1098
|
+
const current = listResourceIntents(target);
|
|
1099
|
+
if (current.length === 0)
|
|
1100
|
+
return;
|
|
1101
|
+
if (policy === 'fail')
|
|
1102
|
+
throw busyError(target, current, 'resource_busy');
|
|
1103
|
+
await waitForResourceIdle(target, { timeout: options?.busyTimeout });
|
|
1104
|
+
}
|
|
1105
|
+
function wrapIntentHandle(claim) {
|
|
1106
|
+
const release = async () => {
|
|
1107
|
+
claim.revoke();
|
|
1108
|
+
};
|
|
1109
|
+
return {
|
|
1110
|
+
id: claim.id,
|
|
1111
|
+
release,
|
|
1112
|
+
revoke: claim.revoke,
|
|
1113
|
+
[Symbol.asyncDispose]: release,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
const publicIntents = Object.assign(intentStream, {
|
|
1117
|
+
async create(intentOptions) {
|
|
1118
|
+
await ready();
|
|
1119
|
+
const claim = intentStream.claim({
|
|
1120
|
+
type: intentOptions.target.resource,
|
|
1121
|
+
id: intentOptions.target.id,
|
|
1122
|
+
path: intentOptions.target.path,
|
|
1123
|
+
range: intentOptions.target.range,
|
|
1124
|
+
field: intentOptions.target.field,
|
|
1125
|
+
meta: intentOptions.target.meta,
|
|
1126
|
+
}, { reason: intentOptions.action, ttl: intentOptions.ttl });
|
|
1127
|
+
return wrapIntentHandle(claim);
|
|
1128
|
+
},
|
|
1129
|
+
list(target) {
|
|
1130
|
+
return listResourceIntents(target);
|
|
1131
|
+
},
|
|
1132
|
+
waitFor(target, options) {
|
|
1133
|
+
return waitForResourceIdle(target, options);
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
// Build the typed proxy — one property per model. Done after publicIntents
|
|
1137
|
+
// exists so model resources can expose workflow helpers such as
|
|
1138
|
+
// `ablo.files.edit(...)` without importing protocol wiring.
|
|
1139
|
+
const modelProxies = {};
|
|
1140
|
+
for (const [schemaKey, modelDef] of Object.entries(schema.models)) {
|
|
1141
|
+
const registeredModelName = modelDef.typename ?? schemaKey;
|
|
1142
|
+
modelProxies[schemaKey] = createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
|
|
1143
|
+
createIntent: (intentOptions) => publicIntents.create(intentOptions),
|
|
1144
|
+
createSnapshot: (modelKey, id) => createSnapshot({
|
|
1145
|
+
pool: objectPool,
|
|
1146
|
+
transport: store.getSyncWebSocket(),
|
|
1147
|
+
getLastSyncId: () => store.getSyncWebSocket()?.getLastSyncId() ?? store.lastSyncId ?? 0,
|
|
1148
|
+
entities: { [modelKey]: id },
|
|
1149
|
+
}),
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
const commits = {
|
|
1153
|
+
async create(commitOptions) {
|
|
1154
|
+
await ready();
|
|
1155
|
+
const clientTxId = createClientTxId(commitOptions.idempotencyKey);
|
|
1156
|
+
const operations = normalizeCommitOperations(commitOptions);
|
|
1157
|
+
const wait = commitOptions.wait ?? 'confirmed';
|
|
1158
|
+
const intentId = normalizeIntentId(commitOptions.intent);
|
|
1159
|
+
void intentId; // The current wire clears intents by entity after commit.
|
|
1160
|
+
// Route through the TransactionQueue's commit lane so the call
|
|
1161
|
+
// tolerates WS disconnects: the envelope stays in memory until
|
|
1162
|
+
// reconnect, mutationExecutor.commit() owns transport-level
|
|
1163
|
+
// retry, and `mutation_log` server-side dedupes replays by
|
|
1164
|
+
// clientTxId. Replaces the direct ws.sendCommit /
|
|
1165
|
+
// sendCommitQueued path that threw synchronously on
|
|
1166
|
+
// `ws.readyState !== OPEN`. The queue lives on the internal
|
|
1167
|
+
// SyncClient we already hold from createInternalComponents —
|
|
1168
|
+
// no need to leak an accessor through BaseSyncedStore.
|
|
1169
|
+
const queue = syncClient.getTransactionQueue();
|
|
1170
|
+
queue.enqueueCommit(clientTxId, operations, {
|
|
1171
|
+
causedByTaskId: activeTurnId,
|
|
1172
|
+
});
|
|
1173
|
+
if (wait === 'queued') {
|
|
1174
|
+
return { id: clientTxId, status: 'queued' };
|
|
1175
|
+
}
|
|
1176
|
+
const { lastSyncId } = await queue.waitForCommitReceipt(clientTxId);
|
|
1177
|
+
return { id: clientTxId, status: 'confirmed', lastSyncId };
|
|
1178
|
+
},
|
|
1179
|
+
};
|
|
1180
|
+
async function retrieveResource(resourceName, id, options) {
|
|
1181
|
+
await applyBusyPolicy({ resource: resourceName, id }, options);
|
|
1182
|
+
await ready();
|
|
1183
|
+
const res = await fetchImpl(`${bootstrapHelper.baseUrl}/sync/query`, {
|
|
1184
|
+
method: 'POST',
|
|
1185
|
+
headers: authHeaders(),
|
|
1186
|
+
credentials: 'include',
|
|
1187
|
+
body: JSON.stringify({
|
|
1188
|
+
queries: [
|
|
1189
|
+
{
|
|
1190
|
+
model: resourceName,
|
|
1191
|
+
where: [['id', '=', id]],
|
|
1192
|
+
limit: 1,
|
|
1193
|
+
},
|
|
1194
|
+
],
|
|
1195
|
+
}),
|
|
1196
|
+
});
|
|
1197
|
+
const bodyText = await res.text();
|
|
1198
|
+
let body = bodyText;
|
|
1199
|
+
if (bodyText.length > 0) {
|
|
1200
|
+
try {
|
|
1201
|
+
body = JSON.parse(bodyText);
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
// Keep raw body text.
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (!res.ok) {
|
|
1208
|
+
throw translateHttpError(res.status, body || `Resource retrieve failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
|
|
1209
|
+
}
|
|
1210
|
+
const parsed = body;
|
|
1211
|
+
const slot = parsed.results?.[0];
|
|
1212
|
+
const rows = Array.isArray(slot) ? slot : [];
|
|
1213
|
+
const data = rows[0];
|
|
1214
|
+
if (!data) {
|
|
1215
|
+
throw new AbloValidationError(`Resource not found: ${resourceName}/${id}`, { code: 'resource_not_found' });
|
|
1216
|
+
}
|
|
1217
|
+
const stamp = typeof parsed.lastSyncId === 'number'
|
|
1218
|
+
? parsed.lastSyncId
|
|
1219
|
+
: store.getSyncWebSocket()?.getLastSyncId() ?? store.lastSyncId ?? 0;
|
|
1220
|
+
return {
|
|
1221
|
+
data,
|
|
1222
|
+
stamp,
|
|
1223
|
+
intents: listResourceIntents({ resource: resourceName, id }),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
function resource(name) {
|
|
1227
|
+
return {
|
|
1228
|
+
retrieve(id, options) {
|
|
1229
|
+
return retrieveResource(name, id, options);
|
|
1230
|
+
},
|
|
1231
|
+
async create(data, mutationOptions) {
|
|
1232
|
+
const id = mutationOptions?.id ?? createResourceId();
|
|
1233
|
+
await applyBusyPolicy({ resource: name, id }, mutationOptions);
|
|
1234
|
+
return commits.create({
|
|
1235
|
+
intent: mutationOptions?.intent,
|
|
1236
|
+
idempotencyKey: mutationOptions?.idempotencyKey,
|
|
1237
|
+
readAt: mutationOptions?.readAt,
|
|
1238
|
+
onStale: mutationOptions?.onStale,
|
|
1239
|
+
wait: mutationOptions?.wait,
|
|
1240
|
+
timeout: mutationOptions?.timeout,
|
|
1241
|
+
operations: [
|
|
1242
|
+
{
|
|
1243
|
+
action: 'create',
|
|
1244
|
+
resource: name,
|
|
1245
|
+
id,
|
|
1246
|
+
data,
|
|
1247
|
+
},
|
|
1248
|
+
],
|
|
1249
|
+
});
|
|
1250
|
+
},
|
|
1251
|
+
async update(id, data, mutationOptions) {
|
|
1252
|
+
await applyBusyPolicy({ resource: name, id }, mutationOptions);
|
|
1253
|
+
return commits.create({
|
|
1254
|
+
intent: mutationOptions?.intent,
|
|
1255
|
+
idempotencyKey: mutationOptions?.idempotencyKey,
|
|
1256
|
+
readAt: mutationOptions?.readAt,
|
|
1257
|
+
onStale: mutationOptions?.onStale,
|
|
1258
|
+
wait: mutationOptions?.wait,
|
|
1259
|
+
timeout: mutationOptions?.timeout,
|
|
1260
|
+
operations: [
|
|
1261
|
+
{
|
|
1262
|
+
action: 'update',
|
|
1263
|
+
resource: name,
|
|
1264
|
+
id,
|
|
1265
|
+
data,
|
|
1266
|
+
},
|
|
1267
|
+
],
|
|
1268
|
+
});
|
|
1269
|
+
},
|
|
1270
|
+
async delete(id, mutationOptions) {
|
|
1271
|
+
await applyBusyPolicy({ resource: name, id }, mutationOptions);
|
|
1272
|
+
return commits.create({
|
|
1273
|
+
intent: mutationOptions?.intent,
|
|
1274
|
+
idempotencyKey: mutationOptions?.idempotencyKey,
|
|
1275
|
+
readAt: mutationOptions?.readAt,
|
|
1276
|
+
onStale: mutationOptions?.onStale,
|
|
1277
|
+
wait: mutationOptions?.wait,
|
|
1278
|
+
timeout: mutationOptions?.timeout,
|
|
1279
|
+
operations: [
|
|
1280
|
+
{
|
|
1281
|
+
action: 'delete',
|
|
1282
|
+
resource: name,
|
|
1283
|
+
id,
|
|
1284
|
+
},
|
|
1285
|
+
],
|
|
1286
|
+
});
|
|
1287
|
+
},
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
const engine = {
|
|
1291
|
+
...modelProxies,
|
|
1292
|
+
ready,
|
|
1293
|
+
waitForFlush,
|
|
1294
|
+
async dispose() {
|
|
1295
|
+
_refreshScheduler?.dispose();
|
|
1296
|
+
_refreshScheduler = null;
|
|
1297
|
+
try {
|
|
1298
|
+
await store.disconnect();
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
logger.warn('Error during sync engine disposal', { error: err.message });
|
|
1302
|
+
}
|
|
1303
|
+
presenceStream.dispose();
|
|
1304
|
+
intentStream.dispose();
|
|
1305
|
+
syncClient.dispose();
|
|
1306
|
+
},
|
|
1307
|
+
/**
|
|
1308
|
+
* Destroy every IndexedDB database owned by this engine. Disconnects
|
|
1309
|
+
* the WebSocket, releases timers, and deletes all `ablo_*` / `ablo-*`
|
|
1310
|
+
* databases. Typically called on session expiry or explicit logout.
|
|
1311
|
+
* Best-effort — errors from individual deletions are swallowed.
|
|
1312
|
+
*/
|
|
1313
|
+
async purge() {
|
|
1314
|
+
await store.purge();
|
|
1315
|
+
syncClient.dispose();
|
|
1316
|
+
},
|
|
1317
|
+
/**
|
|
1318
|
+
* Subscribe to session-error events. Fires when the server rejects
|
|
1319
|
+
* the session (WebSocket close code 1008/4001/4003 or a session_error
|
|
1320
|
+
* frame). Multiple subscribers supported; returns an unsubscribe
|
|
1321
|
+
* function. Consumers typically use this to trigger auth-failed UI
|
|
1322
|
+
* flows (e.g., redirect to sign-in). Does NOT automatically purge the
|
|
1323
|
+
* IndexedDB — call `engine.purge()` from the listener if you need
|
|
1324
|
+
* that behavior (the SDK's `<AbloProvider>` does this by default).
|
|
1325
|
+
*/
|
|
1326
|
+
onSessionError(listener) {
|
|
1327
|
+
return store.subscribeSessionError(listener);
|
|
1328
|
+
},
|
|
1329
|
+
onMutationFailure(listener) {
|
|
1330
|
+
return store.subscribeMutationFailure(listener);
|
|
1331
|
+
},
|
|
1332
|
+
waitForConfirmation(modelName, modelId) {
|
|
1333
|
+
return store.waitForConfirmation(modelName, modelId);
|
|
1334
|
+
},
|
|
1335
|
+
// Expose the store's MobX observable directly — single source of truth.
|
|
1336
|
+
// React components using observer() will re-render automatically on
|
|
1337
|
+
// any state change (syncing, error, offline, pendingChanges, progress).
|
|
1338
|
+
get syncStatus() {
|
|
1339
|
+
return store.syncStatus;
|
|
1340
|
+
},
|
|
1341
|
+
schema,
|
|
1342
|
+
// ── Internal accessors for framework integration ─────────────────
|
|
1343
|
+
// These expose internal components for consumers that need direct
|
|
1344
|
+
// access (e.g., SyncEngineProvider wiring SyncContext, collaboration
|
|
1345
|
+
// events accessing the WebSocket handle, demand loaders accessing
|
|
1346
|
+
// the pool). Prefixed with _ to signal "internal but stable."
|
|
1347
|
+
/** The BaseSyncedStore — implements SyncStoreContract for SyncContext.Provider. */
|
|
1348
|
+
get _store() { return store; },
|
|
1349
|
+
/** The ObjectPool — for demand loaders that need pool.createFromData(). */
|
|
1350
|
+
get _pool() { return objectPool; },
|
|
1351
|
+
/** The SyncWebSocket — for collaboration events (slide selection, cursors). */
|
|
1352
|
+
get _ws() { return store.getSyncWebSocket() ?? null; },
|
|
1353
|
+
/** Presence livestream — same socket as entity sync, no second
|
|
1354
|
+
* connection. Stable reference across the engine's lifetime. */
|
|
1355
|
+
presence: presenceStream,
|
|
1356
|
+
/** Intent livestream — same socket. Stable reference. */
|
|
1357
|
+
intents: publicIntents,
|
|
1358
|
+
commits,
|
|
1359
|
+
resource,
|
|
1360
|
+
/** Structured multiplayer participation — target-first, no
|
|
1361
|
+
* sync-group strings in the common path. */
|
|
1362
|
+
participants: participantManager,
|
|
1363
|
+
/** Context-staleness snapshot — see `engine.snapshot(...)` JSDoc. */
|
|
1364
|
+
snapshot(entities) {
|
|
1365
|
+
return createSnapshot({
|
|
1366
|
+
pool: objectPool,
|
|
1367
|
+
transport: store.getSyncWebSocket(),
|
|
1368
|
+
getLastSyncId: () => store.getSyncWebSocket()?.getLastSyncId() ?? store.lastSyncId ?? 0,
|
|
1369
|
+
entities,
|
|
1370
|
+
});
|
|
1371
|
+
},
|
|
1372
|
+
// ── Turn handles ────────────────────────────────────────────────
|
|
1373
|
+
//
|
|
1374
|
+
// Open a turn — every commit issued while the returned handle is
|
|
1375
|
+
// alive carries `caused_by_task_id` on the wire so the server
|
|
1376
|
+
// stamps it onto each delta. The product surface this powers:
|
|
1377
|
+
// `agent_tasks` audit trails ("which AI prompt produced this
|
|
1378
|
+
// mutation"), parent/child turn chains, cost accounting per turn.
|
|
1379
|
+
//
|
|
1380
|
+
// POST /api/agent/turn (capability bearer) → returns turnId.
|
|
1381
|
+
// POST /api/agent/turn/:id/close (capability bearer) → records
|
|
1382
|
+
// final cost stats. Idempotent.
|
|
1383
|
+
async beginTurn(beginOptions) {
|
|
1384
|
+
const baseUrl = url.replace(/\/+$/, '');
|
|
1385
|
+
const turnUrl = `${baseUrl.replace(/^ws/, 'http')}/api/agent/turn`;
|
|
1386
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1387
|
+
if (currentCapabilityToken) {
|
|
1388
|
+
headers.Authorization = `Bearer ${currentCapabilityToken}`;
|
|
1389
|
+
}
|
|
1390
|
+
const res = await fetch(turnUrl, {
|
|
1391
|
+
method: 'POST',
|
|
1392
|
+
headers,
|
|
1393
|
+
body: JSON.stringify({
|
|
1394
|
+
prompt: beginOptions.prompt,
|
|
1395
|
+
parentTaskId: beginOptions.parentTaskId,
|
|
1396
|
+
surface: beginOptions.surface,
|
|
1397
|
+
metadata: beginOptions.metadata,
|
|
1398
|
+
}),
|
|
1399
|
+
});
|
|
1400
|
+
if (!res.ok) {
|
|
1401
|
+
const body = await res.text().catch(() => '<no body>');
|
|
1402
|
+
throw new AbloError(`beginTurn failed: ${res.status} ${body}`, { code: 'turn_open_failed', httpStatus: res.status });
|
|
1403
|
+
}
|
|
1404
|
+
const json = (await res.json());
|
|
1405
|
+
const turnId = json.turnId;
|
|
1406
|
+
activeTurnId = turnId;
|
|
1407
|
+
let closed = false;
|
|
1408
|
+
const close = async (stats) => {
|
|
1409
|
+
if (closed)
|
|
1410
|
+
return;
|
|
1411
|
+
closed = true;
|
|
1412
|
+
if (activeTurnId === turnId)
|
|
1413
|
+
activeTurnId = null;
|
|
1414
|
+
const closeUrl = `${turnUrl}/${encodeURIComponent(turnId)}/close`;
|
|
1415
|
+
const closeRes = await fetch(closeUrl, {
|
|
1416
|
+
method: 'POST',
|
|
1417
|
+
headers,
|
|
1418
|
+
body: JSON.stringify({
|
|
1419
|
+
costInputTokens: stats?.costInputTokens ?? 0,
|
|
1420
|
+
costOutputTokens: stats?.costOutputTokens ?? 0,
|
|
1421
|
+
costComputeMs: stats?.costComputeMs ?? 0,
|
|
1422
|
+
}),
|
|
1423
|
+
});
|
|
1424
|
+
if (!closeRes.ok) {
|
|
1425
|
+
const body = await closeRes.text().catch(() => '<no body>');
|
|
1426
|
+
throw new AbloError(`closeTurn failed: ${closeRes.status} ${body}`, { code: 'turn_close_failed', httpStatus: closeRes.status });
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
const dispose = () => {
|
|
1430
|
+
if (closed)
|
|
1431
|
+
return;
|
|
1432
|
+
closed = true;
|
|
1433
|
+
if (activeTurnId === turnId)
|
|
1434
|
+
activeTurnId = null;
|
|
1435
|
+
};
|
|
1436
|
+
return { turnId, close, dispose, [Symbol.asyncDispose]: () => close() };
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
return engine;
|
|
1440
|
+
}
|