@abloatai/ablo 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +45 -36
  4. package/dist/BaseSyncedStore.d.ts +72 -195
  5. package/dist/BaseSyncedStore.js +240 -625
  6. package/dist/Database.d.ts +8 -9
  7. package/dist/Database.js +9 -14
  8. package/dist/LazyReferenceCollection.js +5 -0
  9. package/dist/Model.js +6 -8
  10. package/dist/ModelRegistry.d.ts +25 -6
  11. package/dist/ModelRegistry.js +12 -6
  12. package/dist/NetworkMonitor.js +1 -1
  13. package/dist/ObjectPool.js +9 -3
  14. package/dist/SyncClient.d.ts +15 -5
  15. package/dist/SyncClient.js +152 -66
  16. package/dist/SyncEngineContext.d.ts +1 -3
  17. package/dist/SyncEngineContext.js +1 -2
  18. package/dist/agent/Agent.d.ts +21 -8
  19. package/dist/agent/Agent.js +26 -13
  20. package/dist/ai-sdk/coordination-context.js +9 -3
  21. package/dist/ai-sdk/wrap.d.ts +1 -1
  22. package/dist/ai-sdk/wrap.js +1 -1
  23. package/dist/auth/credentialPolicy.d.ts +10 -2
  24. package/dist/auth/credentialSource.d.ts +1 -13
  25. package/dist/auth/credentialSource.js +6 -13
  26. package/dist/auth/index.js +3 -3
  27. package/dist/cli.cjs +583 -287
  28. package/dist/client/Ablo.d.ts +35 -764
  29. package/dist/client/Ablo.js +64 -688
  30. package/dist/client/ApiClient.d.ts +14 -1
  31. package/dist/client/ApiClient.js +63 -9
  32. package/dist/client/auth.d.ts +8 -17
  33. package/dist/client/auth.js +42 -6
  34. package/dist/client/consoleLogger.d.ts +36 -0
  35. package/dist/client/consoleLogger.js +45 -0
  36. package/dist/client/createInternalComponents.js +1 -1
  37. package/dist/client/createModelProxy.js +10 -6
  38. package/dist/client/credentialEndpoint.d.ts +63 -0
  39. package/dist/client/credentialEndpoint.js +87 -0
  40. package/dist/client/hostedEndpoints.d.ts +24 -0
  41. package/dist/client/hostedEndpoints.js +24 -0
  42. package/dist/client/httpClient.d.ts +1 -1
  43. package/dist/client/identity.js +2 -1
  44. package/dist/client/modelRegistration.d.ts +14 -0
  45. package/dist/client/modelRegistration.js +321 -0
  46. package/dist/client/options.d.ts +396 -0
  47. package/dist/client/options.js +10 -0
  48. package/dist/client/resourceTypes.d.ts +344 -0
  49. package/dist/client/resourceTypes.js +13 -0
  50. package/dist/client/schemaConfig.d.ts +56 -0
  51. package/dist/client/schemaConfig.js +188 -0
  52. package/dist/client/sessionMint.d.ts +1 -1
  53. package/dist/client/validateAbloOptions.js +1 -1
  54. package/dist/client/wsMutationExecutor.d.ts +33 -0
  55. package/dist/client/wsMutationExecutor.js +77 -0
  56. package/dist/context.js +0 -3
  57. package/dist/coordination/index.d.ts +8 -1
  58. package/dist/coordination/index.js +24 -1
  59. package/dist/coordination/trace.d.ts +0 -1
  60. package/dist/coordination/trace.js +0 -1
  61. package/dist/core/DatabaseManager.d.ts +0 -1
  62. package/dist/core/DatabaseManager.js +26 -24
  63. package/dist/core/QueryProcessor.js +7 -8
  64. package/dist/core/QueryView.d.ts +14 -2
  65. package/dist/core/QueryView.js +4 -5
  66. package/dist/core/StoreManager.d.ts +2 -2
  67. package/dist/core/StoreManager.js +5 -1
  68. package/dist/core/index.d.ts +1 -1
  69. package/dist/core/openIDBWithTimeout.js +15 -11
  70. package/dist/core/query-utils.js +1 -1
  71. package/dist/core/storeContract.d.ts +143 -0
  72. package/dist/core/storeContract.js +16 -0
  73. package/dist/errorCodes.d.ts +11 -2
  74. package/dist/errorCodes.js +155 -137
  75. package/dist/errors.d.ts +18 -7
  76. package/dist/errors.js +14 -1
  77. package/dist/index.js +5 -0
  78. package/dist/interfaces/index.d.ts +6 -20
  79. package/dist/keys/index.js +18 -9
  80. package/dist/mutators/UndoManager.js +6 -1
  81. package/dist/mutators/defineMutators.d.ts +1 -3
  82. package/dist/policy/types.d.ts +1 -1
  83. package/dist/query/client.d.ts +11 -0
  84. package/dist/query/client.js +81 -52
  85. package/dist/react/AbloProvider.d.ts +6 -10
  86. package/dist/react/AbloProvider.js +22 -15
  87. package/dist/react/context.d.ts +2 -118
  88. package/dist/react/useAblo.js +1 -1
  89. package/dist/react/useErrorListener.js +1 -1
  90. package/dist/react/useMutationFailureListener.js +1 -1
  91. package/dist/react/useMutators.js +7 -1
  92. package/dist/react/useReactive.js +0 -1
  93. package/dist/react/useUndoScope.js +2 -2
  94. package/dist/schema/ddlLock.d.ts +39 -0
  95. package/dist/schema/ddlLock.js +51 -0
  96. package/dist/schema/field.js +1 -1
  97. package/dist/schema/index.d.ts +4 -3
  98. package/dist/schema/index.js +15 -5
  99. package/dist/schema/model.d.ts +3 -3
  100. package/dist/schema/model.js +2 -2
  101. package/dist/schema/openapi.js +5 -1
  102. package/dist/schema/queries.d.ts +1 -1
  103. package/dist/schema/queries.js +1 -0
  104. package/dist/schema/residency.d.ts +35 -0
  105. package/dist/schema/residency.js +27 -0
  106. package/dist/schema/roles.d.ts +25 -0
  107. package/dist/schema/roles.js +37 -0
  108. package/dist/schema/schema.d.ts +3 -3
  109. package/dist/schema/schema.js +11 -7
  110. package/dist/schema/serialize.d.ts +8 -8
  111. package/dist/schema/sync-delta-row.d.ts +2 -2
  112. package/dist/schema/sync-delta-row.js +2 -2
  113. package/dist/server/storage-mode.d.ts +8 -1
  114. package/dist/server/storage-mode.js +14 -4
  115. package/dist/source/adapter.d.ts +1 -1
  116. package/dist/source/adapters/drizzle.js +4 -3
  117. package/dist/source/adapters/kysely.js +4 -3
  118. package/dist/source/adapters/memory.js +1 -1
  119. package/dist/source/adapters/prisma.js +5 -4
  120. package/dist/source/conformance.js +10 -8
  121. package/dist/source/connector.js +4 -2
  122. package/dist/source/factory.d.ts +106 -0
  123. package/dist/source/factory.js +273 -0
  124. package/dist/source/index.d.ts +11 -461
  125. package/dist/source/index.js +14 -416
  126. package/dist/source/next.d.ts +1 -1
  127. package/dist/source/next.js +1 -1
  128. package/dist/source/pushQueue.d.ts +1 -1
  129. package/dist/source/pushQueue.js +6 -3
  130. package/dist/source/signing.d.ts +59 -0
  131. package/dist/source/signing.js +142 -0
  132. package/dist/source/types.d.ts +334 -0
  133. package/dist/source/types.js +44 -0
  134. package/dist/stores/ObjectStore.d.ts +1 -1
  135. package/dist/stores/ObjectStore.js +23 -23
  136. package/dist/stores/SyncActionStore.d.ts +1 -1
  137. package/dist/stores/SyncActionStore.js +70 -35
  138. package/dist/surface.d.ts +1 -1
  139. package/dist/surface.js +1 -0
  140. package/dist/sync/AreaOfInterestManager.d.ts +2 -2
  141. package/dist/sync/AreaOfInterestManager.js +1 -1
  142. package/dist/sync/BootstrapHelper.d.ts +1 -3
  143. package/dist/sync/BootstrapHelper.js +29 -8
  144. package/dist/sync/ConnectionManager.d.ts +8 -0
  145. package/dist/sync/ConnectionManager.js +19 -4
  146. package/dist/sync/HydrationCoordinator.d.ts +12 -0
  147. package/dist/sync/HydrationCoordinator.js +25 -5
  148. package/dist/sync/NetworkProbe.d.ts +3 -2
  149. package/dist/sync/NetworkProbe.js +7 -6
  150. package/dist/sync/SyncWebSocket.d.ts +71 -120
  151. package/dist/sync/SyncWebSocket.js +324 -630
  152. package/dist/sync/awaitClaimGrant.js +27 -19
  153. package/dist/sync/bootstrapApply.d.ts +60 -0
  154. package/dist/sync/bootstrapApply.js +65 -0
  155. package/dist/sync/commitFrames.d.ts +43 -0
  156. package/dist/sync/commitFrames.js +94 -0
  157. package/dist/sync/createClaimStream.js +3 -2
  158. package/dist/sync/createSnapshot.d.ts +1 -3
  159. package/dist/sync/credentialLifecycle.d.ts +175 -0
  160. package/dist/sync/credentialLifecycle.js +329 -0
  161. package/dist/sync/deltaPipeline.d.ts +111 -0
  162. package/dist/sync/deltaPipeline.js +262 -0
  163. package/dist/sync/groupChange.d.ts +111 -0
  164. package/dist/sync/groupChange.js +246 -0
  165. package/dist/sync/heartbeat.d.ts +62 -0
  166. package/dist/sync/heartbeat.js +91 -0
  167. package/dist/sync/participants.d.ts +8 -8
  168. package/dist/sync/syncCursor.d.ts +44 -0
  169. package/dist/sync/syncCursor.js +59 -0
  170. package/dist/sync/syncPlan.d.ts +62 -0
  171. package/dist/sync/syncPlan.js +51 -0
  172. package/dist/sync/wsFrameHandlers.d.ts +117 -0
  173. package/dist/sync/wsFrameHandlers.js +378 -0
  174. package/dist/testing/fixtures/bootstrap.d.ts +9 -11
  175. package/dist/testing/fixtures/deltas.d.ts +1 -1
  176. package/dist/testing/fixtures/models.js +1 -0
  177. package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
  178. package/dist/testing/helpers/sync-engine-harness.js +2 -1
  179. package/dist/testing/index.d.ts +1 -1
  180. package/dist/testing/index.js +1 -1
  181. package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
  182. package/dist/testing/mocks/MockSyncContext.js +1 -32
  183. package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
  184. package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
  185. package/dist/transactions/TransactionQueue.d.ts +20 -56
  186. package/dist/transactions/TransactionQueue.js +101 -487
  187. package/dist/transactions/TransactionStore.d.ts +18 -0
  188. package/dist/transactions/TransactionStore.js +51 -0
  189. package/dist/transactions/coalesceRules.d.ts +34 -0
  190. package/dist/transactions/coalesceRules.js +117 -0
  191. package/dist/transactions/commitPayload.d.ts +134 -0
  192. package/dist/transactions/commitPayload.js +152 -0
  193. package/dist/transactions/deltaConfirmation.d.ts +60 -0
  194. package/dist/transactions/deltaConfirmation.js +223 -0
  195. package/dist/transactions/optimistic.d.ts +24 -0
  196. package/dist/transactions/optimistic.js +45 -0
  197. package/dist/transactions/persistedReplay.d.ts +93 -0
  198. package/dist/transactions/persistedReplay.js +105 -0
  199. package/dist/types/index.d.ts +3 -3
  200. package/dist/types/index.js +0 -5
  201. package/dist/types/modelData.d.ts +12 -0
  202. package/dist/types/modelData.js +11 -0
  203. package/dist/types/participant.d.ts +21 -0
  204. package/dist/types/participant.js +12 -0
  205. package/dist/types/streams.d.ts +8 -14
  206. package/dist/utils/mobx-setup.d.ts +1 -1
  207. package/dist/utils/mobx-setup.js +2 -2
  208. package/dist/wire/errorEnvelope.d.ts +11 -2
  209. package/dist/wire/errorEnvelope.js +14 -2
  210. package/dist/wire/frames.d.ts +90 -0
  211. package/dist/wire/frames.js +55 -1
  212. package/dist/wire/index.d.ts +4 -1
  213. package/dist/wire/index.js +20 -1
  214. package/dist/wire/protocol.d.ts +45 -0
  215. package/dist/wire/protocol.js +45 -0
  216. package/dist/wire/protocolVersion.d.ts +56 -0
  217. package/dist/wire/protocolVersion.js +63 -0
  218. package/docs/api-keys.md +4 -3
  219. package/docs/data-sources.md +12 -5
  220. package/docs/examples/existing-python-backend.md +3 -3
  221. package/docs/identity.md +4 -4
  222. package/docs/integration-guide.md +1 -1
  223. package/docs/react.md +1 -1
  224. package/docs/sessions.md +5 -7
  225. package/llms.txt +4 -2
  226. package/package.json +15 -13
  227. package/dist/client/index.d.ts +0 -36
  228. package/dist/client/index.js +0 -33
  229. package/dist/config/index.d.ts +0 -10
  230. package/dist/config/index.js +0 -12
  231. package/dist/interfaces/headless.d.ts +0 -95
  232. package/dist/interfaces/headless.js +0 -41
  233. package/dist/query/index.d.ts +0 -6
  234. package/dist/query/index.js +0 -5
  235. package/dist/realtime/index.d.ts +0 -10
  236. package/dist/realtime/index.js +0 -9
  237. package/dist/schema/plane.d.ts +0 -23
  238. package/dist/schema/plane.js +0 -19
  239. package/dist/server/next.d.ts +0 -51
  240. package/dist/server/next.js +0 -47
  241. package/dist/sync/OfflineFlush.d.ts +0 -9
  242. package/dist/sync/OfflineFlush.js +0 -22
  243. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  244. package/dist/sync/OfflineTransactionStore.js +0 -263
  245. package/dist/transactions/index.d.ts +0 -16
  246. package/dist/transactions/index.js +0 -7
