@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,468 @@
1
+ /**
2
+ * HydrationCoordinator — the lazy-load lane of the sync engine.
3
+ *
4
+ * Bridges "I need this entity but bootstrap didn't fetch it" → pool
5
+ * hydration. Replaces the per-app loader files (documentLoaders,
6
+ * slideLayerLoaders, layoutLoaders, ensureVaultFiles, ensureDataroomFiles)
7
+ * with one engine-level path.
8
+ *
9
+ * Lookup order on `fetch(modelName, where)`:
10
+ * 1. ObjectPool — if rows already match the where, return them (cheap).
11
+ * 2. IndexedDB — if matching rows exist locally, hydrate pool, return.
12
+ * 3. Network — `postQuery` against `/sync/query`, hydrate pool + IDB.
13
+ *
14
+ * Single-flight dedup: concurrent calls with the same query key share
15
+ * one in-flight promise. Prevents the loader anti-pattern where N
16
+ * components mount and fire N identical hydrations on first paint.
17
+ *
18
+ * The coordinator does NOT replace bootstrap (full sync of `instant`
19
+ * models) or live deltas (WS push). It only fills the gap for `lazy`
20
+ * models accessed by id/where after the engine is ready.
21
+ */
22
+ import { ModelScope } from '../ObjectPool.js';
23
+ import { postQuery } from '../query/client.js';
24
+ export class HydrationCoordinator {
25
+ opts;
26
+ inFlight = new Map();
27
+ capabilityTokenProvider = null;
28
+ constructor(opts) {
29
+ this.opts = opts;
30
+ this.capabilityTokenProvider = opts.getCapabilityToken ?? null;
31
+ }
32
+ /**
33
+ * Late-bind the capability token getter. Used by `Ablo.ts` to wire
34
+ * the token closure after the coordinator is constructed (the token
35
+ * isn't known until auth resolves, which happens after component
36
+ * construction). Browser consumers ride session cookies and don't
37
+ * need this; Node consumers (agent-worker) MUST call it or HTTP
38
+ * queries fail with 401 because cookies aren't available.
39
+ */
40
+ setCapabilityTokenProvider(provider) {
41
+ this.capabilityTokenProvider = provider;
42
+ }
43
+ resolveToken() {
44
+ return this.capabilityTokenProvider?.() ?? undefined;
45
+ }
46
+ /**
47
+ * Fetch matching rows for a model, hydrating the pool from IDB or
48
+ * network if not already present. Idempotent and single-flight
49
+ * deduped on the (modelName, where, orderBy, limit) tuple.
50
+ */
51
+ async fetch(modelName, options) {
52
+ const typename = this.resolveTypename(modelName);
53
+ const ModelClass = this.opts.registry.getModelByName(typename)
54
+ ?? this.opts.registry.getModelByName(modelName);
55
+ if (!ModelClass) {
56
+ throw new Error(`HydrationCoordinator.fetch: unknown model "${modelName}" — ` +
57
+ `not registered in the schema.`);
58
+ }
59
+ const clauses = normalizeWhere(options?.where);
60
+ const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit);
61
+ // Single-flight: an identical hydration is already in flight.
62
+ const inFlight = this.inFlight.get(queryKey);
63
+ if (inFlight)
64
+ return inFlight;
65
+ const work = this.runFetch(modelName, typename, ModelClass, clauses, options);
66
+ this.inFlight.set(queryKey, work);
67
+ work.finally(() => {
68
+ this.inFlight.delete(queryKey);
69
+ });
70
+ return work;
71
+ }
72
+ async runFetch(modelName, typename, ModelClass, clauses, options) {
73
+ const wantsComplete = (options?.type ?? 'complete') === 'complete';
74
+ // Step 1 — pool hit. Skip when caller asked for complete:
75
+ // they want server-confirmed state, not a stale pool snapshot.
76
+ if (!wantsComplete) {
77
+ const fromPool = scanPool(this.opts.objectPool, ModelClass, clauses);
78
+ if (fromPool.length > 0)
79
+ return applyLimit(fromPool, options?.limit);
80
+ }
81
+ // Step 2 — IndexedDB. Survives reload + offline.
82
+ const fromIdb = await scanIdb(this.opts.database, typename, clauses);
83
+ const idbModels = fromIdb
84
+ .map((raw) => this.hydrateOne(raw, typename))
85
+ .filter((m) => m !== null);
86
+ if (idbModels.length > 0) {
87
+ this.opts.objectPool.addBatch(idbModels, ModelScope.live);
88
+ if (!wantsComplete)
89
+ return applyLimit(idbModels, options?.limit);
90
+ }
91
+ // Step 3 — network. Last resort. Always runs when wantsComplete.
92
+ const networkRows = await this.queryNetwork(modelName, clauses, options);
93
+ const networkModels = networkRows
94
+ .map((raw) => this.hydrateOne(raw, typename))
95
+ .filter((m) => m !== null);
96
+ if (networkModels.length > 0) {
97
+ this.opts.objectPool.addBatch(networkModels, ModelScope.live);
98
+ // Background IDB write — don't block the caller.
99
+ void this.persistToIdb(modelName, networkRows);
100
+ }
101
+ return applyLimit(networkModels.length > 0 ? networkModels : idbModels, options?.limit);
102
+ }
103
+ hydrateOne(raw, typename) {
104
+ if (!raw || typeof raw !== 'object')
105
+ return null;
106
+ const obj = raw;
107
+ if (typeof obj.id !== 'string')
108
+ return null;
109
+ if (this.opts.objectPool.has(obj.id)) {
110
+ // Pool already has this entity, but the row coming from the
111
+ // network is the freshest server-confirmed state. Apply the new
112
+ // fields onto the existing instance instead of returning the
113
+ // stale model verbatim — otherwise a `load()` that re-fetches
114
+ // after a missed delta (WS dropped, tab slept, redeploy) silently
115
+ // discards the fresh state and the consumer keeps seeing the
116
+ // birth-time snapshot forever. `updateFromData` is the same
117
+ // primitive `ObjectPool.upsert()` uses for delta application,
118
+ // so the behaviour matches "delta-applied" semantics exactly.
119
+ const existing = this.opts.objectPool.get(obj.id);
120
+ if (existing) {
121
+ const stamped = this.stampTypename(obj, typename);
122
+ existing.updateFromData(stamped);
123
+ return existing;
124
+ }
125
+ return null;
126
+ }
127
+ // Stamp the known relation typename onto the row when the source
128
+ // (IndexedDB rows, sometimes network rows) didn't carry one. Without
129
+ // this, ObjectPool.createFromData falls through to the 'Unknown'
130
+ // model-name branch and emits the
131
+ // "ObjectPool.createFromData: No model identifier found" warning,
132
+ // failing to hydrate the entity from cache (network path then has to
133
+ // re-populate it). The typename comes from the schema relation
134
+ // (`'SlideLayer'`, `'SlideLayoutLayer'`, etc.) so no guessing involved.
135
+ const stamped = this.stampTypename(obj, typename);
136
+ return this.opts.objectPool.createFromData(stamped);
137
+ }
138
+ /**
139
+ * Stamp `__typename` onto a row when it's known (from the schema's
140
+ * relation target). Strips the mangled `_Typename` key the
141
+ * `postgres.camel` driver leaves behind when the server's SQL
142
+ * bakes `__typename` into a JSONB literal — the driver's
143
+ * snake↔camel transform misreads `__typename` as `_typename` with
144
+ * a leading underscore and produces `_Typename`. ObjectPool only
145
+ * recognises `__typename`, so without this step nested rows fall
146
+ * through to the 'Unknown' branch and never instantiate.
147
+ */
148
+ stampTypename(item, typename) {
149
+ if (!item || typeof item !== 'object' || !typename)
150
+ return item;
151
+ const obj = item;
152
+ if (obj.__typename === typename)
153
+ return obj;
154
+ const { _Typename: _drop, ...rest } = obj;
155
+ void _drop;
156
+ return { __typename: typename, ...rest };
157
+ }
158
+ async queryNetwork(modelName, clauses, options) {
159
+ const typename = this.resolveTypename(modelName);
160
+ const orderEntries = options?.orderBy ? Object.entries(options.orderBy) : [];
161
+ const firstOrder = orderEntries[0];
162
+ const query = {
163
+ model: typename,
164
+ where: clauses.map((c) => columnizeClause(c)),
165
+ ...(firstOrder
166
+ ? {
167
+ orderBy: columnize(firstOrder[0]),
168
+ order: firstOrder[1] ?? 'asc',
169
+ }
170
+ : {}),
171
+ ...(options?.limit ? { limit: options.limit } : {}),
172
+ ...(options?.expand && options.expand.length > 0
173
+ ? { related: options.expand }
174
+ : {}),
175
+ };
176
+ const result = await postQuery({
177
+ baseUrl: this.opts.baseUrl,
178
+ capabilityToken: this.resolveToken(),
179
+ }, { queries: [query] });
180
+ const rows = Array.isArray(result.results[0]) ? result.results[0] : [];
181
+ // Normalize: wire rows lack `__typename` when the server elides it.
182
+ const normalized = rows.map((row) => {
183
+ if (row && typeof row === 'object' && !('__typename' in row)) {
184
+ return { __typename: typename, ...row };
185
+ }
186
+ return row;
187
+ });
188
+ // Expand: server returns related entities nested under each row
189
+ // (`row.layers = [{...}, ...]`). Walk the nested shape, stamp the
190
+ // typename from the schema's relation metadata (the server bakes
191
+ // `__typename` into the JSONB but the postgres.camel driver
192
+ // mangles it to `_Typename` mid-flight, so client-side stamping
193
+ // is the only reliable path), hydrate each related row into its
194
+ // own typed pool, then leave the nested arrays in place on the
195
+ // primary row.
196
+ if (options?.expand && options.expand.length > 0) {
197
+ this.hydrateExpanded(modelName, normalized, options.expand);
198
+ }
199
+ return normalized;
200
+ }
201
+ /**
202
+ * Hydrate nested expanded rows. Resolves each relation's target
203
+ * typename via the schema and stamps `__typename` on every nested
204
+ * row before passing to `hydrateOne` — the server's JSONB
205
+ * `__typename` field gets mangled by `postgres.camel` (`__typename`
206
+ * → `_Typename`), so the SDK can't trust whatever string lands.
207
+ */
208
+ hydrateExpanded(parentModelName, rows, relationNames) {
209
+ const schemaModels = this.opts.schema.models;
210
+ const parentDef = schemaModels?.[parentModelName];
211
+ for (const row of rows) {
212
+ if (!row || typeof row !== 'object')
213
+ continue;
214
+ const obj = row;
215
+ for (const rel of relationNames) {
216
+ const nested = obj[rel];
217
+ if (!nested)
218
+ continue;
219
+ // Resolve target typename via parent's relations map.
220
+ const relDef = parentDef?.relations?.[rel];
221
+ const targetKey = relDef?.target;
222
+ const targetTypename = targetKey ? this.resolveTypename(targetKey) : undefined;
223
+ const items = Array.isArray(nested) ? nested : [nested];
224
+ const models = [];
225
+ for (const item of items) {
226
+ const stamped = this.stampTypename(item, targetTypename);
227
+ const m = this.hydrateOne(stamped);
228
+ if (m)
229
+ models.push(m);
230
+ }
231
+ if (models.length > 0) {
232
+ this.opts.objectPool.addBatch(models, ModelScope.live);
233
+ }
234
+ }
235
+ }
236
+ }
237
+ async persistToIdb(modelName, rows) {
238
+ const store = this.opts.database.getStore(this.resolveTypename(modelName));
239
+ if (!store)
240
+ return;
241
+ for (const row of rows) {
242
+ try {
243
+ await store.put(row);
244
+ }
245
+ catch {
246
+ // IDB writes are best-effort — a transient quota/transaction
247
+ // failure shouldn't break the hydration's primary purpose.
248
+ }
249
+ }
250
+ }
251
+ resolveTypename(modelName) {
252
+ // Schema is the source of truth for wire typenames. The model proxy
253
+ // is keyed by camelCase plural (`slideLayers`) but the wire query +
254
+ // ObjectPool typeIndex use the typename (`SlideLayer`).
255
+ const def = this.opts.schema
256
+ .models?.[modelName];
257
+ return def?.typename ?? modelName;
258
+ }
259
+ }
260
+ // ── Helpers ────────────────────────────────────────────────────────────
261
+ function stableKey(modelName, clauses, orderBy, limit) {
262
+ // Sort clauses by their stringified form so caller order doesn't
263
+ // produce different dedup keys for semantically identical queries.
264
+ const sorted = [...clauses].map((c) => [...c]).sort((a, b) => {
265
+ const ka = JSON.stringify(a);
266
+ const kb = JSON.stringify(b);
267
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
268
+ });
269
+ return JSON.stringify({ modelName, where: sorted, orderBy, limit });
270
+ }
271
+ function applyLimit(arr, limit) {
272
+ return typeof limit === 'number' ? arr.slice(0, limit) : arr;
273
+ }
274
+ function scanPool(pool, ModelClass, clauses) {
275
+ const all = pool.getByType(ModelClass);
276
+ if (clauses.length === 0)
277
+ return all;
278
+ return all.filter((entity) => matchesClauses(entity, clauses));
279
+ }
280
+ async function scanIdb(database, modelName, clauses) {
281
+ const store = database.getStore(modelName);
282
+ if (!store)
283
+ return [];
284
+ // Fast path: a single equality `id` lookup hits the primary key.
285
+ const eqClauses = extractEqClauses(clauses);
286
+ if (clauses.length === 1 && eqClauses.id !== undefined && typeof eqClauses.id === 'string') {
287
+ try {
288
+ const row = await store.get(eqClauses.id);
289
+ return row ? [row] : [];
290
+ }
291
+ catch {
292
+ return [];
293
+ }
294
+ }
295
+ // Index-aware path: when every clause is equality and exactly one
296
+ // non-id string column is constrained, hit that column's index for
297
+ // an O(matches) read. Anything involving LIKE/ILIKE/ranges falls
298
+ // through to full-scan + filter.
299
+ if (clausesAreAllEquality(clauses)) {
300
+ const indexedKeys = Object.keys(eqClauses).filter((k) => k !== 'id' && typeof eqClauses[k] === 'string');
301
+ if (indexedKeys.length === 1) {
302
+ const idxKey = indexedKeys[0];
303
+ try {
304
+ const rows = await store.getAllFromIndex(idxKey, eqClauses[idxKey]);
305
+ if (Array.isArray(rows)) {
306
+ return rows.filter((r) => matchesClauses(r, clauses));
307
+ }
308
+ }
309
+ catch {
310
+ // index doesn't exist — fall through to full-scan path.
311
+ }
312
+ }
313
+ }
314
+ try {
315
+ const rows = await store.getAll();
316
+ return Array.isArray(rows)
317
+ ? rows.filter((r) => matchesClauses(r, clauses))
318
+ : [];
319
+ }
320
+ catch {
321
+ return [];
322
+ }
323
+ }
324
+ /**
325
+ * Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
326
+ * tuple form used throughout `runFetch`. Tuple inputs pass through; object
327
+ * inputs become one `['col', '=', val]` or `['col', 'IN', vals]` per key.
328
+ *
329
+ * Detection: an array whose first element is itself an array is treated
330
+ * as tuple form. Object form is the fallback.
331
+ *
332
+ * Exported so callers can pre-normalize (e.g., for tests, or to inspect
333
+ * the canonical clauses before passing them to `load`/`subscribe`).
334
+ */
335
+ export function normalizeWhere(where) {
336
+ if (where == null)
337
+ return [];
338
+ if (Array.isArray(where)) {
339
+ // Tuple form — assumed to already use server-side column names.
340
+ return where;
341
+ }
342
+ if (typeof where === 'object') {
343
+ const obj = where;
344
+ return Object.entries(obj).map(([key, value]) => {
345
+ if (Array.isArray(value)) {
346
+ return [key, 'IN', value];
347
+ }
348
+ return [key, value];
349
+ });
350
+ }
351
+ return [];
352
+ }
353
+ /**
354
+ * Apply `columnize` to the column name of a wire-bound clause so the
355
+ * server sees `slide_id` instead of `slideId`. Tuple-form clauses from
356
+ * callers are passed through unchanged — they already supply the wire
357
+ * column name (matches what existing `postQuery` consumers do).
358
+ */
359
+ function columnizeClause(clause) {
360
+ const col = clause[0];
361
+ // If the column already looks snake_case (no uppercase letters), assume
362
+ // the caller is already using server-side naming. Otherwise camelize→snake.
363
+ const finalCol = /[A-Z]/.test(col) ? columnize(col) : col;
364
+ if (clause.length === 2)
365
+ return [finalCol, clause[1]];
366
+ return [finalCol, clause[1], clause[2]];
367
+ }
368
+ /** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
369
+ function extractEqClauses(clauses) {
370
+ const out = {};
371
+ for (const c of clauses) {
372
+ if (c.length === 2) {
373
+ out[c[0]] = c[1];
374
+ }
375
+ else if (c[1] === '=') {
376
+ out[c[0]] = c[2];
377
+ }
378
+ }
379
+ return out;
380
+ }
381
+ function clausesAreAllEquality(clauses) {
382
+ return clauses.every((c) => c.length === 2 || c[1] === '=');
383
+ }
384
+ /**
385
+ * Operator-aware predicate. Mirrors the server's WhereOp semantics for
386
+ * local matching against pool/IDB rows. LIKE/ILIKE use SQL wildcards
387
+ * (`%` = any chars, `_` = one char) translated to a JS regex.
388
+ *
389
+ * Exported so callers can apply the same predicate to in-memory
390
+ * collections (tests, batch operations) using the canonical clauses.
391
+ */
392
+ export function matchesClauses(entity, clauses) {
393
+ for (const clause of clauses) {
394
+ const col = clause[0];
395
+ const op = clause.length === 2 ? '=' : clause[1];
396
+ const expected = clause.length === 2 ? clause[1] : clause[2];
397
+ const v = entity[col];
398
+ if (!matchOp(v, op, expected))
399
+ return false;
400
+ }
401
+ return true;
402
+ }
403
+ function matchOp(actual, op, expected) {
404
+ switch (op) {
405
+ case '=':
406
+ return actual === expected;
407
+ case '!=':
408
+ return actual !== expected;
409
+ case '<':
410
+ return compareOrdered(actual, expected, (a, b) => a < b);
411
+ case '<=':
412
+ return compareOrdered(actual, expected, (a, b) => a <= b);
413
+ case '>':
414
+ return compareOrdered(actual, expected, (a, b) => a > b);
415
+ case '>=':
416
+ return compareOrdered(actual, expected, (a, b) => a >= b);
417
+ case 'IN':
418
+ return Array.isArray(expected) && expected.some((alt) => alt === actual);
419
+ case 'NOT IN':
420
+ return Array.isArray(expected) && !expected.some((alt) => alt === actual);
421
+ case 'IS':
422
+ // SQL `IS` is null-equality; the only meaningful right-hand side here is null.
423
+ return actual === expected;
424
+ case 'IS NOT':
425
+ return actual !== expected;
426
+ case 'LIKE':
427
+ return typeof actual === 'string' && typeof expected === 'string' && likeRegex(expected, false).test(actual);
428
+ case 'NOT LIKE':
429
+ return typeof actual === 'string' && typeof expected === 'string' && !likeRegex(expected, false).test(actual);
430
+ case 'ILIKE':
431
+ return typeof actual === 'string' && typeof expected === 'string' && likeRegex(expected, true).test(actual);
432
+ case 'NOT ILIKE':
433
+ return typeof actual === 'string' && typeof expected === 'string' && !likeRegex(expected, true).test(actual);
434
+ }
435
+ }
436
+ /**
437
+ * Ordered comparison helper. Both operands must be non-null and the same
438
+ * comparable primitive (string-vs-string or number-vs-number). Mixed
439
+ * types fall back to JS's loose ordering, which would be confusing — so
440
+ * we reject early to match SQL semantics (a NULL operand yields false).
441
+ */
442
+ function compareOrdered(actual, expected, cmp) {
443
+ if (actual == null || expected == null)
444
+ return false;
445
+ if (typeof actual === 'number' && typeof expected === 'number') {
446
+ return cmp(actual, expected);
447
+ }
448
+ if (typeof actual === 'string' && typeof expected === 'string') {
449
+ return cmp(actual, expected);
450
+ }
451
+ return false;
452
+ }
453
+ /** Translate a SQL LIKE/ILIKE pattern to a JS regex (`%` → `.*`, `_` → `.`). */
454
+ function likeRegex(pattern, insensitive) {
455
+ // Escape regex specials *except* `%` and `_`, then translate those.
456
+ const escaped = pattern.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
457
+ const body = escaped.replace(/%/g, '.*').replace(/_/g, '.');
458
+ return new RegExp(`^${body}$`, insensitive ? 'i' : '');
459
+ }
460
+ /**
461
+ * Schema fields are camelCase (`slideId`); the wire query expects
462
+ * the server-side column name. The query server's input resolver
463
+ * casing-folds, but we send snake_case to match the convention used
464
+ * by the existing loaders' postQuery calls (`'slide_id'` etc.).
465
+ */
466
+ function columnize(field) {
467
+ return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
468
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * NetworkProbe - Reliable network + session connectivity detection
3
+ *
4
+ * navigator.onLine is unreliable: it reports true whenever the device has a LAN
5
+ * connection, even without actual internet access (MDN docs confirm this).
6
+ * After laptop sleep/wake, it may report true before WiFi/DNS are functional.
7
+ *
8
+ * This module provides an authenticated probe against the sync server to verify
9
+ * real connectivity + session validity in a single round-trip. The probe hits
10
+ * `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
11
+ * upgrade path:
12
+ * 204 No Content → reachable, session cookie valid
13
+ * 401/403 → reachable, session expired or invalid
14
+ * network fail → unreachable
15
+ *
16
+ * This closes a real gap: the browser's WebSocket API hides HTTP status from
17
+ * the handshake, so a 401 on the WS upgrade surfaces only as `close code
18
+ * 1006`. Without this HTTP probe, the client cannot distinguish auth failure
19
+ * from a network blip and loops reconnecting forever instead of redirecting
20
+ * the user to sign-in.
21
+ *
22
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
23
+ */
24
+ /** Result of a network probe */
25
+ export interface ProbeResult {
26
+ /** Whether the server was reachable */
27
+ reachable: boolean;
28
+ /** Whether the session cookie is still valid (null if server unreachable) */
29
+ sessionValid: boolean | null;
30
+ /** Round-trip time in ms (null if failed) */
31
+ latencyMs: number | null;
32
+ }
33
+ /**
34
+ * Probe the sync engine server with a lightweight HEAD request.
35
+ *
36
+ * Returns reachability AND session status in a single call, so the
37
+ * ConnectionStore can make the right state transition without guessing.
38
+ *
39
+ * @param baseUrl The sync-server base URL (HTTP or WS scheme accepted).
40
+ * If omitted, falls back to `NEXT_PUBLIC_GO_SERVER_URL` →
41
+ * `http://localhost:8080` for backwards compatibility.
42
+ */
43
+ export declare function probeNetwork(baseUrl?: string): Promise<ProbeResult>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * NetworkProbe - Reliable network + session connectivity detection
3
+ *
4
+ * navigator.onLine is unreliable: it reports true whenever the device has a LAN
5
+ * connection, even without actual internet access (MDN docs confirm this).
6
+ * After laptop sleep/wake, it may report true before WiFi/DNS are functional.
7
+ *
8
+ * This module provides an authenticated probe against the sync server to verify
9
+ * real connectivity + session validity in a single round-trip. The probe hits
10
+ * `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
11
+ * upgrade path:
12
+ * 204 No Content → reachable, session cookie valid
13
+ * 401/403 → reachable, session expired or invalid
14
+ * network fail → unreachable
15
+ *
16
+ * This closes a real gap: the browser's WebSocket API hides HTTP status from
17
+ * the handshake, so a 401 on the WS upgrade surfaces only as `close code
18
+ * 1006`. Without this HTTP probe, the client cannot distinguish auth failure
19
+ * from a network blip and loops reconnecting forever instead of redirecting
20
+ * the user to sign-in.
21
+ *
22
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
23
+ */
24
+ import { getContext } from '../context.js';
25
+ import { SyncSessionError } from '../errors.js';
26
+ const PROBE_TIMEOUT_MS = 4000;
27
+ /**
28
+ * Derive the probe URL from a sync-server base URL. Accepts `ws://`,
29
+ * `wss://`, `http://`, `https://`, or a bare host — mirrors the
30
+ * normalisation in `BootstrapHelper` / `createSyncEngine`.
31
+ */
32
+ function resolveProbeUrl(baseUrl) {
33
+ // Fall back to the legacy env var so callers that haven't been migrated
34
+ // to pass an explicit baseUrl keep working.
35
+ const resolved = baseUrl ??
36
+ (typeof process !== 'undefined' ? process.env?.NEXT_PUBLIC_GO_SERVER_URL : undefined) ??
37
+ 'http://localhost:8080';
38
+ // Normalize ws → http so fetch() accepts the URL. Strip any trailing slash
39
+ // so we don't produce `//api/auth/check`.
40
+ const httpBase = resolved.replace(/^ws/, 'http').replace(/\/+$/, '');
41
+ return `${httpBase}/api/auth/check`;
42
+ }
43
+ /**
44
+ * Probe the sync engine server with a lightweight HEAD request.
45
+ *
46
+ * Returns reachability AND session status in a single call, so the
47
+ * ConnectionStore can make the right state transition without guessing.
48
+ *
49
+ * @param baseUrl The sync-server base URL (HTTP or WS scheme accepted).
50
+ * If omitted, falls back to `NEXT_PUBLIC_GO_SERVER_URL` →
51
+ * `http://localhost:8080` for backwards compatibility.
52
+ */
53
+ export async function probeNetwork(baseUrl) {
54
+ const url = resolveProbeUrl(baseUrl);
55
+ // Fast-fail: if navigator.onLine is false, skip the probe entirely.
56
+ // This is the ONE case where navigator.onLine is reliable (MDN: "false
57
+ // means definitely offline"). Use `=== false` rather than `!onLine`
58
+ // because Node 22+ exposes `navigator` with `onLine === undefined`,
59
+ // and `!undefined === true` would short-circuit the probe server-side.
60
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
61
+ return { reachable: false, sessionValid: null, latencyMs: null };
62
+ }
63
+ const controller = new AbortController();
64
+ const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
65
+ const start = performance.now();
66
+ try {
67
+ const response = await fetch(url, {
68
+ method: 'HEAD',
69
+ credentials: 'include', // Send cookies for session check
70
+ signal: controller.signal,
71
+ // Cache-bust to avoid stale responses
72
+ headers: { 'Cache-Control': 'no-cache' },
73
+ });
74
+ const latencyMs = Math.round(performance.now() - start);
75
+ if (SyncSessionError.isSessionErrorResponse(response.status)) {
76
+ // Server reachable but session expired/invalid
77
+ getContext().logger.info('[NetworkProbe] Server reachable, session expired', {
78
+ status: response.status,
79
+ latencyMs,
80
+ });
81
+ return { reachable: true, sessionValid: false, latencyMs };
82
+ }
83
+ // 2xx (including 204) means reachable + session valid.
84
+ // 3xx/4xx (non-auth) still prove connectivity even though the probe
85
+ // expected 204; log a warning so misconfigurations surface instead of
86
+ // silently passing.
87
+ if (response.status < 200 || response.status >= 300) {
88
+ getContext().logger.warn('[NetworkProbe] Unexpected probe response', {
89
+ status: response.status,
90
+ url,
91
+ latencyMs,
92
+ });
93
+ }
94
+ else {
95
+ getContext().logger.debug('[NetworkProbe] Server reachable, session valid', {
96
+ status: response.status,
97
+ latencyMs,
98
+ });
99
+ }
100
+ return { reachable: true, sessionValid: true, latencyMs };
101
+ }
102
+ catch (error) {
103
+ clearTimeout(timeout);
104
+ const isAbort = error instanceof DOMException && error.name === 'AbortError';
105
+ getContext().logger.info('[NetworkProbe] Probe failed', {
106
+ reason: isAbort ? 'timeout' : error.message,
107
+ });
108
+ return { reachable: false, sessionValid: null, latencyMs: null };
109
+ }
110
+ finally {
111
+ clearTimeout(timeout);
112
+ }
113
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * OfflineFlush — Replays queued offline mutations on reconnect.
3
+ *
4
+ * SDK-generic version: delegates to MutationDispatcher from context.
5
+ */
6
+ export declare function flushOfflineQueueOnce(): Promise<{
7
+ processed: number;
8
+ failed: number;
9
+ }>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * OfflineFlush — Replays queued offline mutations on reconnect.
3
+ *
4
+ * SDK-generic version: delegates to MutationDispatcher from context.
5
+ */
6
+ import { OfflineTransactionStore } from './OfflineTransactionStore.js';
7
+ import { getContext } from '../context.js';
8
+ let _offlineTxStore = null;
9
+ function getOfflineTxStore() {
10
+ if (!_offlineTxStore) {
11
+ _offlineTxStore = new OfflineTransactionStore();
12
+ }
13
+ return _offlineTxStore;
14
+ }
15
+ export async function flushOfflineQueueOnce() {
16
+ const store = getOfflineTxStore();
17
+ await store.init();
18
+ const dispatcher = getContext().mutationDispatcher;
19
+ return store.flush(async (tx) => {
20
+ await dispatcher.dispatch(tx.opName, tx.request.variables || {});
21
+ });
22
+ }