@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,465 @@
1
+ /**
2
+ * ConnectionManager — single source of truth for the sync engine's
3
+ * connection lifecycle. Absorbs the FSM every SDK consumer used to
4
+ * rebuild by hand (apps/web's `ConnectionStore` was the reference
5
+ * implementation — 605 LOC of FSM + watchdog + backoff).
6
+ *
7
+ * What it owns:
8
+ * - Browser online/offline + visibility events
9
+ * - Network probe orchestration (via `probeNetwork`)
10
+ * - Session-validity checks (HEAD /api/auth/check)
11
+ * - Retry backoff with ceiling, jitter, and offline-aware parking
12
+ * - Watchdog for browser events that never fire (VPN, captive portal)
13
+ * - The reconnect → bootstrap → WebSocket connect sequence
14
+ *
15
+ * What it DOES NOT own:
16
+ * - The actual bootstrap / IndexedDB / ObjectPool work — that lives in
17
+ * `BaseSyncedStore.performReconnect()`. This class calls it via the
18
+ * `onReconnect` callback and reacts to the outcome.
19
+ *
20
+ * Designed to be embedded by `BaseSyncedStore`: one instance per store,
21
+ * started on first successful connect, disposed on teardown.
22
+ *
23
+ * CONNECTED ──► OFFLINE ──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
24
+ * │ │ │
25
+ * ▼ ▼ ▼
26
+ * WAITING_FOR_NETWORK SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
27
+ *
28
+ * Includes two fixes over the original app-side FSM:
29
+ * 1. `backoff` accepts `NETWORK_ONLINE` / `TAB_VISIBLE` — jumps to
30
+ * probing immediately when the network comes back, without
31
+ * waiting for the backoff timer to elapse.
32
+ * 2. `scheduleBackoff` parks in `waiting_for_network` (resetting
33
+ * `attempt`) when `navigator.onLine === false` at max retries,
34
+ * instead of hard-reloading an already-offline browser.
35
+ */
36
+ import { makeAutoObservable, runInAction } from 'mobx';
37
+ import { getContext } from '../context.js';
38
+ import { probeNetwork } from './NetworkProbe.js';
39
+ // ─── Tunables ─────────────────────────────────────────────────────────────
40
+ const DEFAULT_BACKOFF = {
41
+ BASE_MS: 2_000,
42
+ MAX_MS: 30_000,
43
+ MAX_ATTEMPTS: 8,
44
+ JITTER: 0.15,
45
+ };
46
+ const ONLINE_DEBOUNCE_MS = 500;
47
+ const WATCHDOG_INTERVAL_MS = 30_000;
48
+ const MAX_STUCK_CYCLES_BEFORE_RELOAD = 6;
49
+ // ─── ConnectionManager ────────────────────────────────────────────────────
50
+ export class ConnectionManager {
51
+ // Observable state
52
+ state = 'connected';
53
+ offlineSince = null;
54
+ attempt = 0;
55
+ lastProbeResult = null;
56
+ // Private
57
+ callbacks = null;
58
+ backoffTimer = null;
59
+ debounceTimer = null;
60
+ watchdogTimer = null;
61
+ stuckCycles = 0;
62
+ disposed = false;
63
+ baseUrl;
64
+ backoff;
65
+ handleBrowserOnline = null;
66
+ handleBrowserOffline = null;
67
+ handleVisibilityChange = null;
68
+ constructor(options = {}) {
69
+ this.baseUrl = options.baseUrl;
70
+ this.backoff = { ...DEFAULT_BACKOFF, ...(options.backoff ?? {}) };
71
+ makeAutoObservable(this, {}, { autoBind: true });
72
+ }
73
+ // ── Lifecycle ────────────────────────────────────────────────────────
74
+ start(callbacks) {
75
+ this.callbacks = callbacks;
76
+ this.setupBrowserListeners();
77
+ this.startWatchdog();
78
+ getContext().logger.info('[ConnectionManager] Started');
79
+ }
80
+ dispose() {
81
+ this.disposed = true;
82
+ this.clearBackoffTimer();
83
+ this.clearDebounceTimer();
84
+ if (this.watchdogTimer) {
85
+ clearInterval(this.watchdogTimer);
86
+ this.watchdogTimer = null;
87
+ }
88
+ this.removeBrowserListeners();
89
+ this.callbacks = null;
90
+ getContext().logger.info('[ConnectionManager] Disposed');
91
+ }
92
+ // ── Send events ──────────────────────────────────────────────────────
93
+ send(event) {
94
+ if (this.disposed)
95
+ return;
96
+ const prevState = this.state;
97
+ const nextState = this.transition(prevState, event);
98
+ if (nextState === null)
99
+ return;
100
+ getContext().logger.info('[ConnectionManager] Transition', {
101
+ from: prevState,
102
+ to: nextState,
103
+ event: event.type,
104
+ });
105
+ runInAction(() => {
106
+ this.state = nextState;
107
+ if (prevState === 'connected' && nextState !== 'connected') {
108
+ this.offlineSince = new Date();
109
+ }
110
+ if (nextState === 'connected') {
111
+ this.offlineSince = null;
112
+ this.attempt = 0;
113
+ this.stuckCycles = 0;
114
+ }
115
+ });
116
+ // Notify the embedding store BEFORE running side effects, so the
117
+ // UI flips to the new label (e.g. "Reconnecting…") at the same
118
+ // tick the probe / backoff actually starts. Errors in the consumer
119
+ // must not break the FSM — wrap defensively.
120
+ try {
121
+ this.callbacks?.onStateChange?.(nextState, prevState);
122
+ }
123
+ catch (err) {
124
+ getContext().logger.warn('[ConnectionManager] onStateChange threw', {
125
+ error: err instanceof Error ? err.message : String(err),
126
+ });
127
+ }
128
+ this.onEnterState(nextState, event);
129
+ }
130
+ // ── Pure transition ──────────────────────────────────────────────────
131
+ transition(state, event) {
132
+ switch (state) {
133
+ case 'connected':
134
+ switch (event.type) {
135
+ case 'NETWORK_LOST':
136
+ case 'WS_DISCONNECTED':
137
+ return 'offline';
138
+ case 'WS_SESSION_ERROR':
139
+ case 'BOOTSTRAP_FAILED_SESSION':
140
+ return 'session_expired';
141
+ case 'WS_HANDSHAKE_FAILED':
142
+ return 'probing_network';
143
+ case 'TAB_VISIBLE':
144
+ return 'probing_network';
145
+ default:
146
+ return null;
147
+ }
148
+ case 'offline':
149
+ switch (event.type) {
150
+ case 'NETWORK_ONLINE':
151
+ case 'MANUAL_RETRY':
152
+ case 'TAB_VISIBLE':
153
+ case 'WS_HANDSHAKE_FAILED':
154
+ return 'probing_network';
155
+ case 'WS_SESSION_ERROR':
156
+ case 'BOOTSTRAP_FAILED_SESSION':
157
+ return 'session_expired';
158
+ default:
159
+ return null;
160
+ }
161
+ case 'probing_network':
162
+ switch (event.type) {
163
+ case 'PROBE_SUCCESS':
164
+ return event.sessionValid ? 'reconnecting' : 'session_expired';
165
+ case 'PROBE_FAILED':
166
+ return 'waiting_for_network';
167
+ case 'NETWORK_LOST':
168
+ return 'offline';
169
+ default:
170
+ return null;
171
+ }
172
+ case 'waiting_for_network':
173
+ switch (event.type) {
174
+ case 'NETWORK_ONLINE':
175
+ case 'TAB_VISIBLE':
176
+ case 'MANUAL_RETRY':
177
+ case 'BACKOFF_ELAPSED':
178
+ return 'probing_network';
179
+ case 'NETWORK_LOST':
180
+ return 'offline';
181
+ default:
182
+ return null;
183
+ }
184
+ case 'validating_session':
185
+ switch (event.type) {
186
+ case 'PROBE_SUCCESS':
187
+ return event.sessionValid ? 'reconnecting' : 'session_expired';
188
+ case 'NETWORK_LOST':
189
+ return 'offline';
190
+ default:
191
+ return null;
192
+ }
193
+ case 'reconnecting':
194
+ switch (event.type) {
195
+ case 'RECONNECT_SUCCESS':
196
+ case 'WS_CONNECTED':
197
+ return 'connected';
198
+ case 'RECONNECT_FAILED':
199
+ case 'WS_HANDSHAKE_FAILED':
200
+ return 'backoff';
201
+ case 'BOOTSTRAP_FAILED_SESSION':
202
+ case 'WS_SESSION_ERROR':
203
+ return 'session_expired';
204
+ case 'NETWORK_LOST':
205
+ return 'offline';
206
+ default:
207
+ return null;
208
+ }
209
+ case 'backoff':
210
+ switch (event.type) {
211
+ case 'BACKOFF_ELAPSED':
212
+ case 'MANUAL_RETRY':
213
+ case 'WS_HANDSHAKE_FAILED':
214
+ return 'probing_network';
215
+ case 'NETWORK_ONLINE':
216
+ case 'TAB_VISIBLE':
217
+ // Network came back while we were waiting out a backoff
218
+ // delay — jump straight to probing instead of waiting the
219
+ // full exponential interval. Fixes the "doesn't retrigger
220
+ // when internet comes back" bug.
221
+ return 'probing_network';
222
+ case 'NETWORK_LOST':
223
+ return 'offline';
224
+ case 'WS_SESSION_ERROR':
225
+ case 'BOOTSTRAP_FAILED_SESSION':
226
+ return 'session_expired';
227
+ default:
228
+ return null;
229
+ }
230
+ case 'session_expired':
231
+ return null; // terminal
232
+ default:
233
+ return null;
234
+ }
235
+ }
236
+ // ── Side effects per state ───────────────────────────────────────────
237
+ onEnterState(state, _event) {
238
+ switch (state) {
239
+ case 'connected':
240
+ this.clearBackoffTimer();
241
+ break;
242
+ case 'offline':
243
+ this.clearBackoffTimer();
244
+ this.callbacks?.onDisconnectWebSocket();
245
+ break;
246
+ case 'probing_network':
247
+ this.runProbe();
248
+ break;
249
+ case 'waiting_for_network':
250
+ this.scheduleBackoff();
251
+ break;
252
+ case 'reconnecting':
253
+ this.runReconnect();
254
+ break;
255
+ case 'backoff':
256
+ this.scheduleBackoff();
257
+ break;
258
+ case 'session_expired':
259
+ this.clearBackoffTimer();
260
+ this.callbacks?.onDisconnectWebSocket();
261
+ this.callbacks?.onSessionExpired();
262
+ getContext().observability.breadcrumb('Session expired — redirecting to signin', 'sync.offline', 'warning');
263
+ break;
264
+ }
265
+ }
266
+ // ── Async operations ─────────────────────────────────────────────────
267
+ async runProbe() {
268
+ try {
269
+ const result = await probeNetwork(this.baseUrl);
270
+ runInAction(() => {
271
+ this.lastProbeResult = result;
272
+ });
273
+ if (result.reachable) {
274
+ this.send({ type: 'PROBE_SUCCESS', sessionValid: result.sessionValid ?? true });
275
+ }
276
+ else {
277
+ this.send({ type: 'PROBE_FAILED' });
278
+ }
279
+ }
280
+ catch {
281
+ this.send({ type: 'PROBE_FAILED' });
282
+ }
283
+ }
284
+ async runReconnect() {
285
+ if (!this.callbacks)
286
+ return;
287
+ try {
288
+ const result = await this.callbacks.onReconnect();
289
+ switch (result) {
290
+ case 'success':
291
+ this.send({ type: 'RECONNECT_SUCCESS' });
292
+ break;
293
+ case 'session_error':
294
+ this.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
295
+ break;
296
+ case 'network_error':
297
+ this.send({ type: 'RECONNECT_FAILED' });
298
+ break;
299
+ }
300
+ }
301
+ catch (error) {
302
+ getContext().logger.error('[ConnectionManager] Reconnect threw', { error });
303
+ this.send({ type: 'RECONNECT_FAILED' });
304
+ }
305
+ }
306
+ // ── Backoff ──────────────────────────────────────────────────────────
307
+ scheduleBackoff() {
308
+ this.clearBackoffTimer();
309
+ if (this.attempt >= this.backoff.MAX_ATTEMPTS) {
310
+ // If still offline, a hard reload will fail or serve cached and
311
+ // we'll loop. Park in waiting_for_network and let the `online`
312
+ // event (or watchdog) restart the cycle when the network comes
313
+ // back.
314
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
315
+ getContext().logger.warn('[ConnectionManager] Max retries while offline — parking until network restored');
316
+ getContext().observability.breadcrumb('Max retries — parking offline', 'sync.offline', 'warning');
317
+ runInAction(() => {
318
+ this.attempt = 0;
319
+ });
320
+ return;
321
+ }
322
+ getContext().logger.warn('[ConnectionManager] Max retries — hard reload');
323
+ getContext().observability.breadcrumb('Max retries — hard reload', 'sync.offline', 'error');
324
+ if (typeof window !== 'undefined') {
325
+ window.location.reload();
326
+ }
327
+ return;
328
+ }
329
+ const baseDelay = Math.min(this.backoff.BASE_MS * Math.pow(2, this.attempt), this.backoff.MAX_MS);
330
+ const jitter = baseDelay * this.backoff.JITTER * (2 * Math.random() - 1);
331
+ const delay = Math.round(baseDelay + jitter);
332
+ runInAction(() => {
333
+ this.attempt++;
334
+ });
335
+ this.backoffTimer = setTimeout(() => {
336
+ this.backoffTimer = null;
337
+ this.send({ type: 'BACKOFF_ELAPSED' });
338
+ }, delay);
339
+ }
340
+ // ── Browser listeners ────────────────────────────────────────────────
341
+ setupBrowserListeners() {
342
+ if (typeof window === 'undefined')
343
+ return;
344
+ this.handleBrowserOnline = () => {
345
+ this.clearDebounceTimer();
346
+ this.debounceTimer = setTimeout(() => {
347
+ this.send({ type: 'NETWORK_ONLINE' });
348
+ }, ONLINE_DEBOUNCE_MS);
349
+ };
350
+ this.handleBrowserOffline = () => {
351
+ this.clearDebounceTimer();
352
+ this.send({ type: 'NETWORK_LOST' });
353
+ };
354
+ this.handleVisibilityChange = () => {
355
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
356
+ this.send({ type: 'TAB_VISIBLE' });
357
+ }
358
+ };
359
+ window.addEventListener('online', this.handleBrowserOnline);
360
+ window.addEventListener('offline', this.handleBrowserOffline);
361
+ if (typeof document !== 'undefined') {
362
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
363
+ }
364
+ }
365
+ removeBrowserListeners() {
366
+ if (typeof window === 'undefined')
367
+ return;
368
+ if (this.handleBrowserOnline) {
369
+ window.removeEventListener('online', this.handleBrowserOnline);
370
+ }
371
+ if (this.handleBrowserOffline) {
372
+ window.removeEventListener('offline', this.handleBrowserOffline);
373
+ }
374
+ if (this.handleVisibilityChange && typeof document !== 'undefined') {
375
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
376
+ }
377
+ }
378
+ // ── Watchdog ─────────────────────────────────────────────────────────
379
+ startWatchdog() {
380
+ if (typeof window === 'undefined')
381
+ return;
382
+ this.watchdogTimer = setInterval(() => {
383
+ if (this.disposed)
384
+ return;
385
+ // "Stuck" = parked in a non-active recovery state (offline,
386
+ // waiting_for_network, backoff). We deliberately do NOT gate on
387
+ // `navigator.onLine === true` here: per MDN, `navigator.onLine`
388
+ // is only reliable when it returns false ("definitely offline"),
389
+ // and even that lies after laptop wake / VPN flips. Gating the
390
+ // watchdog on `onLine` was the actual "offline forever" bug —
391
+ // when the browser briefly reported offline and never re-fired
392
+ // the `online` event, the FSM had no escape from `'offline'`.
393
+ // The probe itself fast-fails when truly offline (NetworkProbe.ts),
394
+ // so an unconditional retry costs nothing in the genuine case.
395
+ const isStuck = this.state !== 'connected' &&
396
+ this.state !== 'session_expired' &&
397
+ this.state !== 'probing_network' &&
398
+ this.state !== 'reconnecting';
399
+ if (isStuck) {
400
+ this.stuckCycles++;
401
+ // Hard-reload gate: only reload when `navigator.onLine` is true.
402
+ // `onLine === false` is the one direction we trust (MDN: "false
403
+ // means definitely offline"), and reloading while truly offline
404
+ // would either serve cached content forever or fail and leave
405
+ // the user with a broken page.
406
+ const browserOnline = typeof navigator === 'undefined' || navigator.onLine === true;
407
+ if (this.stuckCycles >= MAX_STUCK_CYCLES_BEFORE_RELOAD && browserOnline) {
408
+ getContext().logger.warn('[ConnectionManager] Watchdog: sustained stuck — hard reload');
409
+ getContext().observability.breadcrumb('Watchdog hard reload', 'sync.offline', 'error');
410
+ if (typeof window !== 'undefined') {
411
+ window.location.reload();
412
+ }
413
+ return;
414
+ }
415
+ getContext().logger.info('[ConnectionManager] Watchdog: stuck — retry', {
416
+ state: this.state,
417
+ stuckCycles: this.stuckCycles,
418
+ browserOnline,
419
+ });
420
+ runInAction(() => {
421
+ this.attempt = 0;
422
+ });
423
+ this.send({ type: 'MANUAL_RETRY' });
424
+ }
425
+ else {
426
+ this.stuckCycles = 0;
427
+ }
428
+ }, WATCHDOG_INTERVAL_MS);
429
+ }
430
+ // ── UI-friendly computed ──────────────────────────────────────────────
431
+ get isConnected() { return this.state === 'connected'; }
432
+ get isOffline() { return this.state === 'offline' || this.state === 'waiting_for_network'; }
433
+ get isReconnecting() {
434
+ return this.state === 'probing_network' || this.state === 'reconnecting' || this.state === 'backoff';
435
+ }
436
+ get isSessionExpired() { return this.state === 'session_expired'; }
437
+ get offlineDuration() {
438
+ if (!this.offlineSince)
439
+ return null;
440
+ const ms = Date.now() - this.offlineSince.getTime();
441
+ const seconds = Math.floor(ms / 1000);
442
+ const minutes = Math.floor(seconds / 60);
443
+ const hours = Math.floor(minutes / 60);
444
+ if (seconds < 60)
445
+ return 'Just now';
446
+ if (minutes < 60)
447
+ return `${minutes}m ago`;
448
+ if (hours < 24)
449
+ return `${hours}h ago`;
450
+ return `${Math.floor(hours / 24)}d ago`;
451
+ }
452
+ // ── Timer helpers ─────────────────────────────────────────────────────
453
+ clearBackoffTimer() {
454
+ if (this.backoffTimer) {
455
+ clearTimeout(this.backoffTimer);
456
+ this.backoffTimer = null;
457
+ }
458
+ }
459
+ clearDebounceTimer() {
460
+ if (this.debounceTimer) {
461
+ clearTimeout(this.debounceTimer);
462
+ this.debounceTimer = null;
463
+ }
464
+ }
465
+ }
@@ -0,0 +1,137 @@
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 type { ObjectPool } from '../ObjectPool.js';
23
+ import type { Database } from '../Database.js';
24
+ import type { Model } from '../Model.js';
25
+ import type { ModelRegistry } from '../ModelRegistry.js';
26
+ import type { LoadWhere, WhereClause } from '../query/types.js';
27
+ import type { Schema } from '../schema/schema.js';
28
+ export interface HydrationCoordinatorOptions {
29
+ readonly objectPool: ObjectPool;
30
+ readonly database: Database;
31
+ readonly registry: ModelRegistry;
32
+ readonly schema: Schema;
33
+ /** Bootstrap base URL (without trailing slash), e.g. `https://api.example.com/api`. */
34
+ readonly baseUrl: string;
35
+ /**
36
+ * Lazy getter for the active capability token. Resolved per-request
37
+ * so cap refreshes propagate without re-instantiating the coordinator.
38
+ * Optional: browser consumers ride session cookies and can omit this;
39
+ * Node consumers (agent-worker) must wire it through or HTTP queries
40
+ * fail with 401 because cookies aren't available.
41
+ */
42
+ readonly getCapabilityToken?: () => string | null;
43
+ }
44
+ export interface FetchOptions<T> {
45
+ /**
46
+ * Filter clauses for the lookup. Accepts either the equality-object
47
+ * form (`{ id: 'abc' }` → `WHERE id = 'abc'`, array values → `IN`)
48
+ * or the explicit tuple form (`[['name', 'ILIKE', '%Goldman%']]`)
49
+ * matching the wire `WhereClause[]` 1:1. Multiple entries AND
50
+ * together. See `LoadWhere` in `../query/types.ts` for the full shape.
51
+ */
52
+ readonly where?: LoadWhere<T>;
53
+ readonly orderBy?: {
54
+ [K in keyof T]?: 'asc' | 'desc';
55
+ };
56
+ readonly limit?: number;
57
+ /**
58
+ * `'complete'` (default): wait for network round-trip even if local
59
+ * data exists, so the caller observes server-confirmed state.
60
+ * `'unknown'`: return whatever's in the pool/IDB immediately and
61
+ * fire the network in the background.
62
+ */
63
+ readonly type?: 'complete' | 'unknown';
64
+ /**
65
+ * Schema-declared relation names to hydrate alongside the primary
66
+ * rows. Each related entity is hydrated into its own typed pool
67
+ * via the same path as the primary fetch (network → pool + IDB).
68
+ */
69
+ readonly expand?: readonly string[];
70
+ }
71
+ export declare class HydrationCoordinator {
72
+ private readonly opts;
73
+ private readonly inFlight;
74
+ private capabilityTokenProvider;
75
+ constructor(opts: HydrationCoordinatorOptions);
76
+ /**
77
+ * Late-bind the capability token getter. Used by `Ablo.ts` to wire
78
+ * the token closure after the coordinator is constructed (the token
79
+ * isn't known until auth resolves, which happens after component
80
+ * construction). Browser consumers ride session cookies and don't
81
+ * need this; Node consumers (agent-worker) MUST call it or HTTP
82
+ * queries fail with 401 because cookies aren't available.
83
+ */
84
+ setCapabilityTokenProvider(provider: () => string | null): void;
85
+ private resolveToken;
86
+ /**
87
+ * Fetch matching rows for a model, hydrating the pool from IDB or
88
+ * network if not already present. Idempotent and single-flight
89
+ * deduped on the (modelName, where, orderBy, limit) tuple.
90
+ */
91
+ fetch<T>(modelName: string, options?: FetchOptions<T>): Promise<Model[]>;
92
+ private runFetch;
93
+ private hydrateOne;
94
+ /**
95
+ * Stamp `__typename` onto a row when it's known (from the schema's
96
+ * relation target). Strips the mangled `_Typename` key the
97
+ * `postgres.camel` driver leaves behind when the server's SQL
98
+ * bakes `__typename` into a JSONB literal — the driver's
99
+ * snake↔camel transform misreads `__typename` as `_typename` with
100
+ * a leading underscore and produces `_Typename`. ObjectPool only
101
+ * recognises `__typename`, so without this step nested rows fall
102
+ * through to the 'Unknown' branch and never instantiate.
103
+ */
104
+ private stampTypename;
105
+ private queryNetwork;
106
+ /**
107
+ * Hydrate nested expanded rows. Resolves each relation's target
108
+ * typename via the schema and stamps `__typename` on every nested
109
+ * row before passing to `hydrateOne` — the server's JSONB
110
+ * `__typename` field gets mangled by `postgres.camel` (`__typename`
111
+ * → `_Typename`), so the SDK can't trust whatever string lands.
112
+ */
113
+ private hydrateExpanded;
114
+ private persistToIdb;
115
+ private resolveTypename;
116
+ }
117
+ /**
118
+ * Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
119
+ * tuple form used throughout `runFetch`. Tuple inputs pass through; object
120
+ * inputs become one `['col', '=', val]` or `['col', 'IN', vals]` per key.
121
+ *
122
+ * Detection: an array whose first element is itself an array is treated
123
+ * as tuple form. Object form is the fallback.
124
+ *
125
+ * Exported so callers can pre-normalize (e.g., for tests, or to inspect
126
+ * the canonical clauses before passing them to `load`/`subscribe`).
127
+ */
128
+ export declare function normalizeWhere(where: unknown): readonly WhereClause[];
129
+ /**
130
+ * Operator-aware predicate. Mirrors the server's WhereOp semantics for
131
+ * local matching against pool/IDB rows. LIKE/ILIKE use SQL wildcards
132
+ * (`%` = any chars, `_` = one char) translated to a JS regex.
133
+ *
134
+ * Exported so callers can apply the same predicate to in-memory
135
+ * collections (tests, batch operations) using the canonical clauses.
136
+ */
137
+ export declare function matchesClauses(entity: Record<string, unknown>, clauses: readonly WhereClause[]): boolean;