@@ -5,7 +5,7 @@
5
5
  * bootstrap, offline queue, DI adapters) behind a single function call.
6
6
  *
7
7
  * Usage:
8
- * import { Ablo } from '@abloatai/ablo/client';
8
+ * import { Ablo } from '@abloatai/ablo';
9
9
  * import { schema } from './schema';
10
10
  *
11
11
  * const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
@@ -18,21 +18,18 @@
18
18
  * });
19
19
  * await sync.reports.delete({ id: reportId });
20
20
  */
21
- import { z } from 'zod';
22
- import { baseFieldsSchema } from '../schema/schema.js';
23
- import { schemaHash } from '../schema/serialize.js';
24
- import { AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
21
+ import { AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
25
22
  import { descriptionFromMeta } from '../coordination/schema.js';
26
- import { LoadStrategy, PropertyType } from '../types/index.js';
27
23
  import { initSyncEngine } from '../context.js';
28
24
  import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
29
25
  import { alwaysOnline } from '../adapters/alwaysOnline.js';
30
26
  import { validateAbloOptions } from './validateAbloOptions.js';
27
+ import { ObjectPool } from '../ObjectPool.js';
28
+ import {} from '../auth/index.js';
31
29
  import { mintSession } from './sessionMint.js';
32
30
  import { createAuthCredentialSource } from '../auth/credentialSource.js';
33
31
  import { createInternalComponents } from './createInternalComponents.js';
34
32
  import { resolveParticipantIdentity } from './identity.js';
35
- import { Model } from '../Model.js';
36
33
  import { BaseSyncedStore } from '../BaseSyncedStore.js';
37
34
  import { createPresenceStream } from '../sync/createPresenceStream.js';
38
35
  import { createClaimStream } from '../sync/createClaimStream.js';
@@ -41,675 +38,21 @@ import { createSnapshot } from '../sync/createSnapshot.js';
41
38
  import { reconcileFunctionalUpdate } from './functionalUpdate.js';
42
39
  import { createParticipantManager } from '../sync/participants.js';
43
40
  import { createProtocolClient, } from './ApiClient.js';
44
- // Value import is cycle-safe: httpClient.js only value-imports ApiClient.js,
45
- // which imports this module type-only.
41
+ // Value import is cycle-safe: httpClient.js and ApiClient.js take the client
42
+ // types from the `options`/`resourceTypes` leaves, never from this module.
46
43
  import { createAbloHttpClient, } from './httpClient.js';
47
44
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
48
45
  import { registerDataSource } from './registerDataSource.js';
49
- import { shouldUseInMemoryPersistence, } from './persistence.js';
46
+ import { shouldUseInMemoryPersistence } from './persistence.js';
47
+ import { deriveConfigFromSchema } from './schemaConfig.js';
48
+ import { registerModelsFromSchema } from './modelRegistration.js';
49
+ import { createConsoleLogger, resolveLogLevel } from './consoleLogger.js';
50
+ import { createDefaultMutationExecutor } from './wsMutationExecutor.js';
51
+ export { computeFKDepthPriority } from './schemaConfig.js';
50
52
  import { createModelProxy } from './createModelProxy.js';
51
53
  import { assertWriteOptions } from './writeOptionsSchema.js';
52
- // ── Config derivation from schema ─────────────────────────────────────────
53
- /**
54
- * Compute a create-priority map from schema `belongsTo` relations using
55
- * Tarjan's strongly-connected-components algorithm.
56
- *
57
- * The FK graph has an edge `child → parent` for every `belongsTo`. Tarjan
58
- * runs a single linear DFS that simultaneously (a) detects cycles by
59
- * grouping mutually-reachable nodes into SCCs and (b) emits those SCCs
60
- * in reverse topological order of the condensation graph. In this edge
61
- * convention a "sink" SCC has no outgoing edges — i.e. no parents — so
62
- * it is an *FK root* (`organizations`, `themes`, etc.). Tarjan emits
63
- * roots first and leaves last, exactly the order in which rows must be
64
- * inserted to satisfy FK constraints.
65
- *
66
- * Priorities are assigned by emit order: SCC #0 → 10, SCC #1 → 20, …
67
- * Members of the same SCC share a priority, so insertion order wins the
68
- * tiebreak inside a cycle (this matters for cyclic schemas like
69
- * `slideDecks ↔ layouts`, where one direction is the user's chosen
70
- * "soft" edge — only the consumer's mutator sequence knows which one).
71
- *
72
- * This algorithm is iteration-order-independent: starting the DFS from
73
- * any node yields the same SCC partitioning, and SCCs always come out
74
- * in valid topological order. The previous DFS-with-memoization
75
- * heuristic broke under cycles by treating the back-edge as depth 0,
76
- * which made priorities depend on which node the walk happened to
77
- * enter the cycle at.
78
- *
79
- * Schema authors can mark one side of a cycle with
80
- * `belongsTo(target, fk, { defer: true })`. Those edges are excluded
81
- * from the dependency graph entirely, which deterministically breaks
82
- * the cycle and turns the SCC into a chain — the marked child gets a
83
- * strictly higher priority than its parent instead of being tied with
84
- * it. Pair with a Postgres `DEFERRABLE INITIALLY DEFERRED` constraint
85
- * if you want the database side of the cycle to also relax. See
86
- * {@link BelongsToOptions.defer}.
87
- *
88
- * The returned map is keyed by {@link ModelDef.typename} (falling back
89
- * to the schema key), because that is what `Model.getModelName()`
90
- * returns at transaction time — keying by schema key would silently
91
- * miss the lookup and every model would fall through to
92
- * `defaultCreatePriority`.
93
- *
94
- * Reference: Tarjan, R. (1972), "Depth-first search and linear graph
95
- * algorithms." Linear in V + E.
96
- */
97
- export function computeFKDepthPriority(schema) {
98
- // schemaKey → typename (wire name used at transaction time)
99
- const keyToTypename = new Map();
100
- for (const [key, def] of Object.entries(schema.models)) {
101
- keyToTypename.set(key, def.typename ?? key);
102
- }
103
- // Adjacency: schemaKey → parent schema keys pulled from `belongsTo`.
104
- // Parents not in the schema (e.g. external types) are dropped so the
105
- // graph stays closed. Edges marked `{ defer: true }` are also
106
- // dropped — the schema author has declared this side of a cycle to
107
- // be the "soft" one (insert with null FK, patch later), so the
108
- // dependency-graph walker treats it as if the edge weren't there.
109
- // That breaks the cycle deterministically and lets the other side
110
- // become a strict topological predecessor.
111
- const parentsOf = new Map();
112
- for (const [key, def] of Object.entries(schema.models)) {
113
- const out = [];
114
- for (const rel of Object.values(def.relations)) {
115
- if (rel.type !== 'belongsTo')
116
- continue;
117
- if (!keyToTypename.has(rel.target))
118
- continue;
119
- if (rel.options?.defer === true)
120
- continue;
121
- out.push(rel.target);
122
- }
123
- parentsOf.set(key, out);
124
- }
125
- // Tarjan SCC bookkeeping
126
- const dfsIndex = new Map();
127
- const lowlink = new Map();
128
- const onStack = new Set();
129
- const stack = [];
130
- const sccs = [];
131
- let counter = 0;
132
- function strongconnect(v) {
133
- dfsIndex.set(v, counter);
134
- lowlink.set(v, counter);
135
- counter++;
136
- stack.push(v);
137
- onStack.add(v);
138
- for (const w of parentsOf.get(v) ?? []) {
139
- if (!dfsIndex.has(w)) {
140
- strongconnect(w);
141
- lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(w)));
142
- }
143
- else if (onStack.has(w)) {
144
- // Back-edge into the active DFS path — w is in the same SCC as v.
145
- lowlink.set(v, Math.min(lowlink.get(v), dfsIndex.get(w)));
146
- }
147
- }
148
- // v is the root of an SCC: pop everything down to v inclusive.
149
- if (lowlink.get(v) === dfsIndex.get(v)) {
150
- const component = [];
151
- let w;
152
- do {
153
- w = stack.pop();
154
- onStack.delete(w);
155
- component.push(w);
156
- } while (w !== v);
157
- sccs.push(component);
158
- }
159
- }
160
- for (const key of keyToTypename.keys()) {
161
- if (!dfsIndex.has(key))
162
- strongconnect(key);
163
- }
164
- // Tarjan emits SCCs in reverse topological order of the condensation.
165
- // In our edge convention (child→parent), reverse-topo of the
166
- // condensation means root-SCCs (no outgoing edges = no parents)
167
- // first, leaf-SCCs (deepest descendants) last. We could just use
168
- // emit-order as the priority — but that gives independent sibling
169
- // SCCs different priorities, which is semantically wrong: siblings
170
- // don't depend on each other and shouldn't be ordered relative to
171
- // each other.
172
- //
173
- // Instead, do one more pass to compute *longest-path depth* on the
174
- // condensation DAG: depth(SCC) = max(depth(parent SCC)) + 1, or 0
175
- // for SCCs with no in-schema parents. SCCs at the same depth get
176
- // the same priority — siblings stay tied, insertion order in the
177
- // queue breaks the tie. Priority = (depth + 1) * 10.
178
- //
179
- // We can compute this in a single pass over the SCCs because
180
- // Tarjan's emit-order *is* a valid topological order of the
181
- // condensation: when we process sccs[i], every parent SCC has
182
- // already been assigned a depth.
183
- const nodeToSccIdx = new Map();
184
- sccs.forEach((scc, i) => {
185
- for (const node of scc)
186
- nodeToSccIdx.set(node, i);
187
- });
188
- const sccDepth = new Map();
189
- sccs.forEach((scc, i) => {
190
- let maxParentDepth = -1;
191
- for (const node of scc) {
192
- for (const parent of parentsOf.get(node) ?? []) {
193
- const parentSccIdx = nodeToSccIdx.get(parent);
194
- if (parentSccIdx === undefined)
195
- continue;
196
- if (parentSccIdx === i)
197
- continue; // intra-SCC edge — not a dep
198
- const d = sccDepth.get(parentSccIdx);
199
- if (d !== undefined && d > maxParentDepth)
200
- maxParentDepth = d;
201
- }
202
- }
203
- sccDepth.set(i, maxParentDepth + 1);
204
- });
205
- const out = new Map();
206
- sccs.forEach((scc, i) => {
207
- const priority = (sccDepth.get(i) + 1) * 10;
208
- for (const key of scc) {
209
- out.set(keyToTypename.get(key), priority);
210
- }
211
- });
212
- return out;
213
- }
214
- function deriveConfigFromSchema(schema) {
215
- // Commit payload projection is done directly inside `TransactionQueue`
216
- // — see `projectCommitPayload` there. Each model's field metadata
217
- // rides on `ModelRegistry` (populated by `registerModelsFromSchema`),
218
- // so there's no config-layer shim: the queue asks the registry for
219
- // the declared fields and serializes accordingly.
220
- return {
221
- modelCreatePriority: computeFKDepthPriority(schema),
222
- defaultCreatePriority: 40,
223
- defaultNonCreatePriority: 50,
224
- essentialFields: {},
225
- classNameFallbackMap: {},
226
- // Hash this client's schema once so bootstrap can detect drift against the
227
- // server's active hash (same `schemaHash` the CLI push + server compute).
228
- expectedSchemaHash: schemaHash(schema),
229
- };
230
- }
231
- // ── Auto model registration from schema ───────────────────────────────────
232
- function registerModelsFromSchema(schema, registry) {
233
- registry.startBatch();
234
- for (const [schemaKey, modelDef] of Object.entries(schema.models)) {
235
- // Use typename as the model name — this is the wire-format name that
236
- // the server sends in bootstrap responses and sync deltas. The pool's
237
- // typeIndex, the ModelRegistry, and getModelName() all use this name.
238
- // Schema key (camelCase plural) is only for the consumer-facing proxy API.
239
- const modelName = modelDef.typename ?? schemaKey;
240
- // Collect JSON sub-property fields to generate ${field}Json getters
241
- const jsonSubFields = [];
242
- for (const [fieldName, zodType] of Object.entries(modelDef.shape)) {
243
- const inner = unwrapZodType(zodType);
244
- if (isZodObject(inner)) {
245
- jsonSubFields.push({ fieldName, subSchema: inner });
246
- }
247
- }
248
- // Create a dynamic Model subclass with JSON sub-property getters.
249
- //
250
- // Field-level MobX observability is ON BY DEFAULT. A reactive read like
251
- // `useAblo((a) => a.documents.get(id))` must re-render when a remote delta
252
- // mutates the row IN PLACE (the common collaborative case); without
253
- // per-field observability that update fires no reaction and the UI silently
254
- // goes stale. Models opt OUT with an explicit `lazyObservable: false` —
255
- // appropriate only for very large read-only list models where per-field
256
- // atoms cost more than the QueryView's entry-replaced reactivity already
257
- // provides. json fields register as `observable.ref` (see
258
- // `registerModelsFromSchema`), so the default is ~one atom per scalar field
259
- // per loaded row — cheap — not a deep atom tree per blob.
260
- const isLazy = modelDef.lazyObservable !== false;
261
- // Base provenance fields (`organizationId`, `createdBy`) live in
262
- // `baseFieldsSchema`, not the per-model `shape`. The server stamps + emits
263
- // them (camelCased on the wire), but hydration (`Model.assignFieldsFromData`)
264
- // only assigns keys that already exist as an own/prototype property — so
265
- // without a slot here, `deck.createdBy` / `deck.organizationId` silently read
266
- // `undefined` (this is why the profile decks tab showed nothing: it filters
267
- // `decks.filter(d => d.createdBy === userId)`). `id`/`createdAt`/`updatedAt`
268
- // are already seeded by the base Model constructor, so they're excluded.
269
- const fieldNames = [
270
- ...Object.keys(modelDef.shape),
271
- ...Object.keys(baseFieldsSchema.shape).filter((f) => f !== 'id' && f !== 'createdAt' && f !== 'updatedAt' && !(f in modelDef.shape)),
272
- ];
273
- const computed = modelDef.computed;
274
- const DynamicModel = createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed, isLazy);
275
- // Respect the schema's load strategy so lazy models skip IDB hydration + bootstrap
276
- const loadStrategy = modelDef.load === 'lazy' || modelDef.load === 'manual'
277
- ? LoadStrategy.lazy
278
- : LoadStrategy.instant;
279
- registry.registerModel(modelName, DynamicModel, {
280
- loadStrategy,
281
- fields: modelDef.fields,
282
- autoFill: modelDef.autoFill,
283
- requiredFields: modelDef.requiredFields,
284
- });
285
- // Collect the set of fields that should get an IDB secondary index.
286
- //
287
- // Matches Linear's opt-in model (see wzhudev/reverse-linear-sync-engine):
288
- // `@Reference(..., { indexed: true })`. Only `belongsTo` relations that
289
- // explicitly set `{ index: true }` in their options get an IDB secondary
290
- // index. Every other FK (and every scalar) is resolved via in-memory
291
- // ObjectPool scans, which are fast enough at org-scope sizes (~10k rows)
292
- // and reactive via MobX.
293
- //
294
- // Auto-indexing every belongsTo was wrong: it bloated write amplification
295
- // for the vast majority of FKs that are never queried by fk. Indexing
296
- // every scalar (like the legacy Go backend did) is even worse.
297
- const indexedFields = new Set();
298
- for (const relDef of Object.values(modelDef.relations)) {
299
- if (relDef.type === 'belongsTo' && relDef.foreignKey && relDef.options?.index === true) {
300
- indexedFields.add(relDef.foreignKey);
301
- }
302
- }
303
- // Register fields as properties (from Zod shape).
304
- for (const [fieldName, rawZodType] of Object.entries(modelDef.shape)) {
305
- const zodType = rawZodType;
306
- const isOptional = zodType.isOptional?.() ?? false;
307
- // A field is indexed if it's the FK of a `belongsTo({ index: true })`
308
- // relation. Legacy `description === 'indexed'` still works for
309
- // consumers using `field.*().indexed()`.
310
- const isIndexed = indexedFields.has(fieldName) || zodType.description === 'indexed';
311
- // JSON-typed fields (per the schema's wire-type tag) are opaque
312
- // blobs from MobX's perspective — chart specs, ProseMirror docs,
313
- // style maps. Deep observability on them recursively walks every
314
- // nested property and creates an atom for each leaf, producing a
315
- // microtask storm on every commit/streaming update. `ref` tracks
316
- // only reassignment, which is how blob consumers actually use them.
317
- const wireType = modelDef.fields?.[fieldName]?.type;
318
- const observability = wireType === 'json' ? 'ref' : undefined;
319
- registry.registerProperty(modelName, fieldName, {
320
- type: PropertyType.property,
321
- indexed: isIndexed,
322
- optional: isOptional,
323
- observability,
324
- });
325
- }
326
- // Register relations
327
- for (const [relName, relDef] of Object.entries(modelDef.relations)) {
328
- if (relDef.type === 'belongsTo') {
329
- registry.registerReference(modelName, relName, {
330
- referencedModel: () => {
331
- const targetModel = registry.getModelByName(relDef.target);
332
- return targetModel ?? DynamicModel;
333
- },
334
- indexed: true,
335
- });
336
- }
337
- else if (relDef.type === 'hasMany') {
338
- // Generate a getter on the parent model that returns all children
339
- // matching the FK via Model.getStore().getByForeignKey(). The FK
340
- // index on the target model is registered by deriveSyncPlanFromSchema.
341
- const targetName = relDef.target;
342
- const foreignKey = relDef.foreignKey;
343
- const orderByField = relDef._orderBy;
344
- // Resolve the target typename from the schema (might differ from the key)
345
- const targetDef = schema.models[targetName];
346
- const targetTypename = targetDef?.typename ?? targetName;
347
- Object.defineProperty(DynamicModel.prototype, relName, {
348
- get() {
349
- const store = Model.getStore();
350
- if (!store)
351
- return [];
352
- const results = store.getByForeignKey(targetTypename, foreignKey, this.id);
353
- if (orderByField && results.length > 1) {
354
- return [...results].sort((a, b) => {
355
- // `orderByField` is a runtime string from the schema's
356
- // hasMany({ orderBy }) — Models have dynamic typed
357
- // fields produced by createDynamicModelClass, so the
358
- // static type doesn't carry an index signature for
359
- // arbitrary field reads. `Reflect.get` is the typed
360
- // bridge — returns `unknown`, narrowed below.
361
- const va = Reflect.get(a, orderByField);
362
- const vb = Reflect.get(b, orderByField);
363
- if (typeof va === 'number' && typeof vb === 'number')
364
- return va - vb;
365
- if (typeof va === 'string' && typeof vb === 'string')
366
- return va.localeCompare(vb);
367
- return 0;
368
- });
369
- }
370
- return results;
371
- },
372
- enumerable: true,
373
- configurable: true,
374
- });
375
- }
376
- }
377
- }
378
- registry.endBatch();
379
- }
380
- // ── JSON sub-property helpers ─────────────────────────────────────────────
381
- /**
382
- * Unwrap a Zod schema through .optional(), .nullable(), .default(),
383
- * .readonly() to find the innermost type. Needed to detect whether a
384
- * field.json() call wraps a ZodObject (has sub-properties) or a plain
385
- * type (ZodUnknown, ZodArray, etc.).
386
- *
387
- * Uses Zod's public `.unwrap()` API per wrapper type — no `_def`
388
- * digging. Bounded loop guards against pathological self-referential
389
- * wrappers.
390
- */
391
- function unwrapZodType(schema) {
392
- let current = schema;
393
- for (let i = 0; i < 10; i++) {
394
- if (current instanceof z.ZodOptional) {
395
- current = current.unwrap();
396
- continue;
397
- }
398
- if (current instanceof z.ZodNullable) {
399
- current = current.unwrap();
400
- continue;
401
- }
402
- if (current instanceof z.ZodDefault) {
403
- // v4 deprecates removeDefault in favor of unwrap, but the
404
- // installed @types declarations only expose removeDefault on
405
- // ZodDefault. Use it — it's the same runtime function.
406
- current = current.unwrap();
407
- continue;
408
- }
409
- if (current instanceof z.ZodReadonly) {
410
- current = current.unwrap();
411
- continue;
412
- }
413
- break;
414
- }
415
- return current;
416
- }
417
- /** Type guard: is this a ZodObject with a .shape property? */
418
- function isZodObject(schema) {
419
- return schema instanceof z.ZodObject;
420
- }
421
- /** Create a Model subclass for a schema-defined model */
422
- function createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed, lazyObservable = false) {
423
- const ModelClass = class extends Model {
424
- _modelName = modelName;
425
- constructor(data) {
426
- super(data);
427
- // Gate `propertyChanged`-via-`observe` tracking during initial
428
- // hydration. M1 installs a MobX `observe()` listener per schema
429
- // property that forwards writes to `propertyChanged()` so direct
430
- // assignments like `layer.position = newPos` still round-trip
431
- // through the transaction queue. During construction we're writing
432
- // wire data, NOT user edits — flagging this as "constructing" lets
433
- // the listener early-return on those writes so `modifiedProperties`
434
- // doesn't get polluted with every field of every hydrated model.
435
- //
436
- // The listener is installed by `makeObservable()` below (inside
437
- // M1), so writes that happen BEFORE that line won't fire it; this
438
- // flag is defensive in case a subclass or call path reorders the
439
- // steps later.
440
- this._isConstructing = true;
441
- // MobX 6 requires fields to exist as own properties BEFORE makeObservable().
442
- // Model base only sets id/createdAt/updatedAt. Schema fields (title, userId, etc.)
443
- // must be initialized here so M1's annotations can find them.
444
- for (const field of fieldNames) {
445
- if (!(field in this)) {
446
- this[field] = data?.[field] ?? undefined;
447
- }
448
- }
449
- // Per-field MobX observability opt-in via `lazyObservable: true` on
450
- // the model definition. Defaults to plain objects — reactivity comes
451
- // from the QueryView "entry replaced" pattern, which is cheap for
452
- // read-only list UIs but invisible to in-place field mutations.
453
- //
454
- // Multiplayer editors need live field-level reactivity so remote
455
- // deltas AND local drag/resize/rename mutations surface through
456
- // `observer()` components without the whole pool entry being
457
- // replaced. Without observability, `layer.position.x = 500` emits
458
- // nothing and the UI lags until some unrelated state change triggers
459
- // a pass (toolbar close, deselect).
460
- //
461
- // Delegates to `Model.makeObservable()` (the inherited method) so
462
- // MobX annotations are derived from the same registry that M1 reads.
463
- // That means computed getters, reference collections, custom
464
- // getters/setters, and property-change tracking all integrate
465
- // correctly — reimplementing `makeObservable` inline here would miss
466
- // those seams.
467
- if (lazyObservable) {
468
- this.makeObservable();
469
- }
470
- this._isConstructing = false;
471
- }
472
- getModelName() {
473
- return this._modelName;
474
- }
475
- };
476
- // Generate ${field}Json getters for JSON fields with sub-properties.
477
- //
478
- // The getter reads the raw JSON string from the instance (set via
479
- // updateFromData), parses it, applies Zod defaults, and caches by
480
- // raw value. This replaces the hand-coded metadataObject + sub-property
481
- // getter pattern that 11+ Ablo models currently repeat.
482
- //
483
- // Example: field named 'metadata' with sub-schema { icon: z.string().default('presentation') }
484
- // → model.metadataJson returns { icon: 'presentation', ... } (typed, cached)
485
- for (const { fieldName, subSchema } of jsonSubFields) {
486
- const getterName = `${fieldName}Json`;
487
- const cacheKey = `__${fieldName}JsonCache`;
488
- Object.defineProperty(ModelClass.prototype, getterName, {
489
- get() {
490
- const raw = this[fieldName];
491
- // Cache check: same raw value → same parsed result
492
- const cache = this[cacheKey];
493
- if (cache && cache.raw === raw)
494
- return cache.parsed;
495
- // Parse: handle string (from DB/wire), object (already parsed), null/undefined
496
- let input;
497
- try {
498
- if (typeof raw === 'string') {
499
- input = JSON.parse(raw);
500
- }
501
- else if (raw && typeof raw === 'object') {
502
- input = raw;
503
- }
504
- else {
505
- input = {};
506
- }
507
- }
508
- catch {
509
- input = {};
510
- }
511
- // Apply Zod parse for type coercion + defaults. safeParse so
512
- // malformed metadata doesn't crash — falls back to all defaults.
513
- const result = subSchema.safeParse(input);
514
- const parsed = result.success ? result.data : subSchema.safeParse({}).data ?? {};
515
- this[cacheKey] = { raw, parsed };
516
- return parsed;
517
- },
518
- enumerable: true,
519
- configurable: true,
520
- });
521
- }
522
- // Install schema-declared computed getters on the prototype.
523
- // Each getter receives `this` (the model instance) and returns the computed value.
524
- if (computed) {
525
- for (const [name, fn] of Object.entries(computed)) {
526
- Object.defineProperty(ModelClass.prototype, name, {
527
- get() {
528
- return fn(this);
529
- },
530
- enumerable: true,
531
- configurable: true,
532
- });
533
- }
534
- }
535
- return ModelClass;
536
- }
537
- const LOG_LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
538
- /**
539
- * Resolve the effective level. Precedence: explicit `logLevel` option →
540
- * `debug: true` (⇒ debug) → `ABLO_LOG_LEVEL` env → default `warn`. `debug: false`
541
- * / omitted just means "don't raise the level" — it falls through to env/default
542
- * rather than force-silencing an ops-set env override.
543
- */
544
- function resolveLogLevel(opts) {
545
- if (opts?.logLevel && opts.logLevel in LOG_LEVEL_RANK)
546
- return opts.logLevel;
547
- if (opts?.debug === true)
548
- return 'debug';
549
- // `globalThis.process` guard keeps this safe in browser/edge runtimes that
550
- // have no `process` binding — there we fall through to the default.
551
- const raw = globalThis.process?.env?.ABLO_LOG_LEVEL;
552
- const normalized = raw?.toLowerCase();
553
- if (normalized && normalized in LOG_LEVEL_RANK)
554
- return normalized;
555
- return 'warn';
556
- }
557
- /**
558
- * Build the default logger, gated at `level` and prefixed `[Ablo]` so a creator
559
- * with a console full of other tools' logs can see at a glance what's ours.
560
- */
561
- function createConsoleLogger(level) {
562
- const threshold = LOG_LEVEL_RANK[level];
563
- const emit = (lvl, fn, args) => {
564
- if (typeof console === 'undefined' || LOG_LEVEL_RANK[lvl] < threshold)
565
- return;
566
- fn('[Ablo]', ...args);
567
- };
568
- return {
569
- debug: (...args) => emit('debug', console.debug, args),
570
- info: (...args) => emit('info', console.info, args),
571
- warn: (...args) => emit('warn', console.warn, args),
572
- error: (...args) => emit('error', console.error, args),
573
- };
574
- }
575
54
  // `readProcessEnv` lives in `./auth` alongside the other resolvers
