@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.
- package/CHANGELOG.md +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- 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 {};
|