@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.
Files changed (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. 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
+ }