576
55
  // that read it. Re-exported there for use elsewhere in the file.
577
- // ── Default mutation executor (wire: `commit` frame over WebSocket) ──────
578
- /**
579
- * Derive a stable `Idempotency-Key` from the batch's operation set.
580
- *
581
- * Retries of the same batch compute the same key — a reconnecting
582
- * client that rebuilds the identical mutations from its offline queue
583
- * sends the identical key, so the server's `mutation_log` replay path
584
- * returns the cached response instead of re-executing the mutators.
585
- *
586
- * Content-addressed: sort operations by (model, id, type) then sha256
587
- * the serialized form. Separator-safe — adjacent fields are delimited
588
- * by a character (`\x1e`, the ASCII record separator) that cannot
589
- * appear in a JSON string literal. Output length is 70 chars — safely
590
- * under Stripe's documented 255-char cap.
591
- *
592
- * Uses the Web Crypto API (cross-runtime: Node 20+ and browsers), same
593
- * primitive as the offline queue's AES-GCM encryption.
594
- *
595
- * @internal — exported as unexported file-local; callers go through
596
- * the executor's own `Idempotency-Key` plumbing.
597
- */
598
- async function deriveOperationsIdempotencyKey(operations) {
599
- const normalized = [...operations]
600
- .map((op) => ({
601
- type: op.type,
602
- model: op.model,
603
- id: op.id,
604
- input: op.input ?? null,
605
- }))
606
- .sort((a, b) => {
607
- if (a.model !== b.model)
608
- return a.model < b.model ? -1 : 1;
609
- if (a.id !== b.id)
610
- return a.id < b.id ? -1 : 1;
611
- return a.type < b.type ? -1 : a.type > b.type ? 1 : 0;
612
- });
613
- const encoded = new TextEncoder().encode(JSON.stringify(normalized));
614
- const digest = await crypto.subtle.digest('SHA-256', encoded);
615
- const bytes = new Uint8Array(digest);
616
- let hex = '';
617
- for (let i = 0; i < bytes.length; i++) {
618
- hex += bytes[i].toString(16).padStart(2, '0');
619
- }
620
- return `batch-${hex}`;
621
- }
622
- /**
623
- * Default mutation executor: sends `{ type: 'commit', payload: ... }` over
624
- * the sync engine's own WebSocket.
625
- *
626
- * Transport ownership follows the Zero / Liveblocks pattern — the engine
627
- * owns its socket end-to-end and the executor is internal. Apps pass URLs
628
- * and auth; they do NOT inject transport callbacks. That's why this
629
- * factory takes a `getWs` closure instead of a full SyncWebSocket: the WS
630
- * doesn't exist when the executor is constructed (it's created later in
631
- * `Ablo` during `BaseSyncedStore` init), so we resolve it
632
- * lazily at commit time. Same trick Zero uses internally — see
633
- * `packages/zero-client/src/client/zero.ts` where `Pusher`/`Puller` are
634
- * constructed before the socket then wired up at connect time.
635
- *
636
- * `options.idempotencyKey` becomes the wire-level `clientTxId` when set,
637
- * matching Stripe-style retry semantics. Otherwise the SDK generates one.
638
- */
639
- function createDefaultMutationExecutor(getWs) {
640
- async function commit(operations, options) {
641
- const ws = getWs();
642
- if (!ws?.sendCommit) {
643
- throw new AbloConnectionError('SyncWebSocket not ready for commit. The engine must finish bootstrap ' +
644
- 'before mutations can be sent.', { code: 'ws_not_ready' });
645
- }
646
- const clientTxId = options?.idempotencyKey ??
647
- (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
648
- ? crypto.randomUUID()
649
- : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
650
- try {
651
- return await ws.sendCommit(operations, clientTxId, undefined, // use sendCommit's built-in 15s default; no per-call override
652
- options?.causedByTaskId, options?.reads);
653
- }
654
- catch (err) {
655
- // Wrap transport-level failures as connection errors so the
656
- // TransactionQueue's retry classifier treats them as transient
657
- // (matches the old HTTP path's network-error handling).
658
- if (err instanceof AbloError)
659
- throw err;
660
- if (err instanceof Error) {
661
- if (/not connected|timed out|connection|ECONN/i.test(err.message)) {
662
- const wrapped = new AbloConnectionError(err.message, { cause: err });
663
- // Preserve any `diagnostics` snapshot the underlying SyncWebSocket
664
- // attached to the rejection. Without this, the wrapped error
665
- // bottoms out at "AbloConnectionError: not connected" with no
666
- // attribution to which close code / heartbeat trip / session
667
- // error caused it. See SyncWebSocket.notConnectedError().
668
- if (err &&
669
- typeof err === 'object' &&
670
- 'diagnostics' in err &&
671
- err.diagnostics) {
672
- wrapped.diagnostics = err.diagnostics;
673
- }
674
- throw wrapped;
675
- }
676
- }
677
- throw err;
678
- }
679
- }
680
- return {
681
- commit,
682
- executeCreate: (model, id, input, _txId, options) => commit([{ type: 'CREATE', model: model.toLowerCase(), id, input }], options).then(() => { }),
683
- executeUpdate: (model, id, data, _txId, options) => commit([{ type: 'UPDATE', model: model.toLowerCase(), id, input: data }], options),
684
- executeDelete: (model, id, _txId, options) => commit([{ type: 'DELETE', model: model.toLowerCase(), id }], options).then(() => { }),
685
- executeArchive: (model, id, _txId, options) => commit([{ type: 'ARCHIVE', model: model.toLowerCase(), id }], options).then(() => { }),
686
- executeUnarchive: (model, id, _txId, options) => commit([{ type: 'UNARCHIVE', model: model.toLowerCase(), id }], options).then(() => { }),
687
- };
688
- }
689
- // ── Default mutation dispatcher (for offline flush) ───────────────────────
690
- function createDefaultMutationDispatcher(executor) {
691
- return {
692
- async dispatch(opName, variables) {
693
- const prefixes = ['Create', 'Update', 'Delete', 'Archive', 'Unarchive'];
694
- for (const prefix of prefixes) {
695
- if (opName.startsWith(prefix)) {
696
- const model = opName.slice(prefix.length);
697
- const v = variables;
698
- const input = (prefix === 'Create' || prefix === 'Update')
699
- ? v.input
700
- : undefined;
701
- await executor.commit([{
702
- type: prefix.toUpperCase(),
703
- model: model.toLowerCase(),
704
- id: v.id ?? '',
705
- input,
706
- }]);
707
- return;
708
- }
709
- }
710
- },
711
- };
712
- }
713
56
  // ── Auth normalization ─────────────────────────────────────────────────────
714
57
  /**
715
58
  * The one resolver the credential lifecycle needs: an async `() => token | null`,
@@ -748,7 +91,9 @@ export function Ablo(options) {
748
91
  // refresh timer + wake/online/focus triggers. Null for the common static
749
92
  // `apiKey` path — no refresh needed.
750
93
  const credentialResolver = resolveCredentialResolver(configuredApiKey);
751
- const authCredentials = createAuthCredentialSource(internalOptions.capabilityToken ?? configuredAuthToken);
94
+ const authCredentials = createAuthCredentialSource(
95
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- load-bearing on the self-hosted path; server-internal cap-mint (Phase 3) not shipped
96
+ internalOptions.capabilityToken ?? configuredAuthToken);
752
97
  const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
753
98
  assertBrowserSafety({
754
99
  apiKey: configuredApiKey,
@@ -761,9 +106,9 @@ export function Ablo(options) {
761
106
  createConsoleLogger(resolveLogLevel({ debug: options.debug, logLevel: options.logLevel }));
762
107
  // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
763
108
  // passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
764
- warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
765
- warnIfDatabaseUrlDeprecated(authInput, (m) => logger.warn(m));
766
- void warnIfCliKeyMismatch(authInput, (m) => logger.warn(m));
109
+ warnIfDatabaseUrlEnvIgnored(authInput, (m) => { logger.warn(m); });
110
+ warnIfDatabaseUrlDeprecated(authInput, (m) => { logger.warn(m); });
111
+ void warnIfCliKeyMismatch(authInput, (m) => { logger.warn(m); });
767
112
  const schema = options.schema;
768
113
  const url = resolveBaseURL(authInput);
769
114
  // 1. Derive config from schema
@@ -802,7 +147,6 @@ export function Ablo(options) {
802
147
  const ws = storeHolder.store?.getSyncWebSocket() ?? null;
803
148
  return ws;
804
149
  });
805
- const dispatcher = internalOptions.mutationDispatcher ?? createDefaultMutationDispatcher(executor);
806
150
  // 3. Initialize SDK context (one call — hides all DI wiring).
807
151
  // Each provider can be overridden individually; the noop defaults
808
152
  // are preserved for the zero-config consumer path.
@@ -817,15 +161,13 @@ export function Ablo(options) {
817
161
  : browserOnlineStatus),
818
162
  config,
819
163
  mutationExecutor: executor,
820
- mutationDispatcher: dispatcher,
821
164
  });
822
165
  // 4. Create internal components (user never sees these). See
823
166
  // `./createInternalComponents.ts` for the construction order
824
167
  // and what each component does. Model registration happens
825
- // here because `registerModelsFromSchema` lives in this file —
826
- // the schema-to-Model-class translation depends on private
827
- // helpers (`createDynamicModelClass`, `unwrapZodType`, etc.)
828
- // that aren't worth pulling into the components module.
168
+ // here (via `registerModelsFromSchema`, in `./modelRegistration.ts`)
169
+ // because the schema-to-Model-class translation is client-construction
170
+ // wiring that isn't worth pulling into the components module.
829
171
  const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({
830
172
  schema,
831
173
  url,
@@ -855,9 +197,34 @@ export function Ablo(options) {
855
197
  // timer + wake/online/focus re-mint). Installed once here so refresh works for
856
198
  // ANY consumer of `Ablo({ auth })` — not only those who render `<AbloProvider>`.
857
199
  // The first mint happens in `ready()` so the first connection carries a token.
200
+ //
201
+ // Long-lived server clients also get the pre-roll TIMER on windowless hosts
202
+ // (`proactiveInNode`): their socket must renew its `rk_`/`ek_` BEFORE the
203
+ // hub's keepalive reaper closes it (4001 `credential_expired`). Two signals
204
+ // qualify — agent/system participants (the axis `createConnectionManager`
205
+ // gates on; `kind` is deprecated but still what agent runtimes pass today),
206
+ // and an ABSOLUTE endpoint-string `apiKey` (a relative one can't fetch in
207
+ // Node at all, so an absolute URL is unambiguously a deliberate server
208
+ // client, kind or no kind). User-kind clients in Node (an SSR/RSC module
209
+ // eval of scaffolded browser code) stay reactive-only.
858
210
  if (credentialResolver) {
859
- store.startCredentialLifecycle(credentialResolver);
211
+ const rawEndpoint = internalOptions.authEndpoint ?? internalOptions.apiKey;
212
+ const absoluteEndpoint = typeof rawEndpoint === 'string' && /^https?:\/\//i.test(rawEndpoint);
213
+ store.startCredentialLifecycle(credentialResolver, {
214
+ /* eslint-disable @typescript-eslint/no-deprecated -- `kind` gates the self-hosted proactive pre-roll; hosted path derives it from the apiKey scope */
215
+ proactiveInNode: internalOptions.kind === 'agent' ||
216
+ internalOptions.kind === 'system' ||
217
+ absoluteEndpoint,
218
+ /* eslint-enable @typescript-eslint/no-deprecated */
219
+ });
860
220
  }
