@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,1843 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseSyncedStore — Generic sync store base class for the SDK.
|
|
3
|
+
*
|
|
4
|
+
* Exports the core types, interfaces, and a base class that app-specific
|
|
5
|
+
* stores extend. The base class provides query/mutation/delta/bootstrap
|
|
6
|
+
* orchestration. Subclasses add domain-specific lazy-loading, collaboration
|
|
7
|
+
* events, and model enrichment.
|
|
8
|
+
*
|
|
9
|
+
* Design: The app's SyncedStore extends this and adds its own methods.
|
|
10
|
+
* This file only contains types and the abstract contract — the actual
|
|
11
|
+
* implementation stays in the app's SyncedStore.ts until we incrementally
|
|
12
|
+
* pull generic methods into this base class.
|
|
13
|
+
*/
|
|
14
|
+
import { makeObservable, observable, computed, runInAction } from 'mobx';
|
|
15
|
+
import { AbloConnectionError, AbloValidationError } from './errors.js';
|
|
16
|
+
import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
17
|
+
import { PropertyType } from './types/index.js';
|
|
18
|
+
import { SyncWebSocket, } from './sync/SyncWebSocket.js';
|
|
19
|
+
import { QueryProcessor } from './core/QueryProcessor.js';
|
|
20
|
+
import { Model, rowAsModel } from './Model.js';
|
|
21
|
+
import { getContext } from './context.js';
|
|
22
|
+
import { SyncSessionError } from './errors.js';
|
|
23
|
+
import { ModelScope } from './ObjectPool.js';
|
|
24
|
+
import { LazyReferenceCollection } from './LazyReferenceCollection.js';
|
|
25
|
+
import { createReaderActions } from './react/useReader.js';
|
|
26
|
+
/** Bootstrap timeout configuration */
|
|
27
|
+
export const BOOTSTRAP_CONFIG = {
|
|
28
|
+
OVERALL_TIMEOUT_MS: 15_000,
|
|
29
|
+
MAX_RETRY_ATTEMPTS: 3,
|
|
30
|
+
RETRY_DELAY_MS: 500,
|
|
31
|
+
};
|
|
32
|
+
// Re-export for clean API
|
|
33
|
+
export { ModelScope };
|
|
34
|
+
// ── Base class ──────────────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* BaseSyncedStore — abstract base for app-specific sync stores.
|
|
37
|
+
*
|
|
38
|
+
* Provides the dependency structure, observable status, and protected
|
|
39
|
+
* accessors that subclasses use. The actual sync orchestration (initialize,
|
|
40
|
+
* delta processing, bootstrap, query, save, delete, etc.) lives in the
|
|
41
|
+
* app's concrete subclass for now — methods will be pulled up into this
|
|
42
|
+
* base class incrementally as they are genericized.
|
|
43
|
+
*
|
|
44
|
+
* Subclasses MUST call `super(dependencies, config)` and then set up
|
|
45
|
+
* their own MobX observables.
|
|
46
|
+
*
|
|
47
|
+
* Generic over `TCollaboration` — an app-defined event map for real-time
|
|
48
|
+
* collaboration events (cursors, selections, presence beyond the core set).
|
|
49
|
+
* Subclasses pass their own event map to get typed `subscribe()` calls on
|
|
50
|
+
* the underlying SyncWebSocket without casts:
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* interface AbloEvents {
|
|
54
|
+
* 'sheet:selection': [SheetSelectionEvent];
|
|
55
|
+
* 'slide:cursor': [SlideCursorEvent];
|
|
56
|
+
* }
|
|
57
|
+
* class SyncedStore extends BaseSyncedStore<AbloEvents> {
|
|
58
|
+
* subscribeToSlideCursor(handler: (e: SlideCursorEvent) => void) {
|
|
59
|
+
* return this.syncWebSocket?.subscribe('slide:cursor', handler);
|
|
60
|
+
* }
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Walk a schema and derive the three sync-plan arrays consumed by
|
|
65
|
+
* `BaseSyncedStore`'s constructor: version-vector keys, FK indexes to
|
|
66
|
+
* register on the pool, and the enrichment plan.
|
|
67
|
+
*
|
|
68
|
+
* Version vector keys are derived from each model's `typename` (lowercased
|
|
69
|
+
* to match the server's event-type convention — `'Task'` → `'task'`,
|
|
70
|
+
* `'SlideLayer'` → `'slidelayer'`). A fallback to the schema key applies
|
|
71
|
+
* when `typename` is unset, though `defineSchema()` now always resolves
|
|
72
|
+
* it during assembly so the fallback is defensive-only.
|
|
73
|
+
*
|
|
74
|
+
* FK indexes and enrichment entries are pulled from each `belongsTo`
|
|
75
|
+
* relation where `options.index` / `options.enrich` is set. Relations
|
|
76
|
+
* without those options are skipped — this is an opt-in mechanism so
|
|
77
|
+
* adding a `belongsTo` never silently changes delta or lookup semantics.
|
|
78
|
+
*
|
|
79
|
+
* Pure function: takes a Schema, returns three arrays. No side effects,
|
|
80
|
+
* no class state. Called once at construction time from `BaseSyncedStore`.
|
|
81
|
+
*/
|
|
82
|
+
export function deriveSyncPlanFromSchema(schema) {
|
|
83
|
+
const versionVectorKeys = [];
|
|
84
|
+
const enrichmentPlan = [];
|
|
85
|
+
const foreignKeyIndexes = [];
|
|
86
|
+
for (const [modelName, def] of Object.entries(schema.models)) {
|
|
87
|
+
const typename = def.typename ?? modelName;
|
|
88
|
+
versionVectorKeys.push(typename.toLowerCase());
|
|
89
|
+
for (const [relationKey, rel] of Object.entries(def.relations)) {
|
|
90
|
+
if (rel.type === 'belongsTo') {
|
|
91
|
+
if (rel.options?.index) {
|
|
92
|
+
foreignKeyIndexes.push({ modelName: typename, fieldName: rel.foreignKey });
|
|
93
|
+
}
|
|
94
|
+
if (rel.options?.enrich) {
|
|
95
|
+
enrichmentPlan.push({
|
|
96
|
+
modelName: typename,
|
|
97
|
+
foreignKey: rel.foreignKey,
|
|
98
|
+
relationKey,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
103
|
+
// hasMany/hasOne: the FK lives on the TARGET model, not the current model.
|
|
104
|
+
// Register the FK index on the target so getByForeignKey works.
|
|
105
|
+
// Target typename is resolved at registration time from the schema.
|
|
106
|
+
const targetDef = schema.models[rel.target];
|
|
107
|
+
const targetTypename = targetDef?.typename ?? rel.target;
|
|
108
|
+
foreignKeyIndexes.push({ modelName: targetTypename, fieldName: rel.foreignKey });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { versionVectorKeys, enrichmentPlan, foreignKeyIndexes };
|
|
113
|
+
}
|
|
114
|
+
export class BaseSyncedStore {
|
|
115
|
+
// ── Observable sync status for UI ──
|
|
116
|
+
syncStatus = {
|
|
117
|
+
state: 'idle',
|
|
118
|
+
progress: 0,
|
|
119
|
+
pendingChanges: 0,
|
|
120
|
+
isSessionError: false,
|
|
121
|
+
};
|
|
122
|
+
// ── Injected dependencies ──
|
|
123
|
+
syncClient;
|
|
124
|
+
database;
|
|
125
|
+
objectPool;
|
|
126
|
+
modelRegistry;
|
|
127
|
+
/**
|
|
128
|
+
* Schema the store was constructed with. Persisted so the `query`
|
|
129
|
+
* accessor namespace can build typed per-model reader actions lazily
|
|
130
|
+
* without callers having to pass the schema at every lookup site.
|
|
131
|
+
*/
|
|
132
|
+
schema;
|
|
133
|
+
/** Lazily-built `query.<modelKey>.*` accessor namespace. */
|
|
134
|
+
_queryProxy;
|
|
135
|
+
// ── Real-time sync ──
|
|
136
|
+
syncWebSocket = null;
|
|
137
|
+
_syncServerUrl;
|
|
138
|
+
/**
|
|
139
|
+
* Public accessor for the underlying SyncWebSocket. Used by the
|
|
140
|
+
* factory in `createSyncEngine` to wire the default mutation
|
|
141
|
+
* executor — the executor needs the WS handle to send commit
|
|
142
|
+
* frames, and the factory can't reach `protected` state through
|
|
143
|
+
* normal typing. Returns null until WS is initialized during
|
|
144
|
+
* `initialize()`.
|
|
145
|
+
*/
|
|
146
|
+
getSyncWebSocket() {
|
|
147
|
+
return this.syncWebSocket;
|
|
148
|
+
}
|
|
149
|
+
// ── Internal helpers ──
|
|
150
|
+
queryProcessor;
|
|
151
|
+
/**
|
|
152
|
+
* Runtime behavior flags only — the three schema/config arrays
|
|
153
|
+
* (`versionVectorKeys`, `enrichmentPlan`, `foreignKeyIndexes`) are
|
|
154
|
+
* consumed at construction time and stored on the instance as
|
|
155
|
+
* `versionVector`, `enrichmentPlan`, and pool-registered indexes.
|
|
156
|
+
* They don't need to persist on `this.config`.
|
|
157
|
+
*/
|
|
158
|
+
config;
|
|
159
|
+
disposers = [];
|
|
160
|
+
initialized = false;
|
|
161
|
+
dataReady = false;
|
|
162
|
+
// ── User context ──
|
|
163
|
+
// Identity context the consumer wired in at construction. The shape
|
|
164
|
+
// (`{userId, organizationId, teamIds}`) is currently a fixed contract
|
|
165
|
+
// because the Go-era bootstrap protocol embedded those keys in scope
|
|
166
|
+
// tokens; the SDK should eventually expose this as an opaque
|
|
167
|
+
// `principal` blob so consumers with different identity models
|
|
168
|
+
// aren't forced into user/org. See the architectural note in the
|
|
169
|
+
// README — "currentUserId" is a domain concept, not an SDK
|
|
170
|
+
// primitive, and the host (apps/web/SyncEngineProvider) is the
|
|
171
|
+
// right place to surface it.
|
|
172
|
+
userContext = null;
|
|
173
|
+
// ── Smart sync ──
|
|
174
|
+
versionVector;
|
|
175
|
+
/**
|
|
176
|
+
* Declarative enrichment plan: "for model X, when a delta arrives,
|
|
177
|
+
* read data[foreignKey] and attach the matching parent from the pool
|
|
178
|
+
* as data[relationKey]." Merged from schema-derived + config at
|
|
179
|
+
* construction time. Replaces the `enrichRelations` subclass override
|
|
180
|
+
* pattern.
|
|
181
|
+
*/
|
|
182
|
+
enrichmentPlan = [];
|
|
183
|
+
smartSyncOptions;
|
|
184
|
+
pendingDeltas = [];
|
|
185
|
+
batchTimer = null;
|
|
186
|
+
syncPromise = null;
|
|
187
|
+
lastAckedId = 0;
|
|
188
|
+
highestProcessedSyncId = 0;
|
|
189
|
+
// ── Delta queuing during bootstrap ──
|
|
190
|
+
bootstrapDeltaQueue = null;
|
|
191
|
+
activeBootstrapCount = 0;
|
|
192
|
+
// ── Delete tracking ──
|
|
193
|
+
pendingDeletes = new Set();
|
|
194
|
+
// ── Model type hydration ──
|
|
195
|
+
modelTypesHydrated = new Set();
|
|
196
|
+
modelTypeHydrationInFlight = new Map();
|
|
197
|
+
constructor(dependencies, config = {}) {
|
|
198
|
+
this.syncClient = dependencies.syncClient;
|
|
199
|
+
this.database = dependencies.database;
|
|
200
|
+
this.objectPool = dependencies.objectPool;
|
|
201
|
+
this.modelRegistry = dependencies.modelRegistry;
|
|
202
|
+
this.schema = dependencies.schema;
|
|
203
|
+
this._syncServerUrl = dependencies.url;
|
|
204
|
+
// Set this store as the global Model store
|
|
205
|
+
Model.setStore(this);
|
|
206
|
+
// ── Schema-derived sync plan (Phase 2) ─────────────────────────────
|
|
207
|
+
//
|
|
208
|
+
// When a schema is provided, derive version vector keys, FK indexes,
|
|
209
|
+
// and the enrichment plan from declarative annotations on the schema's
|
|
210
|
+
// `belongsTo` relations. Explicit config fields layer on top, so
|
|
211
|
+
// subclasses (like Ablo's SyncedStore) can pass hardcoded arrays
|
|
212
|
+
// without needing a full schema.generated.ts.
|
|
213
|
+
//
|
|
214
|
+
// Order matters: schema-derived first, config second, so that in a
|
|
215
|
+
// future where Ablo passes both (schema AND explicit config), the
|
|
216
|
+
// explicit config entries are registered last and can't be
|
|
217
|
+
// accidentally shadowed by schema derivation.
|
|
218
|
+
const derived = dependencies.schema
|
|
219
|
+
? deriveSyncPlanFromSchema(dependencies.schema)
|
|
220
|
+
: { versionVectorKeys: [], enrichmentPlan: [], foreignKeyIndexes: [] };
|
|
221
|
+
const mergedForeignKeyIndexes = [
|
|
222
|
+
...derived.foreignKeyIndexes,
|
|
223
|
+
...(config.foreignKeyIndexes ?? []),
|
|
224
|
+
];
|
|
225
|
+
for (const { modelName, fieldName } of mergedForeignKeyIndexes) {
|
|
226
|
+
this.objectPool.registerForeignKey(modelName, fieldName);
|
|
227
|
+
}
|
|
228
|
+
// Legacy override hook — still called AFTER schema-driven registration
|
|
229
|
+
// so subclasses can add more FKs on top of the declarative set.
|
|
230
|
+
// Kept for backwards compat; subclasses migrate to config at leisure.
|
|
231
|
+
this.registerForeignKeys();
|
|
232
|
+
this.enrichmentPlan = [
|
|
233
|
+
...derived.enrichmentPlan,
|
|
234
|
+
...(config.enrichmentPlan ?? []),
|
|
235
|
+
];
|
|
236
|
+
// Set dependencies for LazyReferenceCollection
|
|
237
|
+
LazyReferenceCollection.setDependencies(this.database, this.objectPool);
|
|
238
|
+
// Apply config defaults
|
|
239
|
+
this.config = {
|
|
240
|
+
enableOffline: config.enableOffline ?? true,
|
|
241
|
+
enableCache: config.enableCache ?? true,
|
|
242
|
+
enableTelemetry: config.enableTelemetry ?? false,
|
|
243
|
+
};
|
|
244
|
+
// Smart sync options
|
|
245
|
+
this.smartSyncOptions = {
|
|
246
|
+
maxDeltasBeforeBootstrap: 1000,
|
|
247
|
+
maxBootstrapSize: 10 * 1024 * 1024,
|
|
248
|
+
batchingDelay: 100,
|
|
249
|
+
maxBatchSize: 50,
|
|
250
|
+
};
|
|
251
|
+
// Version vector: union of schema-derived keys + explicit config keys,
|
|
252
|
+
// each seeded to 0. Empty when neither source supplies keys (unchanged
|
|
253
|
+
// behavior from pre-Phase-2 defaults).
|
|
254
|
+
const mergedVvKeys = [
|
|
255
|
+
...derived.versionVectorKeys,
|
|
256
|
+
...(config.versionVectorKeys ?? []),
|
|
257
|
+
];
|
|
258
|
+
this.versionVector = Object.fromEntries(mergedVvKeys.map((k) => [k, 0]));
|
|
259
|
+
// Create internal helpers
|
|
260
|
+
this.queryProcessor = new QueryProcessor({
|
|
261
|
+
enableCache: this.config.enableCache,
|
|
262
|
+
});
|
|
263
|
+
// Auto-invalidate query cache when SyncClient modifies the pool.
|
|
264
|
+
// Replaces all manual queryProcessor.invalidateCache() calls.
|
|
265
|
+
this.syncClient.on('models:changed', (modelNames) => {
|
|
266
|
+
for (const name of modelNames) {
|
|
267
|
+
this.queryProcessor.invalidateCache(`.*${name}.*`);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
// Make sync status fields observable so consumer code can do
|
|
271
|
+
// reaction(() => store.isReady, ...)
|
|
272
|
+
// observer(() => store.isOffline)
|
|
273
|
+
// and actually receive notifications. Without these annotations,
|
|
274
|
+
// `syncStatus` / `dataReady` are plain properties and the derived
|
|
275
|
+
// getters (isReady, isSyncing, isOffline, ...) never emit change
|
|
276
|
+
// signals — a trap that has burned multiple downstream apps
|
|
277
|
+
// (one stuck forever on the loading skeleton because `reaction`
|
|
278
|
+
// to `store.isReady` never fired). Explicit > accidental.
|
|
279
|
+
makeObservable(this, {
|
|
280
|
+
syncStatus: observable,
|
|
281
|
+
dataReady: observable,
|
|
282
|
+
isReady: computed,
|
|
283
|
+
isSyncing: computed,
|
|
284
|
+
isOffline: computed,
|
|
285
|
+
isReconnecting: computed,
|
|
286
|
+
isError: computed,
|
|
287
|
+
hasUnsyncedChanges: computed,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// ── Protected extension points ────────────────────────────────────────────
|
|
291
|
+
/**
|
|
292
|
+
* Register foreign key indexes for O(1) lookups.
|
|
293
|
+
*
|
|
294
|
+
* Legacy override hook — in Phase 2 the preferred way to declare FK
|
|
295
|
+
* indexes is via `config.foreignKeyIndexes` at construction time, or
|
|
296
|
+
* by marking the `belongsTo` relation with `{ index: true }` in the
|
|
297
|
+
* schema. This hook still fires AFTER the schema-derived + config
|
|
298
|
+
* registrations, so subclasses can layer additional FKs on top.
|
|
299
|
+
*/
|
|
300
|
+
registerForeignKeys() { }
|
|
301
|
+
/**
|
|
302
|
+
* Enrich delta data with related models from the ObjectPool.
|
|
303
|
+
*
|
|
304
|
+
* Base implementation walks `this.enrichmentPlan` — entries populated
|
|
305
|
+
* from the schema's `{ enrich: true }` relations and from
|
|
306
|
+
* `config.enrichmentPlan`. Subclasses can still override for bespoke
|
|
307
|
+
* logic, calling `super.enrichRelations(modelName, data)` first to
|
|
308
|
+
* apply the declarative plan before layering on custom work.
|
|
309
|
+
*
|
|
310
|
+
* Enrichment is best-effort: if the parent isn't yet in the pool
|
|
311
|
+
* (e.g., a child delta arrives before its parent in a bootstrap
|
|
312
|
+
* batch), the entry is silently skipped and the data passes through
|
|
313
|
+
* untouched. The next delta for the same child will re-enrich.
|
|
314
|
+
*/
|
|
315
|
+
enrichRelations(modelName, data) {
|
|
316
|
+
for (const entry of this.enrichmentPlan) {
|
|
317
|
+
if (entry.modelName !== modelName)
|
|
318
|
+
continue;
|
|
319
|
+
const fkValue = data[entry.foreignKey];
|
|
320
|
+
if (typeof fkValue !== 'string')
|
|
321
|
+
continue;
|
|
322
|
+
const parent = this.objectPool.get(fkValue);
|
|
323
|
+
if (parent) {
|
|
324
|
+
data[entry.relationKey] = parent;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return data;
|
|
328
|
+
}
|
|
329
|
+
/** Check if a model name represents a custom/dynamic entity type. */
|
|
330
|
+
isCustomEntity(modelName) {
|
|
331
|
+
return !this.objectPool.registry.getModelByName(modelName);
|
|
332
|
+
}
|
|
333
|
+
/** Create a custom entity instance from delta data. Override for domain-specific custom entities. */
|
|
334
|
+
createCustomEntity(_modelName, _modelId, _data) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
/** Called before save for domain-specific validation/self-healing. */
|
|
338
|
+
beforeSave(_model) { }
|
|
339
|
+
/** Connection lifecycle event callback — set by subclass to wire connection state machine. */
|
|
340
|
+
onConnectionEvent;
|
|
341
|
+
/**
|
|
342
|
+
* Internal connection FSM. Owns network probe + backoff + reconnect
|
|
343
|
+
* orchestration for the default path. Constructed lazily once we
|
|
344
|
+
* have a user context + a WebSocket (see `wireWebSocketEvents`);
|
|
345
|
+
* driven by the `onConnectionEvent` hook AND browser online/offline
|
|
346
|
+
* events it sets up itself.
|
|
347
|
+
*
|
|
348
|
+
* Every consumer gets production-grade offline-to-online recovery
|
|
349
|
+
* out of the box. Subclasses that want their own lifecycle owner
|
|
350
|
+
* can disable this by overriding `createConnectionManager()` to
|
|
351
|
+
* return null.
|
|
352
|
+
*/
|
|
353
|
+
connectionManager = null;
|
|
354
|
+
/**
|
|
355
|
+
* Listeners registered via `subscribeSessionError()`. Fired when the
|
|
356
|
+
* WebSocket closes with a session-invalid code (1008/4001/4003) or a
|
|
357
|
+
* session-error event is received. Separate from `onConnectionEvent`
|
|
358
|
+
* (which exists for the ConnectionStore FSM) so multiple consumers —
|
|
359
|
+
* typically `<AbloProvider>` and a connection-lifecycle owner — can
|
|
360
|
+
* both react without racing on the single-callback slot.
|
|
361
|
+
*/
|
|
362
|
+
sessionErrorListeners = new Set();
|
|
363
|
+
/**
|
|
364
|
+
* Subscribe to session-error events. The returned function removes
|
|
365
|
+
* the listener. Safe to call multiple times from different consumers
|
|
366
|
+
* (each gets its own slot in the listener set).
|
|
367
|
+
*/
|
|
368
|
+
subscribeSessionError(listener) {
|
|
369
|
+
this.sessionErrorListeners.add(listener);
|
|
370
|
+
return () => { this.sessionErrorListeners.delete(listener); };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Subscribe to per-mutation failure payloads. Forwarded from the
|
|
374
|
+
* underlying `SyncClient.transactionQueue` so consumers (toast layer,
|
|
375
|
+
* route-level reverted boundaries, telemetry) can react without
|
|
376
|
+
* reaching across the store. Returns an unsubscribe function.
|
|
377
|
+
*
|
|
378
|
+
* Why this lives on the base store rather than SyncClient: the React
|
|
379
|
+
* `<AbloProvider>` binds against this surface, so adding it here
|
|
380
|
+
* keeps the engine's internal wiring private while still giving the
|
|
381
|
+
* SDK a single hook to expose. Mirrors `subscribeSessionError` —
|
|
382
|
+
* same shape, same lifecycle.
|
|
383
|
+
*/
|
|
384
|
+
subscribeMutationFailure(listener) {
|
|
385
|
+
return this.syncClient.onMutationFailure(listener);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Wait for the in-flight transaction for (modelName, modelId) to be
|
|
389
|
+
* confirmed by the server. See `SyncClient.waitForConfirmation` for the
|
|
390
|
+
* lookup contract; resolves immediately if nothing is in flight.
|
|
391
|
+
*/
|
|
392
|
+
waitForConfirmation(modelName, modelId) {
|
|
393
|
+
return this.syncClient.waitForConfirmation(modelName, modelId);
|
|
394
|
+
}
|
|
395
|
+
// ── Bootstrap + Retry ────────────────────────────────────────────────────
|
|
396
|
+
/**
|
|
397
|
+
* Execute a bootstrap function with timeout protection and automatic retry.
|
|
398
|
+
* Prevents the common issue where bootstrap hangs on startup.
|
|
399
|
+
*/
|
|
400
|
+
async executeBootstrapWithTimeout(bootstrapFn, _context, signal) {
|
|
401
|
+
let lastError = null;
|
|
402
|
+
for (let attempt = 1; attempt <= BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS; attempt++) {
|
|
403
|
+
if (signal?.aborted) {
|
|
404
|
+
throw new DOMException('Initialization aborted', 'AbortError');
|
|
405
|
+
}
|
|
406
|
+
// `navigator.onLine === false` is the MDN-reliable "definitely
|
|
407
|
+
// offline" signal. Don't use `!navigator.onLine`: Node 22+ exposes
|
|
408
|
+
// `globalThis.navigator` with `onLine === undefined`, so the
|
|
409
|
+
// negation false-positives every server-side bootstrap (e.g. the
|
|
410
|
+
// server-side agent.run dispatch path through `connectAgent`).
|
|
411
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
412
|
+
getContext().observability.breadcrumb(`Bootstrap attempt ${attempt} skipped - offline`, 'sync.bootstrap', 'warning');
|
|
413
|
+
throw new AbloConnectionError('Bootstrap skipped - device is offline', {
|
|
414
|
+
code: 'bootstrap_offline',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
getContext().logger.info(`[BaseSyncedStore] Bootstrap attempt ${attempt}/${BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS}`);
|
|
419
|
+
const result = (await Promise.race([
|
|
420
|
+
bootstrapFn(),
|
|
421
|
+
this.createBootstrapTimeout(attempt),
|
|
422
|
+
]));
|
|
423
|
+
getContext().logger.info('[BaseSyncedStore] Bootstrap completed successfully', { attempt });
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
lastError = error;
|
|
428
|
+
const isTimeout = error instanceof Error && error.message.includes('timed out');
|
|
429
|
+
const isAbort = error instanceof DOMException && error.name === 'AbortError';
|
|
430
|
+
const isNetworkError = error instanceof TypeError && error.message.includes('fetch');
|
|
431
|
+
if (isAbort)
|
|
432
|
+
throw error;
|
|
433
|
+
if (SyncSessionError.isSessionError(error))
|
|
434
|
+
throw error;
|
|
435
|
+
if (isNetworkError && typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
436
|
+
getContext().observability.captureBootstrapFailure(error, { type: 'network-offline' });
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
getContext().observability.breadcrumb(`Bootstrap attempt ${attempt} failed`, 'sync.bootstrap', 'warning', { isTimeout, isNetworkError, willRetry: attempt < BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS });
|
|
440
|
+
if (isTimeout && attempt < BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS) {
|
|
441
|
+
getContext().logger.info('[BaseSyncedStore] Resetting state before bootstrap retry');
|
|
442
|
+
this.resetBootstrapState();
|
|
443
|
+
await new Promise((resolve) => setTimeout(resolve, BOOTSTRAP_CONFIG.RETRY_DELAY_MS));
|
|
444
|
+
}
|
|
445
|
+
else if (!isTimeout && attempt < BOOTSTRAP_CONFIG.MAX_RETRY_ATTEMPTS) {
|
|
446
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
throw lastError || new Error('Bootstrap failed after all retry attempts');
|
|
451
|
+
}
|
|
452
|
+
/** Create a timeout promise for bootstrap attempts */
|
|
453
|
+
createBootstrapTimeout(attempt) {
|
|
454
|
+
const timeoutMs = BOOTSTRAP_CONFIG.OVERALL_TIMEOUT_MS + (attempt - 1) * 3_000;
|
|
455
|
+
return new Promise((_, reject) => {
|
|
456
|
+
setTimeout(() => {
|
|
457
|
+
reject(new Error(`Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`));
|
|
458
|
+
}, timeoutMs);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/** Reset bootstrap-related state for a clean retry */
|
|
462
|
+
resetBootstrapState() {
|
|
463
|
+
try {
|
|
464
|
+
this.objectPool.clear({ preserveObserved: true });
|
|
465
|
+
this.queryProcessor.clearCache();
|
|
466
|
+
runInAction(() => { this.dataReady = false; });
|
|
467
|
+
this.modelTypesHydrated.clear();
|
|
468
|
+
this.modelTypeHydrationInFlight.clear();
|
|
469
|
+
getContext().logger.info('[BaseSyncedStore] Bootstrap state reset complete');
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
getContext().observability.breadcrumb('Error resetting bootstrap state', 'sync.bootstrap', 'warning');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// ── Reconnection ─────────────────────────────────────────────────────────
|
|
476
|
+
/** Perform reconnect: bootstrap + WS reconnect. Returns outcome for state machine. */
|
|
477
|
+
async performReconnect() {
|
|
478
|
+
if (!this.userContext)
|
|
479
|
+
return 'network_error';
|
|
480
|
+
try {
|
|
481
|
+
await this.checkSyncGroupShrinkage();
|
|
482
|
+
const requirements = await this.database.requiredBootstrap();
|
|
483
|
+
if (requirements.type === 'full' || requirements.lastSyncId === 0) {
|
|
484
|
+
this.updateSyncStatus({ state: 'syncing', progress: 0 });
|
|
485
|
+
const bootstrapResult = await this.database.bootstrapFromServer(requirements, this.resolveSyncGroups(this.userContext));
|
|
486
|
+
this.applyBootstrapToPool(bootstrapResult);
|
|
487
|
+
this.dataReady = true;
|
|
488
|
+
}
|
|
489
|
+
else if (!this.dataReady) {
|
|
490
|
+
await this.syncClient.hydrateFromDatabase();
|
|
491
|
+
this.dataReady = true;
|
|
492
|
+
}
|
|
493
|
+
if (this.syncWebSocket && !this.syncWebSocket.isConnected()) {
|
|
494
|
+
this.syncWebSocket.resetReconnectAttempts();
|
|
495
|
+
this.syncWebSocket.connect();
|
|
496
|
+
}
|
|
497
|
+
this.updateSyncStatus({ state: 'idle', progress: 100 });
|
|
498
|
+
return 'success';
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
getContext().observability.captureBootstrapFailure(error, { type: 'connection-store-reconnect' });
|
|
502
|
+
if (SyncSessionError.isSessionError(error)) {
|
|
503
|
+
this.syncWebSocket?.setSessionErrorDetected();
|
|
504
|
+
this.syncWebSocket?.disconnect();
|
|
505
|
+
this.updateSyncStatus({ state: 'error', error: error });
|
|
506
|
+
// SECURITY: Clear locally cached data when session is invalid
|
|
507
|
+
this.database.clear().catch(() => { });
|
|
508
|
+
this.objectPool.clear();
|
|
509
|
+
return 'session_error';
|
|
510
|
+
}
|
|
511
|
+
if (!this.dataReady && this.objectPool.size === 0) {
|
|
512
|
+
try {
|
|
513
|
+
await this.syncClient.hydrateFromDatabase();
|
|
514
|
+
if (this.objectPool.size > 0) {
|
|
515
|
+
this.dataReady = true;
|
|
516
|
+
getContext().logger.info('[BaseSyncedStore] Hydrated from local fallback', {
|
|
517
|
+
objectPoolSize: this.objectPool.size,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch (fallbackError) {
|
|
522
|
+
getContext().logger.warn('[BaseSyncedStore] Local fallback failed', {
|
|
523
|
+
error: fallbackError.message,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return 'network_error';
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// ── Sync Group Management ────────────────────────────────────────────────
|
|
531
|
+
/**
|
|
532
|
+
* Handle an actionType 'G' delta.
|
|
533
|
+
*
|
|
534
|
+
* The server emits 'G' via two distinct pathways, distinguished by payload
|
|
535
|
+
* shape:
|
|
536
|
+
*
|
|
537
|
+
* Incremental (EmitGroupAdded): { group, userId }
|
|
538
|
+
* - The recipient was added to a single sync group.
|
|
539
|
+
* - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
|
|
540
|
+
* - No re-bootstrap — entities arrive via the normal insert path.
|
|
541
|
+
*
|
|
542
|
+
* Legacy (EmitGroupChange): { addedGroups, removedGroups }
|
|
543
|
+
* - Single delta carrying the full group membership diff.
|
|
544
|
+
* - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
|
|
545
|
+
* - Deprecated on the server; kept here for wire-level backward compat.
|
|
546
|
+
*/
|
|
547
|
+
async handleSyncGroupChange(delta) {
|
|
548
|
+
const raw = typeof delta.data === 'string' ? JSON.parse(delta.data) : delta.data;
|
|
549
|
+
const rawObj = (raw ?? {});
|
|
550
|
+
// Detect incremental payload shape: { group, userId }
|
|
551
|
+
if (typeof rawObj.group === 'string' && typeof rawObj.userId === 'string') {
|
|
552
|
+
const incremental = {
|
|
553
|
+
group: rawObj.group,
|
|
554
|
+
userId: rawObj.userId,
|
|
555
|
+
};
|
|
556
|
+
await this.handleGroupAdded(incremental, delta.id);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// Legacy payload: { addedGroups, removedGroups }
|
|
560
|
+
const payload = {
|
|
561
|
+
removedGroups: rawObj.removedGroups ?? [],
|
|
562
|
+
addedGroups: rawObj.addedGroups ?? [],
|
|
563
|
+
};
|
|
564
|
+
getContext().logger.info('[BaseSyncedStore] Sync group change received (legacy)', {
|
|
565
|
+
removedGroups: payload.removedGroups,
|
|
566
|
+
addedGroups: payload.addedGroups,
|
|
567
|
+
syncId: delta.id,
|
|
568
|
+
});
|
|
569
|
+
// SECURITY: If groups were removed, clear cached data immediately.
|
|
570
|
+
// This prevents revoked data from persisting if the device goes offline
|
|
571
|
+
// before the full re-bootstrap completes.
|
|
572
|
+
if (payload.removedGroups.length > 0) {
|
|
573
|
+
await this.database.clear();
|
|
574
|
+
this.objectPool.clear();
|
|
575
|
+
getContext().logger.info('[BaseSyncedStore] Cleared cached data due to revoked sync groups', {
|
|
576
|
+
removedGroups: payload.removedGroups,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const updatedGroups = this.computeUpdatedSyncGroups(payload);
|
|
580
|
+
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: updatedGroups });
|
|
581
|
+
this.forceFullRebootstrap();
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Handle an incremental GroupAdded delta.
|
|
585
|
+
*
|
|
586
|
+
* Adds the new group to the subscription metadata without triggering a
|
|
587
|
+
* re-bootstrap. The server will follow up with 'C' (Covering) deltas for
|
|
588
|
+
* each newly-visible entity, which flow through the normal insert path.
|
|
589
|
+
*/
|
|
590
|
+
async handleGroupAdded(payload, syncId) {
|
|
591
|
+
getContext().logger.info('[BaseSyncedStore] Group added (incremental)', {
|
|
592
|
+
group: payload.group,
|
|
593
|
+
syncId,
|
|
594
|
+
});
|
|
595
|
+
const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
|
|
596
|
+
current.add(payload.group);
|
|
597
|
+
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
598
|
+
// Note: no forceFullRebootstrap() — covering deltas will bring the entities.
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Handle an actionType 'S' (GroupRemoved) delta.
|
|
602
|
+
*
|
|
603
|
+
* Signals that the recipient has lost access to a sync group. Because
|
|
604
|
+
* the client does not track per-entity group membership, we can't
|
|
605
|
+
* selectively purge entities belonging to that group. The safe fallback
|
|
606
|
+
* is the legacy behavior: clear local state and force a re-bootstrap
|
|
607
|
+
* with the updated group list.
|
|
608
|
+
*
|
|
609
|
+
* Future optimization: track group membership in the ObjectPool so 'S'
|
|
610
|
+
* can do a targeted purge instead of a full re-bootstrap.
|
|
611
|
+
*/
|
|
612
|
+
async handleGroupRemoved(delta) {
|
|
613
|
+
const raw = typeof delta.data === 'string' ? JSON.parse(delta.data) : delta.data;
|
|
614
|
+
const rawObj = (raw ?? {});
|
|
615
|
+
const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
|
|
616
|
+
if (!groupKey) {
|
|
617
|
+
getContext().logger.warn('[BaseSyncedStore] Group removed delta missing group key', {
|
|
618
|
+
syncId: delta.id,
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
getContext().logger.info('[BaseSyncedStore] Group removed', {
|
|
623
|
+
group: groupKey,
|
|
624
|
+
syncId: delta.id,
|
|
625
|
+
});
|
|
626
|
+
// SECURITY: Clear cached data before re-bootstrap. This prevents
|
|
627
|
+
// revoked-group data from persisting if the device goes offline
|
|
628
|
+
// between receiving 'S' and completing the re-bootstrap.
|
|
629
|
+
await this.database.clear();
|
|
630
|
+
this.objectPool.clear();
|
|
631
|
+
// Update subscription metadata so the re-bootstrap fetches the
|
|
632
|
+
// correct set of groups.
|
|
633
|
+
const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
|
|
634
|
+
current.delete(groupKey);
|
|
635
|
+
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
636
|
+
this.forceFullRebootstrap();
|
|
637
|
+
}
|
|
638
|
+
/** Compute new sync groups after applying additions and removals */
|
|
639
|
+
computeUpdatedSyncGroups(payload) {
|
|
640
|
+
const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
|
|
641
|
+
for (const g of payload.removedGroups)
|
|
642
|
+
current.delete(g);
|
|
643
|
+
for (const g of payload.addedGroups)
|
|
644
|
+
current.add(g);
|
|
645
|
+
return Array.from(current);
|
|
646
|
+
}
|
|
647
|
+
/** Force a full re-bootstrap via connection lifecycle event.
|
|
648
|
+
*
|
|
649
|
+
* No-op for `bootstrapMode: 'none'` participants — they never pull
|
|
650
|
+
* baseline state, so a "force re-bootstrap" trigger (sync-group
|
|
651
|
+
* shrink, scope revocation) instead just flushes the local pool and
|
|
652
|
+
* relies on covering deltas to repopulate the data they actually
|
|
653
|
+
* subscribe to.
|
|
654
|
+
*/
|
|
655
|
+
forceFullRebootstrap() {
|
|
656
|
+
if (this.userContext?.bootstrapMode === 'none') {
|
|
657
|
+
getContext().logger.info('[BaseSyncedStore] forceFullRebootstrap skipped (bootstrapMode=none)');
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
this.database.markRequiresFullBootstrap();
|
|
661
|
+
this.syncWebSocket?.disconnect();
|
|
662
|
+
this.onConnectionEvent?.('WS_DISCONNECTED');
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Single source of truth for the sync-group list this session is
|
|
666
|
+
* subscribed to. Server-issued (`context.syncGroups`) is authoritative.
|
|
667
|
+
* When absent, the SDK subscribes to no explicit groups. Both
|
|
668
|
+
* `checkSyncGroupShrinkage` and `setupWebSocketSync` resolve through
|
|
669
|
+
* here so the WS subscription and the security-critical shrinkage
|
|
670
|
+
* check can never disagree.
|
|
671
|
+
*/
|
|
672
|
+
resolveSyncGroups(context) {
|
|
673
|
+
if (context.syncGroups && context.syncGroups.length > 0) {
|
|
674
|
+
return context.syncGroups;
|
|
675
|
+
}
|
|
676
|
+
return [];
|
|
677
|
+
}
|
|
678
|
+
/** Check if sync groups shrank since last session — force full bootstrap if so */
|
|
679
|
+
async checkSyncGroupShrinkage() {
|
|
680
|
+
if (!this.userContext)
|
|
681
|
+
return;
|
|
682
|
+
try {
|
|
683
|
+
const metadata = await this.database.getWorkspaceMetadata();
|
|
684
|
+
const stored = metadata?.subscribedSyncGroups ?? [];
|
|
685
|
+
if (stored.length === 0)
|
|
686
|
+
return;
|
|
687
|
+
const currentGroups = new Set(this.resolveSyncGroups(this.userContext));
|
|
688
|
+
const removedGroups = stored.filter((g) => !currentGroups.has(g));
|
|
689
|
+
if (removedGroups.length > 0) {
|
|
690
|
+
getContext().logger.info('[BaseSyncedStore] Sync groups shrank — forcing full bootstrap', {
|
|
691
|
+
removedGroups,
|
|
692
|
+
storedCount: stored.length,
|
|
693
|
+
currentCount: currentGroups.size,
|
|
694
|
+
});
|
|
695
|
+
// SECURITY: Clear cached data before re-bootstrap to prevent
|
|
696
|
+
// revoked-group data from persisting if device goes offline
|
|
697
|
+
await this.database.clear();
|
|
698
|
+
this.objectPool.clear();
|
|
699
|
+
this.database.markRequiresFullBootstrap();
|
|
700
|
+
}
|
|
701
|
+
await this.database.updateWorkspaceMetadata({
|
|
702
|
+
subscribedSyncGroups: Array.from(currentGroups),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
getContext().logger.warn('[BaseSyncedStore] Failed to check sync group shrinkage', {
|
|
707
|
+
error: error instanceof Error ? error.message : String(error),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** Apply bootstrap data to the ObjectPool with ghost removal */
|
|
712
|
+
/** Apply bootstrap data to the ObjectPool. Delegates pool writes to SyncClient. */
|
|
713
|
+
applyBootstrapToPool(bootstrapResult, protectedIds) {
|
|
714
|
+
const { bootstrapData } = bootstrapResult;
|
|
715
|
+
// Partial bootstrap: Database.processDeltaBatch already wrote the deltas
|
|
716
|
+
// to IDB. Route the same results through the delta-apply path so the
|
|
717
|
+
// in-memory pool evicts deleted entities (and updates modified ones).
|
|
718
|
+
// Without this, reconnect DELETEs persist to IDB but the canvas keeps
|
|
719
|
+
// showing ghost layers until a full reload.
|
|
720
|
+
if (bootstrapData.type === 'partial') {
|
|
721
|
+
const deltaResults = bootstrapResult.deltaResults;
|
|
722
|
+
if (deltaResults && deltaResults.length > 0) {
|
|
723
|
+
this.syncClient.applyDeltaBatchToPool(deltaResults, (name, data) => this.enrichRelations(name, data));
|
|
724
|
+
}
|
|
725
|
+
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
|
|
726
|
+
}
|
|
727
|
+
if (!bootstrapData.models) {
|
|
728
|
+
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
|
|
729
|
+
}
|
|
730
|
+
const start = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
731
|
+
// SyncClient owns: model creation, healing, pool upsert, ghost removal
|
|
732
|
+
const stats = this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds);
|
|
733
|
+
const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - start);
|
|
734
|
+
getContext().logger.info('[BaseSyncedStore] Bootstrap applied', {
|
|
735
|
+
...stats, elapsedMs, poolSize: this.objectPool.size,
|
|
736
|
+
});
|
|
737
|
+
return { ...stats, elapsedMs };
|
|
738
|
+
}
|
|
739
|
+
// ── Initialize + Lifecycle ───────────────────────────────────────────────
|
|
740
|
+
/**
|
|
741
|
+
* Initialize the sync engine with user context.
|
|
742
|
+
* Offline-first: hydrate from IDB → show UI → bootstrap from server in background.
|
|
743
|
+
*/
|
|
744
|
+
*initialize(context, signal) {
|
|
745
|
+
if (this.initialized)
|
|
746
|
+
return { success: true };
|
|
747
|
+
this.userContext = context;
|
|
748
|
+
// Propagate identity to SyncClient. Without this, every mutation
|
|
749
|
+
// silently drops in `processPendingMutations` / `stageMutation` with
|
|
750
|
+
// `userId=null, organizationId=null`. Previously the SDK assumed
|
|
751
|
+
// callers would call `syncClient.initialize()` themselves as a
|
|
752
|
+
// separate step — that never happened from createSyncEngine, and
|
|
753
|
+
// the drop was invisible because both guard sites just early-return
|
|
754
|
+
// rather than throw. The right fix is to do it here where the store
|
|
755
|
+
// receives the context, so identity is one source of truth.
|
|
756
|
+
yield this.syncClient.initialize(context.userId, context.organizationId);
|
|
757
|
+
try {
|
|
758
|
+
this.updateSyncStatus({ state: 'syncing', progress: 0 });
|
|
759
|
+
// Open database
|
|
760
|
+
yield this.database.open(context.userId, context.organizationId);
|
|
761
|
+
// Hydrate from IndexedDB (fast, cached data)
|
|
762
|
+
let hasLocalData = false;
|
|
763
|
+
try {
|
|
764
|
+
yield this.syncClient.hydrateFromDatabase();
|
|
765
|
+
hasLocalData = this.objectPool.size > 0;
|
|
766
|
+
}
|
|
767
|
+
catch (hydrateError) {
|
|
768
|
+
getContext().logger.warn('[sync-engine] IDB hydration failed', { error: hydrateError });
|
|
769
|
+
getContext().observability.captureBootstrapFailure(hydrateError, { type: 'hydration-from-idb' });
|
|
770
|
+
}
|
|
771
|
+
// Get sync baseline for WebSocket
|
|
772
|
+
const lastSyncId = (yield this.database.getLastSyncId());
|
|
773
|
+
this.lastAckedId = Math.max(this.lastAckedId, lastSyncId || 0);
|
|
774
|
+
this.highestProcessedSyncId = this.lastAckedId;
|
|
775
|
+
try {
|
|
776
|
+
const versions = (yield this.database.getVersionVector());
|
|
777
|
+
if (versions && typeof versions === 'object')
|
|
778
|
+
Object.assign(this.versionVector, versions);
|
|
779
|
+
}
|
|
780
|
+
catch { }
|
|
781
|
+
// If local data available, show UI immediately
|
|
782
|
+
if (hasLocalData) {
|
|
783
|
+
this.dataReady = true;
|
|
784
|
+
this.initialized = true;
|
|
785
|
+
this.updateSyncStatus({ state: 'syncing', progress: 50 });
|
|
786
|
+
}
|
|
787
|
+
// Setup WebSocket
|
|
788
|
+
this.setupWebSocketSync(context, lastSyncId);
|
|
789
|
+
// Bootstrap from server if needed.
|
|
790
|
+
//
|
|
791
|
+
// `bootstrapMode: 'none'` participants (agent-worker, headless
|
|
792
|
+
// task runners) skip baseline replication — they read via
|
|
793
|
+
// `resource.retrieve()` round-trips and rely on covering deltas
|
|
794
|
+
// from filtered subscriptions to populate the pool lazily. The
|
|
795
|
+
// WS is already open by `setupWebSocketSync` above, so live
|
|
796
|
+
// delta flow works regardless of this branch.
|
|
797
|
+
const requirements = (yield this.database.requiredBootstrap());
|
|
798
|
+
if (context.bootstrapMode === 'none') {
|
|
799
|
+
getContext().logger.info('[BaseSyncedStore] Bootstrap skipped (bootstrapMode=none)', { kind: context.kind ?? 'user' });
|
|
800
|
+
// `setupWebSocketSync` above creates the SyncWebSocket and
|
|
801
|
+
// initiates the upgrade, but it does NOT await the 'connected'
|
|
802
|
+
// event — it returns synchronously after wiring listeners.
|
|
803
|
+
// For bootstrapMode='none' consumers (agent-worker, headless
|
|
804
|
+
// task runners), this branch is the entire body of initialize()
|
|
805
|
+
// after the WS is set up, so `ready()` would otherwise resolve
|
|
806
|
+
// while the WS is still in 'connecting' state. The very next
|
|
807
|
+
// `commits.create` then throws "SyncWebSocket not connected".
|
|
808
|
+
//
|
|
809
|
+
// For bootstrapMode='full' consumers we don't need this await:
|
|
810
|
+
// `executeBootstrapWithTimeout` below sends the bootstrap RPC
|
|
811
|
+
// which inherently requires the WS to be open, so it surfaces
|
|
812
|
+
// a connection error if the upgrade hasn't completed.
|
|
813
|
+
//
|
|
814
|
+
// 5s bound is generous (typical connect is <100ms); past that
|
|
815
|
+
// we return anyway and let the next commit attempt fail loudly
|
|
816
|
+
// rather than block initialize() forever.
|
|
817
|
+
yield this.waitForWebSocketConnected(5000);
|
|
818
|
+
}
|
|
819
|
+
else if (requirements.type !== 'local') {
|
|
820
|
+
if (hasLocalData) {
|
|
821
|
+
// Background bootstrap — don't block UI
|
|
822
|
+
this.performBackgroundBootstrap(requirements, context, signal);
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
// First load — must wait for server data
|
|
826
|
+
yield this.executeBootstrapWithTimeout(async () => {
|
|
827
|
+
await this.database.bootstrapFromServer(requirements, this.resolveSyncGroups(context));
|
|
828
|
+
}, context, signal);
|
|
829
|
+
yield this.syncClient.hydrateFromDatabase();
|
|
830
|
+
this.dataReady = true;
|
|
831
|
+
this.initialized = true;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (!this.initialized)
|
|
835
|
+
this.initialized = true;
|
|
836
|
+
if (!this.dataReady) {
|
|
837
|
+
this.dataReady = true;
|
|
838
|
+
}
|
|
839
|
+
this.updateSyncStatus({ state: 'idle', progress: 100 });
|
|
840
|
+
return { success: true };
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
const isAbort = error instanceof DOMException && error.name === 'AbortError';
|
|
844
|
+
if (isAbort) {
|
|
845
|
+
this.dataReady = false;
|
|
846
|
+
this.initialized = false;
|
|
847
|
+
this.updateSyncStatus({ state: 'idle', progress: 0 });
|
|
848
|
+
return { success: false, error: error };
|
|
849
|
+
}
|
|
850
|
+
const isSession = SyncSessionError.isSessionError(error);
|
|
851
|
+
getContext().observability.captureBootstrapFailure(error, { type: 'initialize' });
|
|
852
|
+
if (isSession) {
|
|
853
|
+
this.syncWebSocket?.setSessionErrorDetected();
|
|
854
|
+
this.syncWebSocket?.disconnect();
|
|
855
|
+
this.updateSyncStatus({ state: 'error', error: error });
|
|
856
|
+
return { success: false, error: error };
|
|
857
|
+
}
|
|
858
|
+
// Fallback: show local data if available
|
|
859
|
+
if (this.objectPool.size === 0) {
|
|
860
|
+
try {
|
|
861
|
+
yield this.syncClient.hydrateFromDatabase();
|
|
862
|
+
}
|
|
863
|
+
catch { }
|
|
864
|
+
}
|
|
865
|
+
if (this.objectPool.size > 0) {
|
|
866
|
+
this.dataReady = true;
|
|
867
|
+
this.initialized = true;
|
|
868
|
+
this.updateSyncStatus(this.syncWebSocket?.isConnected()
|
|
869
|
+
? { state: 'idle', progress: 100 }
|
|
870
|
+
: { state: 'offline', offlineSince: new Date() });
|
|
871
|
+
return { success: true };
|
|
872
|
+
}
|
|
873
|
+
this.updateSyncStatus({ state: 'error', error: error });
|
|
874
|
+
return { success: false, error: error };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/** Background bootstrap — non-blocking, user sees cached data while this runs */
|
|
878
|
+
async performBackgroundBootstrap(requirements, context, signal) {
|
|
879
|
+
await this.withDeltaQueuing(async () => {
|
|
880
|
+
try {
|
|
881
|
+
const preBootstrapIds = new Set(this.objectPool.getAllIds());
|
|
882
|
+
const bootstrapResult = await this.database.bootstrapFromServer(requirements, this.resolveSyncGroups(context));
|
|
883
|
+
const deltaProtectedIds = this.collectDeltaProtectedIds(preBootstrapIds);
|
|
884
|
+
this.applyBootstrapToPool(bootstrapResult, deltaProtectedIds);
|
|
885
|
+
this.updateSyncStatus({ state: 'idle', progress: 100 });
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
getContext().logger.warn('[sync-engine] Background bootstrap failed', {
|
|
889
|
+
error: error instanceof Error ? error.message : String(error),
|
|
890
|
+
cause: error,
|
|
891
|
+
});
|
|
892
|
+
getContext().observability.captureBootstrapFailure(error, { type: 'background' });
|
|
893
|
+
if (SyncSessionError.isSessionError(error)) {
|
|
894
|
+
this.syncWebSocket?.setSessionErrorDetected();
|
|
895
|
+
this.syncWebSocket?.disconnect();
|
|
896
|
+
this.updateSyncStatus({ state: 'error', error: error });
|
|
897
|
+
}
|
|
898
|
+
else if (!this.syncWebSocket?.isConnected()) {
|
|
899
|
+
this.updateSyncStatus({ state: 'offline', offlineSince: new Date() });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
/** Run bootstrap with delta queuing to prevent race conditions */
|
|
905
|
+
async withDeltaQueuing(fn) {
|
|
906
|
+
this.activeBootstrapCount++;
|
|
907
|
+
if (this.bootstrapDeltaQueue === null)
|
|
908
|
+
this.bootstrapDeltaQueue = [];
|
|
909
|
+
try {
|
|
910
|
+
return await fn();
|
|
911
|
+
}
|
|
912
|
+
finally {
|
|
913
|
+
this.activeBootstrapCount--;
|
|
914
|
+
if (this.activeBootstrapCount === 0)
|
|
915
|
+
this.replayQueuedDeltas();
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
/** Collect IDs that must survive ghost removal (added by deltas during bootstrap) */
|
|
919
|
+
collectDeltaProtectedIds(preBootstrapIds) {
|
|
920
|
+
const protectedIds = new Set();
|
|
921
|
+
for (const id of this.objectPool.getAllIds()) {
|
|
922
|
+
if (!preBootstrapIds.has(id))
|
|
923
|
+
protectedIds.add(id);
|
|
924
|
+
}
|
|
925
|
+
for (const delta of this.bootstrapDeltaQueue ?? []) {
|
|
926
|
+
if (delta.actionType !== 'D' && delta.modelId)
|
|
927
|
+
protectedIds.add(delta.modelId);
|
|
928
|
+
}
|
|
929
|
+
return protectedIds;
|
|
930
|
+
}
|
|
931
|
+
/** Replay deltas queued during bootstrap */
|
|
932
|
+
replayQueuedDeltas() {
|
|
933
|
+
const queue = this.bootstrapDeltaQueue;
|
|
934
|
+
this.bootstrapDeltaQueue = null;
|
|
935
|
+
if (!queue || queue.length === 0)
|
|
936
|
+
return;
|
|
937
|
+
for (const delta of queue)
|
|
938
|
+
this.processDeltaWithBatching(delta);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Factory for the internal `ConnectionManager`. Override to return
|
|
942
|
+
* `null` in subclasses that own their own connection lifecycle
|
|
943
|
+
* (tests, headless runners, custom FSM wrappers). Default builds a
|
|
944
|
+
* manager scoped to `_syncServerUrl` with production backoff.
|
|
945
|
+
*
|
|
946
|
+
* **Agent participants get `null`.** The FSM is wired around browser
|
|
947
|
+
* events (`visibilitychange`, `online`/`offline`, watchdog) which are
|
|
948
|
+
* meaningful for human-facing tabs and meaningless for headless agent
|
|
949
|
+
* processes. On agent hosts the FSM has no event source to drive
|
|
950
|
+
* recovery — and worse, its `offline` entry action calls
|
|
951
|
+
* `syncWebSocket.disconnect()` which sets `isManualClose=true` and
|
|
952
|
+
* cancels the reconnect that `SyncWebSocket.onclose` had just
|
|
953
|
+
* scheduled. The two recovery systems fight and the browser-only one
|
|
954
|
+
* wins by destroying the Node-compatible one's work. Returning `null`
|
|
955
|
+
* for agents leaves `SyncWebSocket`'s exponential-backoff
|
|
956
|
+
* `scheduleReconnect()` as the sole recovery path — which is correct
|
|
957
|
+
* for server-side agents whether they run on Node, Bun, Deno, or
|
|
958
|
+
* inside a Docker container with no `window`.
|
|
959
|
+
*
|
|
960
|
+
* Why gate on `kind` and not `typeof window`: env detection by global
|
|
961
|
+
* existence is fragile (SSR polyfills, jsdom, sandboxed hosts). The
|
|
962
|
+
* participant kind is the actual semantic axis — "is this a human-
|
|
963
|
+
* driven session" vs "is this a server agent". The latter never has
|
|
964
|
+
* a tab to lose focus or a network adapter to wake up.
|
|
965
|
+
*/
|
|
966
|
+
createConnectionManager(kind) {
|
|
967
|
+
if (kind === 'agent')
|
|
968
|
+
return null;
|
|
969
|
+
return new ConnectionManager({ baseUrl: this._syncServerUrl });
|
|
970
|
+
}
|
|
971
|
+
/** Disconnect and clean up all resources */
|
|
972
|
+
async disconnect() {
|
|
973
|
+
if (this.batchTimer) {
|
|
974
|
+
clearTimeout(this.batchTimer);
|
|
975
|
+
this.batchTimer = null;
|
|
976
|
+
}
|
|
977
|
+
this.pendingDeltas = [];
|
|
978
|
+
for (const dispose of this.disposers)
|
|
979
|
+
dispose();
|
|
980
|
+
this.disposers = [];
|
|
981
|
+
if (this.connectionManager) {
|
|
982
|
+
this.connectionManager.dispose();
|
|
983
|
+
this.connectionManager = null;
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
const last = this.syncWebSocket?.getLastSyncId?.() || 0;
|
|
987
|
+
if (last > 0)
|
|
988
|
+
await this.database.updateWorkspaceMetadata({ lastSyncId: last });
|
|
989
|
+
}
|
|
990
|
+
catch { }
|
|
991
|
+
if (this.syncWebSocket) {
|
|
992
|
+
this.syncWebSocket.disconnect();
|
|
993
|
+
this.syncWebSocket = null;
|
|
994
|
+
}
|
|
995
|
+
this.syncClient.disconnect();
|
|
996
|
+
this.queryProcessor.clearCache();
|
|
997
|
+
this.updateSyncStatus({ state: 'offline' });
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Destroy every IndexedDB database owned by the sync engine.
|
|
1001
|
+
*
|
|
1002
|
+
* First disconnects (releases WebSocket + timers + in-memory caches),
|
|
1003
|
+
* then walks `indexedDB.databases()` and deletes any database whose
|
|
1004
|
+
* name starts with `ablo_` or `ablo-`. This covers:
|
|
1005
|
+
* - `ablo_<hash>` workspace data DBs
|
|
1006
|
+
* - `ablo_databases` meta registry
|
|
1007
|
+
* - `ablo-sync` offline mutation queue
|
|
1008
|
+
*
|
|
1009
|
+
* Use case: session expiry (previous-user data must not persist on
|
|
1010
|
+
* disk before the next sign-in races into a corrupted state) or
|
|
1011
|
+
* explicit user-initiated logout.
|
|
1012
|
+
*
|
|
1013
|
+
* Best-effort: swallows individual delete errors. Some browsers do
|
|
1014
|
+
* not support `indexedDB.databases()` — the method returns without
|
|
1015
|
+
* deleting in that case, same behavior as the pre-SDK app code.
|
|
1016
|
+
*/
|
|
1017
|
+
async purge() {
|
|
1018
|
+
try {
|
|
1019
|
+
await this.disconnect();
|
|
1020
|
+
}
|
|
1021
|
+
catch { }
|
|
1022
|
+
if (typeof indexedDB === 'undefined' || typeof indexedDB.databases !== 'function') {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
try {
|
|
1026
|
+
const dbs = await indexedDB.databases();
|
|
1027
|
+
for (const db of dbs) {
|
|
1028
|
+
if (!db.name)
|
|
1029
|
+
continue;
|
|
1030
|
+
if (db.name.startsWith('ablo_') || db.name.startsWith('ablo-')) {
|
|
1031
|
+
try {
|
|
1032
|
+
indexedDB.deleteDatabase(db.name);
|
|
1033
|
+
}
|
|
1034
|
+
catch { }
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
catch { }
|
|
1039
|
+
}
|
|
1040
|
+
// ── WebSocket Setup ───────────────────────────────────────────────────────
|
|
1041
|
+
/**
|
|
1042
|
+
* Create WebSocket connection and wire all event handlers.
|
|
1043
|
+
* Handles: deltas, batches, presence, bootstrap_required, errors, reconnection.
|
|
1044
|
+
*/
|
|
1045
|
+
/**
|
|
1046
|
+
* Block until the WebSocket reports a `connected` event, or until
|
|
1047
|
+
* `timeoutMs` elapses (returns false on timeout, true on connect).
|
|
1048
|
+
* Used by `initialize()` for `bootstrapMode: 'none'` consumers to
|
|
1049
|
+
* honor `ready()`'s "WS is connected when this resolves" contract
|
|
1050
|
+
* — `setupWebSocketSync` is fire-and-forget on the upgrade, and
|
|
1051
|
+
* without an explicit wait the next mutation can race the open.
|
|
1052
|
+
*
|
|
1053
|
+
* Resolves immediately if the WS is already connected (e.g., warm
|
|
1054
|
+
* reconnect after redeploy). Resolves false on timeout rather than
|
|
1055
|
+
* throwing so initialize() can complete and let the caller's first
|
|
1056
|
+
* mutation attempt surface a clearer error.
|
|
1057
|
+
*/
|
|
1058
|
+
async waitForWebSocketConnected(timeoutMs) {
|
|
1059
|
+
const ws = this.syncWebSocket;
|
|
1060
|
+
if (!ws)
|
|
1061
|
+
return false;
|
|
1062
|
+
if (ws.isConnected())
|
|
1063
|
+
return true;
|
|
1064
|
+
return new Promise((resolve) => {
|
|
1065
|
+
let resolved = false;
|
|
1066
|
+
const unsubscribe = ws.subscribe('connected', () => {
|
|
1067
|
+
if (resolved)
|
|
1068
|
+
return;
|
|
1069
|
+
resolved = true;
|
|
1070
|
+
unsubscribe();
|
|
1071
|
+
clearTimeout(timer);
|
|
1072
|
+
resolve(true);
|
|
1073
|
+
});
|
|
1074
|
+
const timer = setTimeout(() => {
|
|
1075
|
+
if (resolved)
|
|
1076
|
+
return;
|
|
1077
|
+
resolved = true;
|
|
1078
|
+
unsubscribe();
|
|
1079
|
+
getContext().logger.warn(`[BaseSyncedStore] waitForWebSocketConnected timed out after ${timeoutMs}ms — initialize() will return but the next mutation may race the upgrade.`);
|
|
1080
|
+
resolve(false);
|
|
1081
|
+
}, timeoutMs);
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
setupWebSocketSync(context, lastSyncId) {
|
|
1085
|
+
if (!context.userId || !context.organizationId) {
|
|
1086
|
+
getContext().observability.breadcrumb('Cannot setup WebSocket sync without user context', 'sync.websocket', 'warning');
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
this.syncWebSocket = new SyncWebSocket({
|
|
1090
|
+
baseUrl: this._syncServerUrl,
|
|
1091
|
+
userId: context.userId,
|
|
1092
|
+
organizationId: context.organizationId,
|
|
1093
|
+
syncGroups: [...this.resolveSyncGroups(context)],
|
|
1094
|
+
lastSyncId,
|
|
1095
|
+
versions: this.versionVector,
|
|
1096
|
+
kind: context.kind,
|
|
1097
|
+
capabilityToken: context.capabilityToken,
|
|
1098
|
+
capabilities: {
|
|
1099
|
+
partialBootstrap: true,
|
|
1100
|
+
compressedDeltas: true,
|
|
1101
|
+
streamingBootstrap: true,
|
|
1102
|
+
batchedDeltas: true,
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
// Connection events → forward to connection lifecycle callback
|
|
1106
|
+
const onConnected = this.syncWebSocket.subscribe('connected', () => {
|
|
1107
|
+
this.syncClient.markConnected();
|
|
1108
|
+
this.onConnectionEvent?.('WS_CONNECTED');
|
|
1109
|
+
if (this.dataReady) {
|
|
1110
|
+
this.updateSyncStatus({ state: 'idle', offlineSince: undefined });
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
this.updateSyncStatus({ offlineSince: undefined });
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
const onDisconnected = this.syncWebSocket.subscribe('disconnected', () => {
|
|
1117
|
+
this.syncClient.disconnect();
|
|
1118
|
+
this.onConnectionEvent?.('WS_DISCONNECTED');
|
|
1119
|
+
this.updateSyncStatus({ state: 'offline', offlineSince: new Date() });
|
|
1120
|
+
});
|
|
1121
|
+
const onReconnecting = this.syncWebSocket.subscribe('reconnecting', ({ attempt, delay }) => {
|
|
1122
|
+
getContext().logger.info('[BaseSyncedStore] WebSocket reconnecting', { attempt, delay });
|
|
1123
|
+
this.updateSyncStatus({ state: 'reconnecting' });
|
|
1124
|
+
});
|
|
1125
|
+
// Delta events → feed into processing pipeline
|
|
1126
|
+
const onDelta = this.syncWebSocket.subscribe('delta', (delta) => {
|
|
1127
|
+
this.processDeltaWithBatching(delta);
|
|
1128
|
+
});
|
|
1129
|
+
const onDeltaBatch = this.syncWebSocket.subscribe('delta_batch', (deltas) => {
|
|
1130
|
+
deltas.forEach((delta) => this.processDeltaWithBatching(delta));
|
|
1131
|
+
});
|
|
1132
|
+
// Bootstrap events
|
|
1133
|
+
const onBootstrapRequired = this.syncWebSocket.subscribe('bootstrap_required', (hint) => { this.handleBootstrapRequired(hint); });
|
|
1134
|
+
const onBootstrapData = this.syncWebSocket.subscribe('bootstrap_data', (data) => {
|
|
1135
|
+
this.handleBootstrapData(data);
|
|
1136
|
+
});
|
|
1137
|
+
const onPresenceUpdate = this.syncWebSocket.subscribe('presence_update', (data) => {
|
|
1138
|
+
this.handlePresenceUpdate(data);
|
|
1139
|
+
});
|
|
1140
|
+
// Error events
|
|
1141
|
+
const onError = this.syncWebSocket.subscribe('error', (error) => {
|
|
1142
|
+
if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
|
|
1143
|
+
this.updateSyncStatus({ state: 'offline', offlineSince: new Date() });
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
this.updateSyncStatus({ state: 'error', error });
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
const onSessionError = this.syncWebSocket.subscribe('session_error', (error) => {
|
|
1150
|
+
getContext().observability.captureWebSocketError({ context: 'session-error', error: error.message });
|
|
1151
|
+
this.onConnectionEvent?.('WS_SESSION_ERROR');
|
|
1152
|
+
for (const listener of this.sessionErrorListeners) {
|
|
1153
|
+
try {
|
|
1154
|
+
listener(error);
|
|
1155
|
+
}
|
|
1156
|
+
catch { }
|
|
1157
|
+
}
|
|
1158
|
+
this.updateSyncStatus({ state: 'error', error, isSessionError: true });
|
|
1159
|
+
// SECURITY: Clear IndexedDB data on session expiry.
|
|
1160
|
+
// When auth is revoked, locally cached data must not persist on disk.
|
|
1161
|
+
this.database.clear().catch((clearErr) => {
|
|
1162
|
+
getContext().logger.error('[BaseSyncedStore] Failed to clear database on session error', clearErr);
|
|
1163
|
+
});
|
|
1164
|
+
this.objectPool.clear();
|
|
1165
|
+
});
|
|
1166
|
+
// Handshake failed: WS close before open. The HTTP status is hidden
|
|
1167
|
+
// behind close code 1006, so we can't tell whether the server rejected
|
|
1168
|
+
// auth (401/403) or the connection never reached the server (DNS/TLS/LB).
|
|
1169
|
+
// Forward a dedicated event so the connection-lifecycle owner can run
|
|
1170
|
+
// an authenticated HTTP probe to disambiguate.
|
|
1171
|
+
const onHandshakeFailed = this.syncWebSocket.subscribe('handshake_failed', () => {
|
|
1172
|
+
this.onConnectionEvent?.('WS_HANDSHAKE_FAILED');
|
|
1173
|
+
this.updateSyncStatus({ state: 'offline', offlineSince: new Date() });
|
|
1174
|
+
});
|
|
1175
|
+
const onReconnectFailed = this.syncWebSocket.subscribe('reconnect_failed', ({ attempts }) => {
|
|
1176
|
+
getContext().logger.warn('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
|
|
1177
|
+
this.updateSyncStatus({ state: 'reconnecting' });
|
|
1178
|
+
});
|
|
1179
|
+
this.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed);
|
|
1180
|
+
// ── Connection FSM ────────────────────────────────────────────
|
|
1181
|
+
// Instantiate + start the SDK's ConnectionManager so every
|
|
1182
|
+
// consumer gets correct online/offline recovery. Previously this
|
|
1183
|
+
// was an external concern (each app rebuilt its own FSM); now
|
|
1184
|
+
// it's default behavior. The `onConnectionEvent` hook stays as
|
|
1185
|
+
// the bridge — WS events fire the hook, the hook forwards into
|
|
1186
|
+
// the FSM.
|
|
1187
|
+
this.connectionManager = this.createConnectionManager(context.kind);
|
|
1188
|
+
if (this.connectionManager) {
|
|
1189
|
+
const manager = this.connectionManager;
|
|
1190
|
+
// Preserve any externally-set onConnectionEvent — chain rather
|
|
1191
|
+
// than overwrite, so subclasses that wire a secondary consumer
|
|
1192
|
+
// still receive events.
|
|
1193
|
+
const priorHook = this.onConnectionEvent;
|
|
1194
|
+
this.onConnectionEvent = (event) => {
|
|
1195
|
+
try {
|
|
1196
|
+
priorHook?.(event);
|
|
1197
|
+
}
|
|
1198
|
+
catch { /* don't let subclass crash the FSM */ }
|
|
1199
|
+
switch (event) {
|
|
1200
|
+
case 'WS_CONNECTED':
|
|
1201
|
+
manager.send({ type: 'WS_CONNECTED' });
|
|
1202
|
+
break;
|
|
1203
|
+
case 'WS_DISCONNECTED':
|
|
1204
|
+
manager.send({ type: 'WS_DISCONNECTED' });
|
|
1205
|
+
break;
|
|
1206
|
+
case 'WS_SESSION_ERROR':
|
|
1207
|
+
manager.send({ type: 'WS_SESSION_ERROR' });
|
|
1208
|
+
break;
|
|
1209
|
+
case 'WS_HANDSHAKE_FAILED':
|
|
1210
|
+
manager.send({ type: 'WS_HANDSHAKE_FAILED' });
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
manager.start({
|
|
1215
|
+
onReconnect: () => this.performReconnect(),
|
|
1216
|
+
onSessionExpired: () => {
|
|
1217
|
+
const err = new SyncSessionError('Session expired');
|
|
1218
|
+
for (const listener of this.sessionErrorListeners) {
|
|
1219
|
+
try {
|
|
1220
|
+
listener(err);
|
|
1221
|
+
}
|
|
1222
|
+
catch { }
|
|
1223
|
+
}
|
|
1224
|
+
},
|
|
1225
|
+
onDisconnectWebSocket: () => {
|
|
1226
|
+
this.syncWebSocket?.disconnect();
|
|
1227
|
+
},
|
|
1228
|
+
// Mirror FSM transitions into the visible `syncStatus.state` so
|
|
1229
|
+
// the UI can show "Reconnecting…" while the FSM cycles through
|
|
1230
|
+
// probing / reconnecting / backoff. Previously these states
|
|
1231
|
+
// were opaque to the UI, leaving the sidebar pinned to
|
|
1232
|
+
// "offline" for the entire recovery window — exactly the
|
|
1233
|
+
// confusing UX the warning log was trying to surface.
|
|
1234
|
+
//
|
|
1235
|
+
// We only override `state` here; `error` / `progress` / etc.
|
|
1236
|
+
// continue to be set by the WebSocket subscription handlers
|
|
1237
|
+
// and bootstrap pipeline, which know more than the FSM does.
|
|
1238
|
+
onStateChange: (next) => {
|
|
1239
|
+
switch (next) {
|
|
1240
|
+
case 'connected':
|
|
1241
|
+
// Don't clobber an in-flight 'syncing' / 'idle' update
|
|
1242
|
+
// that the bootstrap pipeline might be midway through —
|
|
1243
|
+
// those handlers run their own `updateSyncStatus`. Only
|
|
1244
|
+
// promote out of an offline / reconnecting / error label.
|
|
1245
|
+
if (this.syncStatus.state === 'offline' ||
|
|
1246
|
+
this.syncStatus.state === 'reconnecting' ||
|
|
1247
|
+
this.syncStatus.state === 'error') {
|
|
1248
|
+
this.updateSyncStatus({ state: 'idle', offlineSince: undefined });
|
|
1249
|
+
}
|
|
1250
|
+
break;
|
|
1251
|
+
case 'probing_network':
|
|
1252
|
+
case 'reconnecting':
|
|
1253
|
+
case 'backoff':
|
|
1254
|
+
// Active recovery — the UI should reflect that the FSM
|
|
1255
|
+
// is doing work, not that we've given up.
|
|
1256
|
+
if (this.syncStatus.state !== 'reconnecting') {
|
|
1257
|
+
this.updateSyncStatus({ state: 'reconnecting' });
|
|
1258
|
+
}
|
|
1259
|
+
break;
|
|
1260
|
+
case 'waiting_for_network':
|
|
1261
|
+
case 'offline':
|
|
1262
|
+
if (this.syncStatus.state !== 'offline') {
|
|
1263
|
+
this.updateSyncStatus({
|
|
1264
|
+
state: 'offline',
|
|
1265
|
+
offlineSince: this.syncStatus.offlineSince ?? new Date(),
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
break;
|
|
1269
|
+
// 'session_expired' / 'validating_session' are handled by
|
|
1270
|
+
// the existing session-error / WS subscription paths.
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
// Transaction events for pendingChanges tracking
|
|
1276
|
+
const unsubCreated = this.syncClient.onTransactionEvent('created', () => { this.incrementPendingChanges(); });
|
|
1277
|
+
const unsubCompleted = this.syncClient.onTransactionEvent('completed', () => { this.decrementPendingChanges(); });
|
|
1278
|
+
const unsubFailed = this.syncClient.onTransactionEvent('failed', () => { this.decrementPendingChanges(); });
|
|
1279
|
+
this.disposers.push(unsubCreated, unsubCompleted, unsubFailed);
|
|
1280
|
+
this.syncWebSocket.connect();
|
|
1281
|
+
}
|
|
1282
|
+
// ── Delta Processing Pipeline ─────────────────────────────────────────────
|
|
1283
|
+
/** State signature for delta deduplication */
|
|
1284
|
+
extractStateSignature(delta) {
|
|
1285
|
+
if (!delta.data || typeof delta.data !== 'object')
|
|
1286
|
+
return null;
|
|
1287
|
+
const data = typeof delta.data === 'string'
|
|
1288
|
+
? JSON.parse(delta.data)
|
|
1289
|
+
: delta.data;
|
|
1290
|
+
// Generic state fields — subclasses can override getStateFields() for model-specific fields
|
|
1291
|
+
const fieldsToCheck = this.getStateFields(delta.modelName);
|
|
1292
|
+
const signature = {
|
|
1293
|
+
actionType: delta.actionType,
|
|
1294
|
+
modelName: delta.modelName,
|
|
1295
|
+
};
|
|
1296
|
+
for (const field of fieldsToCheck) {
|
|
1297
|
+
if (field in data)
|
|
1298
|
+
signature[field] = data[field];
|
|
1299
|
+
}
|
|
1300
|
+
return signature;
|
|
1301
|
+
}
|
|
1302
|
+
/** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
|
|
1303
|
+
getStateFields(_modelName) {
|
|
1304
|
+
return ['status', 'state', 'isActive'];
|
|
1305
|
+
}
|
|
1306
|
+
isSameState(a, b) {
|
|
1307
|
+
if (!a || !b)
|
|
1308
|
+
return false;
|
|
1309
|
+
const keys = Object.keys(a);
|
|
1310
|
+
if (keys.length !== Object.keys(b).length)
|
|
1311
|
+
return false;
|
|
1312
|
+
return keys.every((k) => a[k] === b[k]);
|
|
1313
|
+
}
|
|
1314
|
+
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
1315
|
+
deduplicateDeltas(deltas) {
|
|
1316
|
+
const byEntity = new Map();
|
|
1317
|
+
for (const d of deltas) {
|
|
1318
|
+
const key = `${d.modelName}:${d.modelId}`;
|
|
1319
|
+
if (!byEntity.has(key))
|
|
1320
|
+
byEntity.set(key, []);
|
|
1321
|
+
byEntity.get(key).push(d);
|
|
1322
|
+
}
|
|
1323
|
+
const result = [];
|
|
1324
|
+
for (const entityDeltas of byEntity.values()) {
|
|
1325
|
+
const sorted = entityDeltas.sort((a, b) => a.id - b.id);
|
|
1326
|
+
// DELETE wins — it's the final state
|
|
1327
|
+
const del = sorted.find((d) => d.actionType === 'D');
|
|
1328
|
+
if (del) {
|
|
1329
|
+
result.push(del);
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
// Keep deltas that represent different states
|
|
1333
|
+
const unique = [];
|
|
1334
|
+
let prev = null;
|
|
1335
|
+
for (const d of sorted) {
|
|
1336
|
+
const sig = this.extractStateSignature(d);
|
|
1337
|
+
if (!this.isSameState(prev, sig)) {
|
|
1338
|
+
unique.push(d);
|
|
1339
|
+
prev = sig;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
result.push(...(unique.length > 0 ? unique : [sorted[sorted.length - 1]]));
|
|
1343
|
+
}
|
|
1344
|
+
return result.sort((a, b) => a.id - b.id);
|
|
1345
|
+
}
|
|
1346
|
+
/** Process incoming delta with smart batching */
|
|
1347
|
+
processDeltaWithBatching(delta) {
|
|
1348
|
+
// Dedup guard — skip already-processed deltas
|
|
1349
|
+
if (delta.id > 0 && delta.id <= this.highestProcessedSyncId)
|
|
1350
|
+
return;
|
|
1351
|
+
// Confirm awaiting transactions via sync ID threshold (before batching)
|
|
1352
|
+
this.syncClient.onDeltaReceived(delta.id);
|
|
1353
|
+
// Update version vector
|
|
1354
|
+
const entityType = delta.modelName.toLowerCase();
|
|
1355
|
+
if (this.versionVector[entityType] !== undefined) {
|
|
1356
|
+
this.versionVector[entityType] = Math.max(this.versionVector[entityType], delta.id);
|
|
1357
|
+
}
|
|
1358
|
+
// Queue during active bootstrap
|
|
1359
|
+
if (this.bootstrapDeltaQueue !== null) {
|
|
1360
|
+
this.bootstrapDeltaQueue.push(delta);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
// Advance watermark
|
|
1364
|
+
if (delta.id > this.highestProcessedSyncId) {
|
|
1365
|
+
this.highestProcessedSyncId = delta.id;
|
|
1366
|
+
}
|
|
1367
|
+
// Sync group added — handle immediately. Supports both legacy
|
|
1368
|
+
// (addedGroups/removedGroups) and incremental (group/userId) payloads.
|
|
1369
|
+
if (delta.actionType === 'G') {
|
|
1370
|
+
void this.handleSyncGroupChange(delta);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
// Sync group removed — handle immediately. Clears affected local state
|
|
1374
|
+
// and forces re-bootstrap with the updated group list.
|
|
1375
|
+
if (delta.actionType === 'S') {
|
|
1376
|
+
void this.handleGroupRemoved(delta);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
// DELETE — fire the cascade cancel immediately (O(1) via FK index;
|
|
1380
|
+
// must run BEFORE any subsequent update on the same model lands so
|
|
1381
|
+
// pending update transactions for soon-deleted children don't race
|
|
1382
|
+
// their parent's delete) but route the IDB+pool write through the
|
|
1383
|
+
// same batched path as UPDATEs. The previous immediate-flush path
|
|
1384
|
+
// produced N IDB writes + N pool mutations + N `models:changed`
|
|
1385
|
+
// events when a peer deleted a chart with N layers; the batched
|
|
1386
|
+
// path produces one of each per microtask flush. Dedup in
|
|
1387
|
+
// `flushPendingDeltas` handles the U-then-D-on-same-model case
|
|
1388
|
+
// correctly via arrival-order replay through `processDeltaBatch`.
|
|
1389
|
+
if (delta.actionType === 'D') {
|
|
1390
|
+
this.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
|
|
1391
|
+
}
|
|
1392
|
+
this.pendingDeltas.push(delta);
|
|
1393
|
+
if (this.batchTimer)
|
|
1394
|
+
clearTimeout(this.batchTimer);
|
|
1395
|
+
if (this.pendingDeltas.length >= this.smartSyncOptions.maxBatchSize) {
|
|
1396
|
+
void this.flushPendingDeltas().catch(this.handleFlushError);
|
|
1397
|
+
}
|
|
1398
|
+
else {
|
|
1399
|
+
this.batchTimer = setTimeout(() => {
|
|
1400
|
+
void this.flushPendingDeltas().catch(this.handleFlushError);
|
|
1401
|
+
}, this.smartSyncOptions.batchingDelay);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Cancel pending transactions for child entities when a parent is deleted.
|
|
1406
|
+
*
|
|
1407
|
+
* Uses `pool.getByForeignKey` (O(1) via the FK index registered at
|
|
1408
|
+
* schema build time) to find children. The previous implementation did
|
|
1409
|
+
* `getByType(ctor).filter(e => e.toJSON()[foreignKey] === parentId)` —
|
|
1410
|
+
* a full pool scan per child model + a `toJSON()` allocation per
|
|
1411
|
+
* candidate. For a deck delete with 10K layers in the pool, that was
|
|
1412
|
+
* 10K toJSON allocations per cascade level. The FK-indexed lookup
|
|
1413
|
+
* skips both the scan AND the allocation.
|
|
1414
|
+
*/
|
|
1415
|
+
cascadeCancelTransactionsForDeletedParent(parentModelName, parentId) {
|
|
1416
|
+
const reg = this.objectPool.registry;
|
|
1417
|
+
const childModels = reg.getChildModels(parentModelName);
|
|
1418
|
+
if (childModels.length === 0)
|
|
1419
|
+
return;
|
|
1420
|
+
let totalCancelled = 0;
|
|
1421
|
+
for (const { childModel, foreignKey } of childModels) {
|
|
1422
|
+
const cancelled = this.syncClient.cancelTransactionsByForeignKey(childModel, foreignKey, parentId);
|
|
1423
|
+
totalCancelled += cancelled;
|
|
1424
|
+
// O(1) FK-index lookup — skips the prior `getByType().filter(toJSON)` scan.
|
|
1425
|
+
const children = this.objectPool.getByForeignKey(childModel, foreignKey, parentId);
|
|
1426
|
+
for (const child of children) {
|
|
1427
|
+
this.cascadeCancelTransactionsForDeletedParent(childModel, child.id);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (totalCancelled > 0) {
|
|
1431
|
+
getContext().logger.info('[BaseSyncedStore] Cascade cancelled orphaned transactions', {
|
|
1432
|
+
parentModel: parentModelName,
|
|
1433
|
+
parentId: parentId.slice(0, 12),
|
|
1434
|
+
totalCancelled,
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/** Flush pending deltas with deduplication and batched ObjectPool mutations */
|
|
1439
|
+
/** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
|
|
1440
|
+
async flushPendingDeltas() {
|
|
1441
|
+
if (this.pendingDeltas.length === 0)
|
|
1442
|
+
return;
|
|
1443
|
+
const deduplicatedDeltas = this.deduplicateDeltas(this.pendingDeltas);
|
|
1444
|
+
// Custom entities → apply directly to ObjectPool (skip IDB)
|
|
1445
|
+
const customDeltas = deduplicatedDeltas.filter((d) => this.isCustomEntity(d.modelName));
|
|
1446
|
+
if (customDeltas.length > 0) {
|
|
1447
|
+
runInAction(() => {
|
|
1448
|
+
for (const delta of customDeltas) {
|
|
1449
|
+
const data = typeof delta.data === 'string'
|
|
1450
|
+
? JSON.parse(delta.data)
|
|
1451
|
+
: delta.data;
|
|
1452
|
+
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
1453
|
+
// gained permission to see the entity, so we insert it into the
|
|
1454
|
+
// pool as if newly created.
|
|
1455
|
+
if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
|
|
1456
|
+
const existing = this.objectPool.get(delta.modelId);
|
|
1457
|
+
if (existing) {
|
|
1458
|
+
existing.updateFromData(data);
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
const model = this.createCustomEntity(delta.modelName, delta.modelId, data);
|
|
1462
|
+
if (model) {
|
|
1463
|
+
model.markAsPersisted();
|
|
1464
|
+
this.objectPool.add(model, ModelScope.live);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
else if (delta.actionType === 'D') {
|
|
1469
|
+
this.objectPool.remove(delta.modelId);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
// Regular deltas → IDB then ObjectPool via SyncClient.
|
|
1475
|
+
// 'G' and 'S' deltas are routed upstream (handleSyncGroupChange,
|
|
1476
|
+
// handleGroupRemoved) and never reach flushPendingDeltas, but the
|
|
1477
|
+
// Database.processDelta signature accepts them defensively.
|
|
1478
|
+
const regularDeltas = deduplicatedDeltas.filter((d) => !this.isCustomEntity(d.modelName));
|
|
1479
|
+
const batch = await this.database.processDeltaBatch(regularDeltas.map((d) => ({
|
|
1480
|
+
syncId: d.id,
|
|
1481
|
+
actionType: d.actionType,
|
|
1482
|
+
modelName: d.modelName,
|
|
1483
|
+
modelId: d.modelId,
|
|
1484
|
+
data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
|
|
1485
|
+
// Thread `transactionId` through so the receive layer can
|
|
1486
|
+
// recognize echoes of locally-applied transactions and skip
|
|
1487
|
+
// the pool mutation. See `OPTIMISTIC_RECONCILIATION.md`.
|
|
1488
|
+
transactionId: d.transactionId,
|
|
1489
|
+
})));
|
|
1490
|
+
const dbResults = batch.results;
|
|
1491
|
+
// Delegate ObjectPool writes to SyncClient (owns pool operations)
|
|
1492
|
+
this.syncClient.applyDeltaBatchToPool(dbResults, (name, data) => this.enrichRelations(name, data));
|
|
1493
|
+
// Acknowledge + advance sync cursor — gated on IDB persistence.
|
|
1494
|
+
//
|
|
1495
|
+
// We MUST ack `persistedSyncId` (the high-water mark of deltas whose
|
|
1496
|
+
// store transaction actually committed), NOT the input batch's last
|
|
1497
|
+
// delta id. Acking by input range advances the server's view past
|
|
1498
|
+
// deltas that never wrote to IDB; the next catch-up request would
|
|
1499
|
+
// then send the advanced cursor and the server replies "you're up
|
|
1500
|
+
// to date" — losing the un-persisted delta forever. This is the
|
|
1501
|
+
// Replicache "same-transaction" invariant: the cursor and the
|
|
1502
|
+
// persisted view must be consistent.
|
|
1503
|
+
const persistedSyncId = batch.persistedSyncId;
|
|
1504
|
+
if (persistedSyncId > this.lastAckedId) {
|
|
1505
|
+
this.syncWebSocket?.acknowledge?.(persistedSyncId);
|
|
1506
|
+
this.lastAckedId = persistedSyncId;
|
|
1507
|
+
this.highestProcessedSyncId = Math.max(this.highestProcessedSyncId, persistedSyncId);
|
|
1508
|
+
}
|
|
1509
|
+
// Cache invalidation is automatic via SyncClient 'models:changed' event
|
|
1510
|
+
this.pendingDeltas = [];
|
|
1511
|
+
if (this.batchTimer) {
|
|
1512
|
+
clearTimeout(this.batchTimer);
|
|
1513
|
+
this.batchTimer = null;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
// ── Core Mutations (thin delegation to SyncClient) ────────────────────────
|
|
1517
|
+
//
|
|
1518
|
+
// BaseSyncedStore is an orchestrator, not an implementor.
|
|
1519
|
+
// SyncClient owns: ObjectPool operations, TransactionQueue, IDB writes.
|
|
1520
|
+
// BaseSyncedStore owns: validation, hooks, pending delete tracking.
|
|
1521
|
+
/** Check if a model type is local-only (no sync). Override for domain-specific models. */
|
|
1522
|
+
isLocalOnlyModel(_modelName) {
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
/** Validate model against schema before save */
|
|
1526
|
+
validateModel(model) {
|
|
1527
|
+
const modelName = model.getModelName();
|
|
1528
|
+
const properties = this.modelRegistry.getPropertiesForModel(modelName);
|
|
1529
|
+
const modelData = model.toJSON();
|
|
1530
|
+
for (const [propName, metadata] of properties) {
|
|
1531
|
+
if (metadata.type === PropertyType.referenceModel)
|
|
1532
|
+
continue;
|
|
1533
|
+
if (metadata.type === PropertyType.ephemeralProperty)
|
|
1534
|
+
continue;
|
|
1535
|
+
if (!metadata.optional && (modelData[propName] === null || modelData[propName] === undefined)) {
|
|
1536
|
+
throw new AbloValidationError(`Required field ${propName} is missing on ${modelName}`, { code: 'model_required_field_missing' });
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Save a model (create or update).
|
|
1542
|
+
*
|
|
1543
|
+
* Accepts any entity shape with `{ id: string }` so consumers can pass the
|
|
1544
|
+
* Zod-inferred model types from `InferModel<Schema, K>` without knowing
|
|
1545
|
+
* about the internal `Model` base class. At runtime, every entity reaching
|
|
1546
|
+
* this method came through the object pool (via `store.create`, a query
|
|
1547
|
+
* accessor, or an optimistic insert) and IS a `Model` instance — the one
|
|
1548
|
+
* cast below preserves that invariant inside the SDK.
|
|
1549
|
+
*/
|
|
1550
|
+
async save(entity, options) {
|
|
1551
|
+
const model = rowAsModel(entity);
|
|
1552
|
+
this.beforeSave(model);
|
|
1553
|
+
if (!options?.skipValidation)
|
|
1554
|
+
this.validateModel(model);
|
|
1555
|
+
if (!model.createdAt)
|
|
1556
|
+
model.createdAt = new Date();
|
|
1557
|
+
// SyncClient.add/update handles: optimistic pool add, transaction queue, IDB write
|
|
1558
|
+
const isCreate = !this.objectPool.get(model.id);
|
|
1559
|
+
if (isCreate) {
|
|
1560
|
+
model.updatedAt = new Date();
|
|
1561
|
+
this.syncClient.add(model);
|
|
1562
|
+
}
|
|
1563
|
+
else {
|
|
1564
|
+
this.syncClient.update(model);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/** Save with an atomic server mutation (e.g., createSlideWithLayers) */
|
|
1568
|
+
async saveWithAtomicMutation(model, mutation) {
|
|
1569
|
+
this.objectPool.add(model, ModelScope.live);
|
|
1570
|
+
await mutation(this.syncClient.gql);
|
|
1571
|
+
}
|
|
1572
|
+
/** Delete a model. Accepts schema-inferred entity shapes (see `save`). */
|
|
1573
|
+
async delete(entity) {
|
|
1574
|
+
const model = rowAsModel(entity);
|
|
1575
|
+
this.pendingDeletes.add(model.id);
|
|
1576
|
+
// SyncClient.delete handles: pool remove, transaction queue
|
|
1577
|
+
this.syncClient.delete(model);
|
|
1578
|
+
}
|
|
1579
|
+
/** Archive a model. Accepts schema-inferred entity shapes (see `save`). */
|
|
1580
|
+
async archive(entity) {
|
|
1581
|
+
const model = rowAsModel(entity);
|
|
1582
|
+
model.archivedAt = new Date();
|
|
1583
|
+
this.syncClient.archive(model);
|
|
1584
|
+
}
|
|
1585
|
+
/** Unarchive a model. Accepts schema-inferred entity shapes (see `save`). */
|
|
1586
|
+
async unarchive(entity) {
|
|
1587
|
+
const model = rowAsModel(entity);
|
|
1588
|
+
model.archivedAt = null;
|
|
1589
|
+
this.syncClient.update(model);
|
|
1590
|
+
}
|
|
1591
|
+
// ── Query API ────────────────────────────────────────────────────────────
|
|
1592
|
+
/**
|
|
1593
|
+
* Schema-keyed accessor namespace — the primary type-safe lookup surface.
|
|
1594
|
+
*
|
|
1595
|
+
* ```ts
|
|
1596
|
+
* const chat = store.query.chats.retrieve(chatId); // Chat | undefined
|
|
1597
|
+
* const slides = store.query.slides.findMany({ where: { deckId } }); // Slide[]
|
|
1598
|
+
* ```
|
|
1599
|
+
*
|
|
1600
|
+
* Each `query.<modelKey>` is a `ReaderActions<Schema, K>` built lazily on
|
|
1601
|
+
* first access via `createReaderActions`. The returned types are inferred
|
|
1602
|
+
* from the schema (`InferModel<S, K>`), including `InferRelations` — so
|
|
1603
|
+
* `chat.messages`, `slide.layers`, etc. are typed without a cast.
|
|
1604
|
+
*
|
|
1605
|
+
* Throws if the store was constructed without a schema (class-based
|
|
1606
|
+
* subclasses that wire models via `modelRegistry.registerModel` directly
|
|
1607
|
+
* don't have access to schema-derived inference).
|
|
1608
|
+
*/
|
|
1609
|
+
get query() {
|
|
1610
|
+
if (!this.schema) {
|
|
1611
|
+
throw new AbloValidationError('store.query requires a schema to be passed to the BaseSyncedStore constructor. ' +
|
|
1612
|
+
'Pass `{ schema }` in the dependencies argument.', { code: 'store_query_schema_missing' });
|
|
1613
|
+
}
|
|
1614
|
+
if (!this._queryProxy) {
|
|
1615
|
+
const schema = this.schema;
|
|
1616
|
+
// BaseSyncedStore satisfies SyncStoreContract structurally via
|
|
1617
|
+
// `findById` / `queryByClass` / `save` / `delete`. Pass `this`
|
|
1618
|
+
// directly — `createReaderActions` accepts the contract shape.
|
|
1619
|
+
const store = this;
|
|
1620
|
+
const cache = new Map();
|
|
1621
|
+
this._queryProxy = new Proxy({}, {
|
|
1622
|
+
get: (_target, prop) => {
|
|
1623
|
+
if (typeof prop !== 'string')
|
|
1624
|
+
return undefined;
|
|
1625
|
+
const cached = cache.get(prop);
|
|
1626
|
+
if (cached)
|
|
1627
|
+
return cached;
|
|
1628
|
+
if (!(prop in schema.models)) {
|
|
1629
|
+
throw new AbloValidationError(`store.query: unknown model key "${prop}". Known keys: ${Object.keys(schema.models).join(', ')}`, { code: 'store_query_unknown_model' });
|
|
1630
|
+
}
|
|
1631
|
+
const actions = createReaderActions(schema, prop, store);
|
|
1632
|
+
cache.set(prop, actions);
|
|
1633
|
+
return actions;
|
|
1634
|
+
},
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
return this._queryProxy;
|
|
1638
|
+
}
|
|
1639
|
+
/** Retrieve a single entity by id. Synchronous pool read. */
|
|
1640
|
+
retrieve(_modelClass, id) {
|
|
1641
|
+
return this.objectPool.get(id);
|
|
1642
|
+
}
|
|
1643
|
+
/** Find any entity by ID regardless of type */
|
|
1644
|
+
findAnyById(id) {
|
|
1645
|
+
return this.objectPool.get(id);
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Lookup a model by ID alone. Matches the `SyncStoreRef.getById` contract
|
|
1649
|
+
* that schema-defined computeds use when they need to resolve a related
|
|
1650
|
+
* entity without holding onto its constructor.
|
|
1651
|
+
*/
|
|
1652
|
+
getById(id) {
|
|
1653
|
+
return this.objectPool.get(id);
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Create a model instance locally, typed via the schema.
|
|
1657
|
+
*
|
|
1658
|
+
* ```ts
|
|
1659
|
+
* const sheet = store.create('spreadsheetSheets', { name, spreadsheetId });
|
|
1660
|
+
* // sheet: SpreadsheetSheet | null — no cast needed
|
|
1661
|
+
* ```
|
|
1662
|
+
*
|
|
1663
|
+
* The `typename` arg is the schema key (camelCase plural, e.g.
|
|
1664
|
+
* `'spreadsheetSheets'`); the returned instance has the
|
|
1665
|
+
* `InferModel<Schema, K>` shape including computeds + relation accessors.
|
|
1666
|
+
* Wraps `pool.create(...)` — the underlying runtime is unchanged, just
|
|
1667
|
+
* type-narrowed.
|
|
1668
|
+
*/
|
|
1669
|
+
create(typename, data) {
|
|
1670
|
+
if (!this.schema) {
|
|
1671
|
+
throw new AbloValidationError('store.create requires a schema to be passed to the BaseSyncedStore constructor.', { code: 'store_create_schema_missing' });
|
|
1672
|
+
}
|
|
1673
|
+
const modelDef = this.schema.models[typename];
|
|
1674
|
+
const wireTypename = modelDef?.typename ?? typename;
|
|
1675
|
+
// Same boundary-cast idiom used by `createReaderActions.findById` — the
|
|
1676
|
+
// runtime instance IS the schema-typed shape (the dynamic class was
|
|
1677
|
+
// built from the same Zod shape), TypeScript just can't unify the SDK's
|
|
1678
|
+
// static `Model` class with the schema's object-literal type.
|
|
1679
|
+
return this.objectPool.create(wireTypename, data);
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Legacy class-based query entry point — kept for callers that still pass
|
|
1683
|
+
* a Model constructor + options object. New code should use the typed
|
|
1684
|
+
* `store.query.<modelKey>` namespace instead, which returns properly
|
|
1685
|
+
* inferred schema types without needing a class value or cast.
|
|
1686
|
+
*/
|
|
1687
|
+
queryByClass(modelClass, options) {
|
|
1688
|
+
const modelName = this.objectPool.registry.getModelNameFromConstructor(modelClass);
|
|
1689
|
+
if (!modelName)
|
|
1690
|
+
return { data: [], total: 0, hasMore: false };
|
|
1691
|
+
let allModels = this.objectPool.getByType(modelClass, options?.scope ?? ModelScope.live);
|
|
1692
|
+
// Filter out pending deletes
|
|
1693
|
+
allModels = allModels.filter((m) => !this.pendingDeletes.has(m.id));
|
|
1694
|
+
// Apply predicate
|
|
1695
|
+
if (options?.predicate) {
|
|
1696
|
+
allModels = allModels.filter(options.predicate);
|
|
1697
|
+
}
|
|
1698
|
+
const total = allModels.length;
|
|
1699
|
+
// Apply ordering
|
|
1700
|
+
if (options?.orderBy) {
|
|
1701
|
+
const field = options.orderBy;
|
|
1702
|
+
const dir = options.order === 'desc' ? -1 : 1;
|
|
1703
|
+
allModels.sort((a, b) => {
|
|
1704
|
+
const av = a.getField(field);
|
|
1705
|
+
const bv = b.getField(field);
|
|
1706
|
+
if (av == null || bv == null)
|
|
1707
|
+
return 0;
|
|
1708
|
+
return av < bv ? -dir : av > bv ? dir : 0;
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
// Apply pagination
|
|
1712
|
+
if (options?.offset)
|
|
1713
|
+
allModels = allModels.slice(options.offset);
|
|
1714
|
+
const hasMore = options?.limit ? allModels.length > options.limit : false;
|
|
1715
|
+
if (options?.limit)
|
|
1716
|
+
allModels = allModels.slice(0, options.limit);
|
|
1717
|
+
return { data: allModels, total, hasMore };
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Get all models of a type. Returns Model[] honestly — callers that need
|
|
1721
|
+
* narrow types should use `useAblo((ablo) => ablo.<model>.list(...))`
|
|
1722
|
+
* which does proper inference via `InferModel<S, K>`.
|
|
1723
|
+
*/
|
|
1724
|
+
allModelsOfType(modelClass, scope) {
|
|
1725
|
+
return this.objectPool.getByType(modelClass, scope ?? ModelScope.live);
|
|
1726
|
+
}
|
|
1727
|
+
/** Error handler for fire-and-forget flushPendingDeltas calls */
|
|
1728
|
+
handleFlushError = (error) => {
|
|
1729
|
+
getContext().observability.captureTransactionFailure({
|
|
1730
|
+
context: 'flush-pending-deltas',
|
|
1731
|
+
modelName: 'batch',
|
|
1732
|
+
modelId: 'batch',
|
|
1733
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1734
|
+
});
|
|
1735
|
+
getContext().logger.error('[BaseSyncedStore] Delta flush error', {
|
|
1736
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1737
|
+
});
|
|
1738
|
+
};
|
|
1739
|
+
/** Process a single delta (used for immediate DELETE processing). Override for domain-specific handling. */
|
|
1740
|
+
async processDelta(delta) {
|
|
1741
|
+
const dbResult = await this.database.processDelta({
|
|
1742
|
+
syncId: delta.id,
|
|
1743
|
+
actionType: delta.actionType,
|
|
1744
|
+
modelName: delta.modelName,
|
|
1745
|
+
modelId: delta.modelId,
|
|
1746
|
+
data: typeof delta.data === 'string' ? JSON.parse(delta.data) : delta.data,
|
|
1747
|
+
});
|
|
1748
|
+
if (!dbResult)
|
|
1749
|
+
return;
|
|
1750
|
+
// Track pending deletes for query filtering
|
|
1751
|
+
if (dbResult.action === 'remove') {
|
|
1752
|
+
this.pendingDeletes.add(dbResult.modelId);
|
|
1753
|
+
}
|
|
1754
|
+
// Delegate pool writes to SyncClient (auto-invalidates cache via 'models:changed' event)
|
|
1755
|
+
this.syncClient.applyDeltaBatchToPool([dbResult], (name, data) => this.enrichRelations(name, data));
|
|
1756
|
+
// Advance sync ID
|
|
1757
|
+
if (delta.id > this.lastAckedId) {
|
|
1758
|
+
this.lastAckedId = delta.id;
|
|
1759
|
+
this.highestProcessedSyncId = Math.max(this.highestProcessedSyncId, delta.id);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
/** Handle bootstrap_required event */
|
|
1763
|
+
handleBootstrapRequired(_hint) {
|
|
1764
|
+
// Subclass implements — triggers background bootstrap
|
|
1765
|
+
}
|
|
1766
|
+
/** Handle bootstrap_data event. Override in subclass. */
|
|
1767
|
+
handleBootstrapData(_data) {
|
|
1768
|
+
this.updateSyncStatus({ state: 'syncing' });
|
|
1769
|
+
}
|
|
1770
|
+
/** Handle presence_update event. Override in subclass. */
|
|
1771
|
+
handlePresenceUpdate(_data) { }
|
|
1772
|
+
// ── Pending changes tracking ─────────────────────────────────────────────
|
|
1773
|
+
incrementPendingChanges() {
|
|
1774
|
+
runInAction(() => { this.syncStatus.pendingChanges++; });
|
|
1775
|
+
}
|
|
1776
|
+
decrementPendingChanges() {
|
|
1777
|
+
runInAction(() => {
|
|
1778
|
+
if (this.syncStatus.pendingChanges > 0)
|
|
1779
|
+
this.syncStatus.pendingChanges--;
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
// ── Status helpers ───────────────────────────────────────────────────────
|
|
1783
|
+
updateSyncStatus(updates) {
|
|
1784
|
+
runInAction(() => {
|
|
1785
|
+
Object.assign(this.syncStatus, updates);
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
// ── Accessors ─────────────────────────────────────────────────────────────
|
|
1789
|
+
get pool() {
|
|
1790
|
+
return this.objectPool;
|
|
1791
|
+
}
|
|
1792
|
+
get lastSyncId() {
|
|
1793
|
+
return this.lastAckedId;
|
|
1794
|
+
}
|
|
1795
|
+
// ── Status convenience getters ──────────────────────────────────────────
|
|
1796
|
+
// Thin wrappers over syncStatus for consumer ergonomics. Previously on
|
|
1797
|
+
// SyncedStore; moved here so createSyncEngine consumers get them too.
|
|
1798
|
+
get isReady() {
|
|
1799
|
+
// Ready if: fully synced (idle + 100%) OR local data loaded (dataReady + syncing in background)
|
|
1800
|
+
return (this.syncStatus.state === 'idle' && this.syncStatus.progress >= 100)
|
|
1801
|
+
|| (this.dataReady && this.syncStatus.state === 'syncing');
|
|
1802
|
+
}
|
|
1803
|
+
get isSyncing() {
|
|
1804
|
+
return this.syncStatus.state === 'syncing';
|
|
1805
|
+
}
|
|
1806
|
+
get isOffline() {
|
|
1807
|
+
return this.syncStatus.state === 'offline';
|
|
1808
|
+
}
|
|
1809
|
+
get isReconnecting() {
|
|
1810
|
+
return this.syncStatus.state === 'reconnecting';
|
|
1811
|
+
}
|
|
1812
|
+
get isError() {
|
|
1813
|
+
return this.syncStatus.state === 'error';
|
|
1814
|
+
}
|
|
1815
|
+
get hasUnsyncedChanges() {
|
|
1816
|
+
return this.syncStatus.pendingChanges > 0;
|
|
1817
|
+
}
|
|
1818
|
+
/** The SyncWebSocket handle — for collaboration events. */
|
|
1819
|
+
get ws() {
|
|
1820
|
+
return this.syncWebSocket;
|
|
1821
|
+
}
|
|
1822
|
+
/** The Database instance — for demand loaders and direct IDB operations. */
|
|
1823
|
+
get db() {
|
|
1824
|
+
return this.database;
|
|
1825
|
+
}
|
|
1826
|
+
/** The SyncClient instance — for assignment operations and other direct sync actions. */
|
|
1827
|
+
get sc() {
|
|
1828
|
+
return this.syncClient;
|
|
1829
|
+
}
|
|
1830
|
+
/** The current organization ID — from the last initialize() call. */
|
|
1831
|
+
get orgId() {
|
|
1832
|
+
return this.userContext?.organizationId;
|
|
1833
|
+
}
|
|
1834
|
+
/** Count models matching a predicate. */
|
|
1835
|
+
count(modelClass, predicate) {
|
|
1836
|
+
const all = this.allModelsOfType(modelClass);
|
|
1837
|
+
return predicate ? all.filter(predicate).length : all.length;
|
|
1838
|
+
}
|
|
1839
|
+
/** Get entities by foreign key (used by Model subclasses via Model.store) */
|
|
1840
|
+
getByForeignKey(modelName, foreignKey, id) {
|
|
1841
|
+
return this.objectPool.getByForeignKey(modelName, foreignKey, id);
|
|
1842
|
+
}
|
|
1843
|
+
}
|