@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,434 @@
1
+ /**
2
+ * BootstrapHelper - Fixed to always fetch fresh data
3
+ * Removed problematic caching that was serving stale data
4
+ */
5
+ import { getContext } from '../context.js';
6
+ import { SyncSessionError, AbloConnectionError, translateHttpError } from '../errors.js';
7
+ // SyncObservability replaced by getContext().observability
8
+ import { parseBootstrapResponse } from './schemas.js';
9
+ export class BootstrapHelper {
10
+ options;
11
+ abortController = null;
12
+ get baseUrl() {
13
+ return this.options.baseUrl;
14
+ }
15
+ constructor(options) {
16
+ // Defaults are spread first; the explicit `baseUrl` then takes precedence
17
+ // and is computed from `options.baseUrl` (or the localhost fallback).
18
+ //
19
+ // Historical note: a previous version of this constructor placed
20
+ // `baseUrl: \`${baseUrl}/api\`` BEFORE the `...options` spread, which
21
+ // meant the spread silently overwrote it back to the caller's value
22
+ // and the `/api` suffix was dead code. Both Ablo and `createSyncEngine`
23
+ // already pass `${url}/api` explicitly, so removing the suffix here
24
+ // preserves the actual on-the-wire behavior while making the contract
25
+ // explicit: callers pass the full base URL including `/api`.
26
+ this.options = {
27
+ syncGroups: [],
28
+ maxRetries: 3,
29
+ retryDelay: 1000,
30
+ fetchTimeout: 10_000, // 10 second timeout per request - fail fast for good UX
31
+ ...options,
32
+ baseUrl: options.baseUrl || 'http://localhost:8080/api',
33
+ cacheScope: options.cacheScope ?? options.organizationId ?? null,
34
+ };
35
+ // Do not clear cache here; keep offline fallback available
36
+ }
37
+ /**
38
+ * Update the offline-cache namespace once auth has resolved the server-side
39
+ * account scope. This is intentionally not a public organizationId input.
40
+ */
41
+ setCacheScope(cacheScope) {
42
+ if (cacheScope.trim().length === 0)
43
+ return;
44
+ this.options.cacheScope = cacheScope;
45
+ }
46
+ setSyncGroups(syncGroups) {
47
+ this.options.syncGroups = [...(syncGroups ?? [])];
48
+ }
49
+ setAuthToken(authToken) {
50
+ if (!authToken) {
51
+ delete this.options.authToken;
52
+ return;
53
+ }
54
+ this.options.authToken = authToken;
55
+ }
56
+ /**
57
+ * Create a promise that rejects after a timeout
58
+ * Used to race against fetch requests that may hang indefinitely
59
+ */
60
+ createTimeoutPromise(ms, operation) {
61
+ return new Promise((_, reject) => {
62
+ setTimeout(() => {
63
+ reject(new Error(`Bootstrap ${operation} timed out after ${ms}ms`));
64
+ }, ms);
65
+ });
66
+ }
67
+ /**
68
+ * Wrap a promise with a timeout - if the promise doesn't resolve within
69
+ * the timeout period, the AbortController is triggered and an error is thrown
70
+ */
71
+ async withTimeout(promise, timeoutMs, operation) {
72
+ return Promise.race([promise, this.createTimeoutPromise(timeoutMs, operation)]);
73
+ }
74
+ /**
75
+ * Fetch bootstrap data from sync engine with partial bootstrap support
76
+ * @param lastSyncId - Optional: client's current lastSyncId for partial bootstrap
77
+ * @returns Bootstrap data (either full snapshot or delta batch)
78
+ */
79
+ async fetchBootstrap(lastSyncId) {
80
+ // organizationId omitted — server reads it from auth identity.
81
+ // See `fetchBootstrapWithETag` for the full rationale.
82
+ const params = new URLSearchParams();
83
+ // Add lastSyncId for partial bootstrap support
84
+ if (lastSyncId !== undefined && lastSyncId > 0) {
85
+ params.append('lastSyncId', lastSyncId.toString());
86
+ }
87
+ // Add sync groups
88
+ this.options.syncGroups.forEach((group) => {
89
+ params.append('syncGroups', group);
90
+ });
91
+ // Selective bootstrap: only request instant-strategy models.
92
+ // When present, the server skips all other models → smaller payload.
93
+ // When absent, server returns all models (backward compat).
94
+ if (this.options.instantModels && this.options.instantModels.length > 0) {
95
+ params.append('models', this.options.instantModels.join(','));
96
+ }
97
+ const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
98
+ // If offline, try cached bootstrap
99
+ if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
100
+ const cached = this.options.cacheScope
101
+ ? this.loadCachedBootstrap(this.options.cacheScope)
102
+ : null;
103
+ if (cached) {
104
+ getContext().logger.info('Using cached bootstrap (offline)');
105
+ return cached;
106
+ }
107
+ throw new AbloConnectionError('Offline and no cached bootstrap available', {
108
+ code: 'bootstrap_offline_no_cache',
109
+ });
110
+ }
111
+ getContext().logger.info('Fetching fresh bootstrap data', { url });
112
+ // Fetch with retries
113
+ let lastError = null;
114
+ for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
115
+ try {
116
+ const data = await this.performFetch(url);
117
+ getContext().logger.info('Bootstrap data fetched', {
118
+ type: data.type,
119
+ lastSyncId: data.lastSyncId,
120
+ modelCount: data.models ? Object.keys(data.models).length : 0,
121
+ deltaCount: data.deltaCount || 0,
122
+ totalItems: data.models
123
+ ? Object.values(data.models).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0)
124
+ : 0,
125
+ });
126
+ // Persist for offline fallback
127
+ if (this.options.cacheScope) {
128
+ this.saveCachedBootstrap(this.options.cacheScope, data);
129
+ }
130
+ return data;
131
+ }
132
+ catch (error) {
133
+ // SessionError should NOT be retried - the session is invalid and needs re-authentication
134
+ // Also do NOT fallback to cache - the user must sign in again
135
+ if (SyncSessionError.isSessionError(error)) {
136
+ getContext().observability.breadcrumb('Bootstrap session error - redirecting to sign-in', 'sync.bootstrap', 'warning', {
137
+ statusCode: error.statusCode,
138
+ });
139
+ throw error;
140
+ }
141
+ lastError = error;
142
+ getContext().observability.breadcrumb('Bootstrap fetch failed', 'sync.bootstrap', 'warning', {
143
+ attempt: attempt + 1,
144
+ });
145
+ if (attempt < this.options.maxRetries - 1) {
146
+ await this.delay(this.options.retryDelay * Math.pow(2, attempt));
147
+ }
148
+ }
149
+ }
150
+ // On error, attempt cached fallback (but NOT for session errors - already handled above)
151
+ const cached = this.options.cacheScope
152
+ ? this.loadCachedBootstrap(this.options.cacheScope)
153
+ : null;
154
+ if (cached) {
155
+ getContext().observability.breadcrumb('Bootstrap cache fallback', 'sync.bootstrap', 'warning', {
156
+ error: lastError?.message,
157
+ });
158
+ return cached;
159
+ }
160
+ throw lastError || new Error('Failed to fetch bootstrap data');
161
+ }
162
+ /**
163
+ * Fetch bootstrap with ETag, returning 304 hints
164
+ */
165
+ async fetchBootstrapWithETag() {
166
+ // organizationId is intentionally NOT sent. Server resolves it from
167
+ // the authenticated identity (`c.var.identity.organizationId`) —
168
+ // see `apps/sync-server/src/routes/bootstrap.ts`. Sending it
169
+ // client-side was historical: it predated the auth-context pipeline
170
+ // and forced a cross-org guard to defend against the SDK lying.
171
+ const params = new URLSearchParams();
172
+ this.options.syncGroups.forEach((g) => params.append('syncGroups', g));
173
+ if (this.options.instantModels && this.options.instantModels.length > 0) {
174
+ params.append('models', this.options.instantModels.join(','));
175
+ }
176
+ const url = `${this.options.baseUrl}/sync/bootstrap?${params.toString()}`;
177
+ // Note: ETag caching is deliberately app-side, not SDK-side. The server
178
+ // still returns an ETag on responses, which is captured below and
179
+ // forwarded to callers via BootstrapFetchResult.etag — apps that want
180
+ // conditional revalidation (If-None-Match) implement it at their own
181
+ // level where they own the cache-key namespace. The 304 branch below
182
+ // remains defensively in place for when a caller enables revalidation.
183
+ const headers = { 'Content-Type': 'application/json' };
184
+ if (this.options.authToken) {
185
+ headers.Authorization = `Bearer ${this.options.authToken}`;
186
+ }
187
+ this.abortController = new AbortController();
188
+ const res = await fetch(url, {
189
+ method: 'GET',
190
+ headers,
191
+ credentials: 'include',
192
+ signal: this.abortController.signal,
193
+ });
194
+ const etag = res.headers.get('ETag');
195
+ if (res.status === 304) {
196
+ // Log for telemetry
197
+ getContext().logger.info('[Bootstrap] 304 Not Modified - using cached data');
198
+ return { notModified: true, etag };
199
+ }
200
+ if (!res.ok) {
201
+ // Check for session/auth errors - these should redirect to login
202
+ if (SyncSessionError.isSessionErrorResponse(res.status)) {
203
+ let body = '';
204
+ try {
205
+ body = await res.text();
206
+ }
207
+ catch {
208
+ // Ignore body parsing errors
209
+ }
210
+ throw new SyncSessionError(body || `Session expired or invalid: ${res.status}`, res.status);
211
+ }
212
+ const bodyText = await res.text().catch(() => '');
213
+ let parsed = bodyText;
214
+ if (bodyText) {
215
+ try {
216
+ parsed = JSON.parse(bodyText);
217
+ }
218
+ catch {
219
+ // Keep as string.
220
+ }
221
+ }
222
+ throw translateHttpError(res.status, parsed || `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
223
+ }
224
+ const rawJson = await res.json();
225
+ const data = parseBootstrapResponse(rawJson);
226
+ // Persist payload for offline
227
+ try {
228
+ if (this.options.cacheScope) {
229
+ this.saveCachedBootstrap(this.options.cacheScope, data);
230
+ }
231
+ }
232
+ catch { }
233
+ getContext().logger.info('[Bootstrap] 200 OK - received new data');
234
+ return { notModified: false, data, etag };
235
+ }
236
+ /**
237
+ * Perform the actual fetch request with timeout protection
238
+ */
239
+ async performFetch(url) {
240
+ // Cancel any previous in-flight request
241
+ if (this.abortController) {
242
+ this.abortController.abort();
243
+ }
244
+ this.abortController = new AbortController();
245
+ const timeoutId = setTimeout(() => {
246
+ getContext().observability.breadcrumb('Bootstrap fetch timeout', 'sync.bootstrap', 'warning', {
247
+ timeoutMs: this.options.fetchTimeout,
248
+ });
249
+ this.abortController?.abort();
250
+ }, this.options.fetchTimeout);
251
+ let response;
252
+ try {
253
+ response = await fetch(url, {
254
+ method: 'GET',
255
+ headers: {
256
+ 'Content-Type': 'application/json',
257
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
258
+ Pragma: 'no-cache',
259
+ ...(this.options.authToken
260
+ ? { Authorization: `Bearer ${this.options.authToken}` }
261
+ : {}),
262
+ },
263
+ credentials: 'include',
264
+ signal: this.abortController.signal,
265
+ cache: 'no-store', // Force browser to not cache
266
+ });
267
+ }
268
+ catch (error) {
269
+ clearTimeout(timeoutId);
270
+ // Convert abort to timeout error for better error messaging
271
+ if (error instanceof Error && error.name === 'AbortError') {
272
+ throw new AbloConnectionError(`Bootstrap fetch timed out after ${this.options.fetchTimeout}ms`, { code: 'bootstrap_fetch_timeout', cause: error });
273
+ }
274
+ throw error;
275
+ }
276
+ clearTimeout(timeoutId);
277
+ if (!response.ok) {
278
+ // Check for session/auth errors - these should redirect to login
279
+ if (SyncSessionError.isSessionErrorResponse(response.status)) {
280
+ let body = '';
281
+ try {
282
+ body = await response.text();
283
+ }
284
+ catch {
285
+ // Ignore body parsing errors
286
+ }
287
+ throw new SyncSessionError(body || `Session expired or invalid: ${response.status}`, response.status);
288
+ }
289
+ const bodyText = await response.text().catch(() => '');
290
+ let parsed = bodyText;
291
+ if (bodyText) {
292
+ try {
293
+ parsed = JSON.parse(bodyText);
294
+ }
295
+ catch {
296
+ // Keep as string.
297
+ }
298
+ }
299
+ throw translateHttpError(response.status, parsed || `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
300
+ }
301
+ const rawJson = await response.json();
302
+ const data = parseBootstrapResponse(rawJson);
303
+ // Save a copy for offline
304
+ try {
305
+ if (this.options.cacheScope) {
306
+ this.saveCachedBootstrap(this.options.cacheScope, data);
307
+ }
308
+ }
309
+ catch { }
310
+ return data;
311
+ }
312
+ /**
313
+ * Fetch a single entity by ID (on-demand self-healing).
314
+ * Returns `null` for 404 (entity deleted) — this is an expected state, not an error.
315
+ * Throws for unexpected HTTP errors (5xx, network failures).
316
+ */
317
+ async fetchEntity(modelName, id) {
318
+ const url = `${this.options.baseUrl}/sync/entity/${modelName}/${id}`;
319
+ const response = await fetch(url, {
320
+ method: 'GET',
321
+ headers: {
322
+ 'Content-Type': 'application/json',
323
+ },
324
+ credentials: 'include',
325
+ });
326
+ if (response.status === 404) {
327
+ return null;
328
+ }
329
+ if (!response.ok) {
330
+ const bodyText = await response.text().catch(() => '');
331
+ let parsed = bodyText;
332
+ if (bodyText) {
333
+ try {
334
+ parsed = JSON.parse(bodyText);
335
+ }
336
+ catch {
337
+ // Keep as string.
338
+ }
339
+ }
340
+ throw translateHttpError(response.status, parsed || `Entity fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
341
+ }
342
+ return await response.json();
343
+ }
344
+ // ─────────────────────────────────────────────────────────────────────
345
+ /**
346
+ * Clear all cached bootstrap data
347
+ */
348
+ clearCache() {
349
+ if (typeof window === 'undefined')
350
+ return;
351
+ try {
352
+ // Clear all bootstrap cache keys
353
+ const keysToRemove = [];
354
+ for (let i = 0; i < localStorage.length; i++) {
355
+ const key = localStorage.key(i);
356
+ if (key && key.includes('sync-bootstrap')) {
357
+ keysToRemove.push(key);
358
+ }
359
+ }
360
+ keysToRemove.forEach((key) => {
361
+ localStorage.removeItem(key);
362
+ getContext().logger.debug('Cleared cache key', { key });
363
+ });
364
+ }
365
+ catch (error) {
366
+ getContext().logger.warn('Failed to clear cache', { error });
367
+ }
368
+ }
369
+ // Cache helpers for offline bootstrap
370
+ getBootstrapCacheKey(orgId) {
371
+ return `ablo:bootstrap:${orgId}`;
372
+ }
373
+ saveCachedBootstrap(orgId, data) {
374
+ if (typeof window === 'undefined')
375
+ return;
376
+ try {
377
+ localStorage.setItem(this.getBootstrapCacheKey(orgId), JSON.stringify(data));
378
+ }
379
+ catch (e) {
380
+ getContext().logger.warn('Failed to cache bootstrap payload', {
381
+ error: e instanceof Error ? e.message : String(e),
382
+ });
383
+ }
384
+ }
385
+ loadCachedBootstrap(orgId) {
386
+ if (typeof window === 'undefined')
387
+ return null;
388
+ try {
389
+ const raw = localStorage.getItem(this.getBootstrapCacheKey(orgId));
390
+ if (!raw)
391
+ return null;
392
+ return JSON.parse(raw);
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ /**
399
+ * Abort ongoing fetch request
400
+ */
401
+ abort() {
402
+ if (this.abortController) {
403
+ this.abortController.abort();
404
+ this.abortController = null;
405
+ }
406
+ }
407
+ /**
408
+ * Helper to delay execution
409
+ */
410
+ delay(ms) {
411
+ return new Promise((resolve) => setTimeout(resolve, ms));
412
+ }
413
+ /**
414
+ * Get health status of sync engine
415
+ */
416
+ async checkHealth() {
417
+ try {
418
+ const response = await fetch(`${this.options.baseUrl}/health`, {
419
+ method: 'GET',
420
+ credentials: 'include',
421
+ signal: AbortSignal.timeout(5000),
422
+ cache: 'no-store',
423
+ });
424
+ if (!response.ok)
425
+ return false;
426
+ const data = await response.json();
427
+ return data.status === 'healthy';
428
+ }
429
+ catch (error) {
430
+ getContext().observability.breadcrumb('Health check failed', 'sync.bootstrap', 'warning');
431
+ return false;
432
+ }
433
+ }
434
+ }
@@ -0,0 +1,136 @@
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 { type ProbeResult } from './NetworkProbe.js';
37
+ export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'session_expired';
38
+ export type ConnectionEvent = {
39
+ type: 'NETWORK_LOST';
40
+ } | {
41
+ type: 'NETWORK_ONLINE';
42
+ } | {
43
+ type: 'TAB_VISIBLE';
44
+ } | {
45
+ type: 'WS_CONNECTED';
46
+ } | {
47
+ type: 'WS_DISCONNECTED';
48
+ } | {
49
+ type: 'WS_SESSION_ERROR';
50
+ } | {
51
+ type: 'WS_HANDSHAKE_FAILED';
52
+ } | {
53
+ type: 'PROBE_SUCCESS';
54
+ sessionValid: boolean;
55
+ } | {
56
+ type: 'PROBE_FAILED';
57
+ } | {
58
+ type: 'RECONNECT_SUCCESS';
59
+ } | {
60
+ type: 'RECONNECT_FAILED';
61
+ } | {
62
+ type: 'BACKOFF_ELAPSED';
63
+ } | {
64
+ type: 'BOOTSTRAP_FAILED_SESSION';
65
+ } | {
66
+ type: 'MANUAL_RETRY';
67
+ };
68
+ export interface ConnectionCallbacks {
69
+ /** Run bootstrap + WebSocket reconnect. Returns the outcome. */
70
+ onReconnect: () => Promise<'success' | 'session_error' | 'network_error'>;
71
+ /** Called when the session is confirmed expired — route to signin. */
72
+ onSessionExpired: () => void;
73
+ /** Called to tear down the WebSocket when entering a dead state. */
74
+ onDisconnectWebSocket: () => void;
75
+ /**
76
+ * Fired on every FSM state transition. Lets the embedding store
77
+ * mirror recovery progress into its visible `syncStatus` so the UI
78
+ * can show "Reconnecting…" instead of a sticky "offline" while the
79
+ * FSM cycles through `probing_network` → `reconnecting` → `backoff`.
80
+ * Optional — omitting it preserves the previous behavior where the
81
+ * FSM was opaque to the UI.
82
+ */
83
+ onStateChange?: (next: ConnectionState, prev: ConnectionState) => void;
84
+ }
85
+ export interface ConnectionManagerOptions {
86
+ /**
87
+ * Sync-server base URL used for probes. Falls back to the env-based
88
+ * default of `probeNetwork`.
89
+ */
90
+ baseUrl?: string;
91
+ /** Override retry ceilings / jitter. Production should leave defaults. */
92
+ backoff?: Partial<typeof DEFAULT_BACKOFF>;
93
+ }
94
+ declare const DEFAULT_BACKOFF: {
95
+ readonly BASE_MS: 2000;
96
+ readonly MAX_MS: 30000;
97
+ readonly MAX_ATTEMPTS: 8;
98
+ readonly JITTER: 0.15;
99
+ };
100
+ export declare class ConnectionManager {
101
+ state: ConnectionState;
102
+ offlineSince: Date | null;
103
+ attempt: number;
104
+ lastProbeResult: ProbeResult | null;
105
+ private callbacks;
106
+ private backoffTimer;
107
+ private debounceTimer;
108
+ private watchdogTimer;
109
+ private stuckCycles;
110
+ private disposed;
111
+ private readonly baseUrl?;
112
+ private readonly backoff;
113
+ private handleBrowserOnline;
114
+ private handleBrowserOffline;
115
+ private handleVisibilityChange;
116
+ constructor(options?: ConnectionManagerOptions);
117
+ start(callbacks: ConnectionCallbacks): void;
118
+ dispose(): void;
119
+ send(event: ConnectionEvent): void;
120
+ private transition;
121
+ private onEnterState;
122
+ private runProbe;
123
+ private runReconnect;
124
+ private scheduleBackoff;
125
+ private setupBrowserListeners;
126
+ private removeBrowserListeners;
127
+ private startWatchdog;
128
+ get isConnected(): boolean;
129
+ get isOffline(): boolean;
130
+ get isReconnecting(): boolean;
131
+ get isSessionExpired(): boolean;
132
+ get offlineDuration(): string | null;
133
+ private clearBackoffTimer;
134
+ private clearDebounceTimer;
135
+ }
136
+ export {};