221
+ // Put the lazy-query lane on the SAME auth-recovery backbone as the WS probe
222
+ // and the proactive pre-roll: a 401 on `/sync/query` re-mints via the store's
223
+ // single-flight lifecycle and replays once, instead of silently returning
224
+ // empty rows against an expired `ek_` until the next proactive tick (the
225
+ // "Could not load documents — apikey_expired" wedge). Late-bound because the
226
+ // coordinator is constructed before the store exists.
227
+ hydration.setCredentialRecovery((recovery) => store.recoverFromAuthRejection(recovery));
861
228
  // Wire the store back into the default executor's lazy getter (see
862
229
  // `storeHolder` above). The executor was constructed before the store
863
230
  // existed; this late binding closes the loop so commits dispatch over
@@ -883,10 +250,13 @@ export function Ablo(options) {
883
250
  // The server stamps `isAgent` on outbound presence frames from the
884
251
  // connection's authenticated identity prefix, but the local `self`
885
252
  // entry uses the kind we know at construction.
886
- const participantId = (internalOptions.kind === 'agent' ? internalOptions.agentId : internalOptions.user?.id) ?? '';
253
+ const participantId =
254
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- self-hosted identity fallback; hosted path derives identity from the apiKey scope
255
+ (internalOptions.kind === 'agent' ? internalOptions.agentId : internalOptions.user?.id) ?? '';
887
256
  const presenceStream = createPresenceStream({
888
257
  participantId,
889
258
  syncGroups: internalOptions.syncGroups ?? [],
259
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- local self-entry kind; server re-stamps isAgent from the authenticated identity
890
260
  isAgent: internalOptions.kind === 'agent',
891
261
  });
892
262
  const claimStream = createClaimStream({ participantId });
@@ -900,6 +270,7 @@ export function Ablo(options) {
900
270
  // 6. Validate options up front — fail loudly on obviously wrong inputs so
901
271
  // strangers don't get silent empty results. Validation errors are written
902
272
  // into `store.syncStatus` (the single source of truth).
273
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- self-hosted default; hosted path ignores it (server derives kind from the apiKey scope)
903
274
  const kind = internalOptions.kind ?? 'user';
904
275
  const _validationError = validateAbloOptions({
905
276
  options: internalOptions,
@@ -918,6 +289,7 @@ export function Ablo(options) {
918
289
  // trusting them is the trap (you think you're an agent; the key says user).
919
290
  // Warn loudly rather than removing the fields — `agentId` is still load-bearing
920
291
  // on the self-hosted path (no apiKey; paired with `capabilityToken`).
292
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- reads the deprecated fields precisely to warn callers off them under a configured apiKey
921
293
  if (configuredApiKey && (internalOptions.kind || internalOptions.agentId)) {
922
294
  logger.warn('Ablo: `kind` / `agentId` are ignored when an `apiKey` is configured — ' +
923
295
  'the server derives participant identity from the key’s scope. Remove ' +
@@ -1274,10 +646,12 @@ export function Ablo(options) {
1274
646
  }
1275
647
  };
1276
648
  const onAbort = () => {
1277
- finish(() => reject(new AbloConnectionError('Claim wait aborted.', {
1278
- code: 'claim_wait_aborted',
1279
- cause: options?.signal?.reason,
1280
- })));
649
+ finish(() => {
650
+ reject(new AbloConnectionError('Claim wait aborted.', {
651
+ code: 'claim_wait_aborted',
652
+ cause: options?.signal?.reason,
653
+ }));
654
+ });
1281
655
  };
1282
656
  if (options?.signal?.aborted) {
1283
657
  onAbort();
@@ -1287,7 +661,9 @@ export function Ablo(options) {
1287
661
  options?.signal?.addEventListener('abort', onAbort, { once: true });
1288
662
  if (options?.timeout != null) {
1289
663
  timeoutId = setTimeout(() => {
1290
- finish(() => reject(claimedError(target, listModelClaims(target), 'model_claimed_timeout')));
664
+ finish(() => {
665
+ reject(claimedError(target, listModelClaims(target), 'model_claimed_timeout'));
666
+ });
1291
667
  }, options.timeout);
1292
668
  }
1293
669
  });
@@ -1387,7 +763,7 @@ export function Ablo(options) {
1387
763
  entities: { [modelKey]: id },
1388
764
  }),
1389
765
  queue: (target) => publicClaims.queueFor({ type: target.model, id: target.id }),
1390
- reorder: (target, order) => publicClaims.reorder({ type: target.model, id: target.id }, order),
766
+ reorder: (target, order) => { publicClaims.reorder({ type: target.model, id: target.id }, order); },
1391
767
  state: (target) => {
1392
768
  // The live claim stream only tracks *open* (active) claims;
1393
769
  // terminal states (committed / expired / canceled) drop out of
@@ -1665,7 +1041,7 @@ export function Ablo(options) {
1665
1041
  // the server then denies. See `MintSessionContext`.
1666
1042
  modelTypenames: Object.fromEntries(Object.entries(schema.models).map(([key, def]) => [
1667
1043
  key,
1668
- def.typename ?? key,
1044
+ (def).typename ?? key,
1669
1045
  ])),
1670
1046
  };
1671
1047
  }