zerodrift 1.0.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/LICENSE +21 -0
- package/README.md +358 -0
- package/dist/core/BaseModel.d.ts +76 -0
- package/dist/core/BaseModel.js +505 -0
- package/dist/core/BaseSSEConnection.d.ts +31 -0
- package/dist/core/BaseSSEConnection.js +91 -0
- package/dist/core/BatchModelLoader.d.ts +27 -0
- package/dist/core/BatchModelLoader.js +70 -0
- package/dist/core/CompoundIndexFetcher.d.ts +46 -0
- package/dist/core/CompoundIndexFetcher.js +177 -0
- package/dist/core/Database.d.ts +303 -0
- package/dist/core/Database.js +837 -0
- package/dist/core/LazyCollection.d.ts +168 -0
- package/dist/core/LazyCollection.js +403 -0
- package/dist/core/LazyOwnedCollection.d.ts +35 -0
- package/dist/core/LazyOwnedCollection.js +66 -0
- package/dist/core/MemoryAdapter.d.ts +67 -0
- package/dist/core/MemoryAdapter.js +243 -0
- package/dist/core/ModelRegistry.d.ts +64 -0
- package/dist/core/ModelRegistry.js +217 -0
- package/dist/core/ModelStream.d.ts +33 -0
- package/dist/core/ModelStream.js +68 -0
- package/dist/core/ObjectPool.d.ts +113 -0
- package/dist/core/ObjectPool.js +339 -0
- package/dist/core/Store.d.ts +40 -0
- package/dist/core/Store.js +73 -0
- package/dist/core/StoreManager.d.ts +839 -0
- package/dist/core/StoreManager.js +2034 -0
- package/dist/core/SyncConnection.d.ts +105 -0
- package/dist/core/SyncConnection.js +348 -0
- package/dist/core/Transaction.d.ts +114 -0
- package/dist/core/Transaction.js +147 -0
- package/dist/core/TransactionQueue.d.ts +110 -0
- package/dist/core/TransactionQueue.js +601 -0
- package/dist/core/decorators.d.ts +66 -0
- package/dist/core/decorators.js +278 -0
- package/dist/core/hash.d.ts +6 -0
- package/dist/core/hash.js +12 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +18 -0
- package/dist/core/internal.d.ts +27 -0
- package/dist/core/internal.js +25 -0
- package/dist/core/observability.d.ts +21 -0
- package/dist/core/observability.js +66 -0
- package/dist/core/refAccessors.d.ts +43 -0
- package/dist/core/refAccessors.js +80 -0
- package/dist/core/serializers.d.ts +2 -0
- package/dist/core/serializers.js +2 -0
- package/dist/core/types.d.ts +320 -0
- package/dist/core/types.js +84 -0
- package/dist/react/index.d.ts +82 -0
- package/dist/react/index.js +373 -0
- package/dist/schema/builders.d.ts +29 -0
- package/dist/schema/builders.js +81 -0
- package/dist/schema/compile.d.ts +28 -0
- package/dist/schema/compile.js +334 -0
- package/dist/schema/createStore.d.ts +235 -0
- package/dist/schema/createStore.js +264 -0
- package/dist/schema/extend.d.ts +46 -0
- package/dist/schema/extend.js +6 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/infer.d.ts +102 -0
- package/dist/schema/infer.js +1 -0
- package/dist/schema/types.d.ts +76 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/zod.d.ts +90 -0
- package/dist/schema/zod.js +101 -0
- package/package.json +99 -0
|
@@ -0,0 +1,2034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StoreManager — the top-level orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Owns: ObjectPool, Database, TransactionQueue, SyncConnection, all Stores.
|
|
5
|
+
*
|
|
6
|
+
* Bootstrap phases (for loading indicators):
|
|
7
|
+
* idle → creatingStores → connectingDatabase → determiningBootstrapType
|
|
8
|
+
* → fetching → writingToDatabase → hydrating → connectingSync → ready
|
|
9
|
+
*
|
|
10
|
+
* Batch API:
|
|
11
|
+
* storeManager.batch(() => {
|
|
12
|
+
* issue.title = "x"; issue.save();
|
|
13
|
+
* team.name = "y"; team.save();
|
|
14
|
+
* });
|
|
15
|
+
* storeManager.undo(); // reverts both
|
|
16
|
+
*
|
|
17
|
+
* Lazy loading:
|
|
18
|
+
* storeManager.getOrLoadCollection("Issue", "teamId", teamId)
|
|
19
|
+
* storeManager.getOrLoadById("DocumentContent", docId)
|
|
20
|
+
*/
|
|
21
|
+
import { ModelRegistry } from "./ModelRegistry";
|
|
22
|
+
import { DEFAULT_TRANSIENT_INDEX_DEPTH } from "./types";
|
|
23
|
+
import { ObjectPool, prop, readFk } from "./ObjectPool";
|
|
24
|
+
import { Database, BootstrapType, } from "./Database";
|
|
25
|
+
import { FullStore, PartialStore, EphemeralStore, } from "./Store";
|
|
26
|
+
import { TransactionQueue, } from "./TransactionQueue";
|
|
27
|
+
import { SyncConnection, encodeCsvList, createBrowserSSEFactory, } from "./SyncConnection";
|
|
28
|
+
import { ModelStream } from "./ModelStream";
|
|
29
|
+
import { BatchModelLoader } from "./BatchModelLoader";
|
|
30
|
+
import { COMPOUND_FETCH_THRESHOLD, wrapCompoundFetcher, } from "./CompoundIndexFetcher";
|
|
31
|
+
import { BaseModel } from "./BaseModel";
|
|
32
|
+
import { BootstrapPhase, LoadStrategy, PropertyType, toError, } from "./types";
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when a delete/archive is blocked by an onDelete: "restrict" relationship.
|
|
35
|
+
*
|
|
36
|
+
* Example: if Label has @Reference("Team", { onDelete: "restrict" }) and you
|
|
37
|
+
* try to delete a Team that has Labels pointing to it, this error is thrown
|
|
38
|
+
* with details about which model and property blocked the deletion.
|
|
39
|
+
*/
|
|
40
|
+
export class RestrictDeleteError extends Error {
|
|
41
|
+
constructor(deletedModelName, deletedModelId, restrictedByModel, restrictedByProperty) {
|
|
42
|
+
super(`Cannot delete ${deletedModelName} "${deletedModelId}": ` +
|
|
43
|
+
`referenced by ${restrictedByModel}.${restrictedByProperty} with onDelete: "restrict"`);
|
|
44
|
+
this.deletedModelName = deletedModelName;
|
|
45
|
+
this.deletedModelId = deletedModelId;
|
|
46
|
+
this.restrictedByModel = restrictedByModel;
|
|
47
|
+
this.restrictedByProperty = restrictedByProperty;
|
|
48
|
+
this.name = "RestrictDeleteError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** @internal Grouped public config → flat internal config. Single mapping
|
|
52
|
+
* point; the discriminated `onDemand` union expands to the legacy flat
|
|
53
|
+
* onDemand* fields here. */
|
|
54
|
+
export function normalizeConfig(c) {
|
|
55
|
+
const od = c.loading?.onDemand;
|
|
56
|
+
return {
|
|
57
|
+
workspaceId: c.workspaceId,
|
|
58
|
+
bootstrapFetcher: c.transport.bootstrapFetcher,
|
|
59
|
+
transactionSender: c.transport.transactionSender,
|
|
60
|
+
syncUrl: c.transport.syncUrl,
|
|
61
|
+
bootstrapSyncGroups: c.transport.bootstrapSyncGroups,
|
|
62
|
+
modelStreams: c.transport.modelStreams,
|
|
63
|
+
sseClientFactory: c.transport.sseClientFactory,
|
|
64
|
+
sseInit: c.transport.sseInit,
|
|
65
|
+
syncTransform: c.transport.syncTransform,
|
|
66
|
+
storageAdapter: c.persistence?.storageAdapter,
|
|
67
|
+
undoLimit: c.persistence?.undoLimit,
|
|
68
|
+
transientIndexDepth: c.loading?.transientIndexDepth,
|
|
69
|
+
deferredModels: c.loading?.deferredModels,
|
|
70
|
+
onDemandFetcher: od?.mode === "perKey" ? od.fetch : undefined,
|
|
71
|
+
onDemandBatchFetcher: od?.mode === "perKey" ? od.batchFetch : undefined,
|
|
72
|
+
onDemandIndexBatchFetcher: od?.mode === "indexBatch" ? od.fetch : undefined,
|
|
73
|
+
serverSupportsCompoundIndexKeys: od?.mode === "indexBatch" ? od.compound != null : undefined,
|
|
74
|
+
compoundIndexFetchThreshold: od?.mode === "indexBatch" ? od.compound?.threshold : undefined,
|
|
75
|
+
onPhaseChange: c.hooks?.onPhaseChange,
|
|
76
|
+
onDeltaPacket: c.hooks?.onDeltaPacket,
|
|
77
|
+
onReady: c.hooks?.onReady,
|
|
78
|
+
onError: c.hooks?.onError,
|
|
79
|
+
onSyncGroupDelete: c.hooks?.onSyncGroupDelete,
|
|
80
|
+
identifierFn: c.advanced?.identifierFn,
|
|
81
|
+
applyFieldTransforms: c.advanced?.applyFieldTransforms,
|
|
82
|
+
routeCommit: c.advanced?.routeCommit,
|
|
83
|
+
onModelTouched: c.advanced?.onModelTouched,
|
|
84
|
+
undoableActions: c.advanced?.undoableActions,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reserved `indexKey` segment used by `getOrLoadAll` to record whole-table
|
|
89
|
+
* coverage in `partialIndexCoverage` alongside real field-keyed entries.
|
|
90
|
+
* Real models must not declare a field named `"*"`.
|
|
91
|
+
*/
|
|
92
|
+
const ALL_INDEX_KEY_SENTINEL = "*";
|
|
93
|
+
export class StoreManager {
|
|
94
|
+
get transientIndexDepth() {
|
|
95
|
+
return this.config.transientIndexDepth ?? DEFAULT_TRANSIENT_INDEX_DEPTH;
|
|
96
|
+
}
|
|
97
|
+
constructor(config) {
|
|
98
|
+
this.stores = new Map();
|
|
99
|
+
this.syncConnection = null;
|
|
100
|
+
this.modelStreams = [];
|
|
101
|
+
this.context = undefined;
|
|
102
|
+
this.fieldTransforms = new Map();
|
|
103
|
+
this.hasFieldTransforms = false;
|
|
104
|
+
this.hasModelTouchedHandler = false;
|
|
105
|
+
this._phase = BootstrapPhase.Idle;
|
|
106
|
+
this._error = null;
|
|
107
|
+
this.stopped = false;
|
|
108
|
+
this.loadedModelsUnsub = null;
|
|
109
|
+
this.syncReconnectTimer = null;
|
|
110
|
+
/**
|
|
111
|
+
* Hot cache of collection coverage, keyed by `"modelName:indexKey:value"`.
|
|
112
|
+
* Each value carries the structured tuple plus the `firstSyncId` (the
|
|
113
|
+
* `lastSyncId` at the time of fetch). Mirrored to the storage adapter's
|
|
114
|
+
* `__partialIndexes` store, so coverage survives reload.
|
|
115
|
+
*/
|
|
116
|
+
this.partialIndexCoverage = new Map();
|
|
117
|
+
this.loadedIds = new Set();
|
|
118
|
+
/** Models whose IDB rows have been hydrated into the pool at least once
|
|
119
|
+
* this session. `getOrLoadAll`'s cache-hit path skips a full IDB scan
|
|
120
|
+
* when a model is in this set — pool stays current via SSE
|
|
121
|
+
* (`shouldHydrateInsert` honors `*`-coverage, see `isModelFullyLoaded`),
|
|
122
|
+
* so a fresh IDB read would just rediscover what pool already holds.
|
|
123
|
+
* Cleared whenever `objectPool.clear()` is called. */
|
|
124
|
+
this.poolSyncedFromIDB = new Set();
|
|
125
|
+
/** Models that have at least one `*`-coverage entry in
|
|
126
|
+
* `partialIndexCoverage` (any scope). Mirror of those entries — kept so
|
|
127
|
+
* `isModelFullyLoaded` is O(1) on the SSE insert hot path. Updated
|
|
128
|
+
* wherever `partialIndexCoverage` mutates a `"*"` row. */
|
|
129
|
+
this.fullyLoadedModels = new Set();
|
|
130
|
+
/** Models with an in-flight `getOrLoadAll` (or `fetchDeferredModels`)
|
|
131
|
+
* fetch. Refcounted because two fetches with different scopes can overlap.
|
|
132
|
+
* `isModelFullyLoaded` returns true while pending so `shouldHydrateInsert`
|
|
133
|
+
* starts admitting SSE deltas immediately — otherwise inserts arriving
|
|
134
|
+
* during the fetch window would land only in IDB and the snapshot's older
|
|
135
|
+
* `hydrateAndPut` pass would overwrite the pool with stale data. */
|
|
136
|
+
this.pendingFullLoadRefcount = new Map();
|
|
137
|
+
/** Tombstone set populated by SSE `D`/`A` handlers while a model has a
|
|
138
|
+
* pending full-load. The merge step at the end of `getOrLoadAll` /
|
|
139
|
+
* `fetchDeferredModels` filters snapshot records through this set so a
|
|
140
|
+
* delete that arrived after the server's snapshot doesn't get resurrected.
|
|
141
|
+
* Cleared when the last in-flight fetch for the model completes. */
|
|
142
|
+
this.inflightDeletes = new Map();
|
|
143
|
+
/** Per-(model, scope) in-flight `getOrLoadAll` promises so concurrent calls
|
|
144
|
+
* coalesce instead of double-fetching. Keyed by `coverageKey`. */
|
|
145
|
+
this.inflightFullLoads = new Map();
|
|
146
|
+
/** Sync groups returned by `config.bootstrapSyncGroups`. Used as a
|
|
147
|
+
* pre-Phase-1 source for `subscribedSyncGroupsForFetch` (when no prior
|
|
148
|
+
* dbMeta exists, currentMeta is still null at fetch time) and unioned
|
|
149
|
+
* into the meta written by `saveMeta` after Phase 1/Partial completes. */
|
|
150
|
+
this.seededSyncGroups = [];
|
|
151
|
+
/** Wired only when `onDemandIndexBatchFetcher` is configured. */
|
|
152
|
+
this.indexBatchLoader = null;
|
|
153
|
+
/** Set of models touched inside the currently open `atomic()` scope.
|
|
154
|
+
* `null` when no scope is active. Mutations register themselves via
|
|
155
|
+
* `registerAtomicTouch` (called from `BaseModel.propertyChanged`). */
|
|
156
|
+
this.activeAtomicScope = null;
|
|
157
|
+
/** True only while the engine replays a redirected commit onto its target.
|
|
158
|
+
* Gates every user-intent hook (`routeCommit`, `onModelTouched`) so the
|
|
159
|
+
* engine's own `assign()`/`save()` during replay isn't mistaken for a
|
|
160
|
+
* fresh user edit. */
|
|
161
|
+
this.suppressUserIntentHooks = false;
|
|
162
|
+
const c = normalizeConfig(config);
|
|
163
|
+
this.config = c;
|
|
164
|
+
this.objectPool = new ObjectPool();
|
|
165
|
+
this.database = c.storageAdapter ?? new Database(c.workspaceId);
|
|
166
|
+
this.transactionQueue = new TransactionQueue(this.database, this.objectPool, c.undoLimit);
|
|
167
|
+
if (c.transactionSender != null) {
|
|
168
|
+
this.transactionQueue.setSender(c.transactionSender);
|
|
169
|
+
}
|
|
170
|
+
this.transactionQueue.setErrorReporter((err, ctx) => this.emitError(err, ctx));
|
|
171
|
+
if (c.undoableActions != null) {
|
|
172
|
+
this.transactionQueue.setActionHandlers(c.undoableActions);
|
|
173
|
+
}
|
|
174
|
+
if (c.onDemandIndexBatchFetcher != null) {
|
|
175
|
+
const fetcher = c.serverSupportsCompoundIndexKeys === true
|
|
176
|
+
? wrapCompoundFetcher(c.onDemandIndexBatchFetcher, this.objectPool, {
|
|
177
|
+
threshold: c.compoundIndexFetchThreshold ?? COMPOUND_FETCH_THRESHOLD,
|
|
178
|
+
onCompoundFetched: (compound, bag) => this.absorbCompoundResponse(compound, bag),
|
|
179
|
+
})
|
|
180
|
+
: c.onDemandIndexBatchFetcher;
|
|
181
|
+
this.indexBatchLoader = new BatchModelLoader(fetcher);
|
|
182
|
+
}
|
|
183
|
+
if (c.applyFieldTransforms != null) {
|
|
184
|
+
const apply = c.applyFieldTransforms;
|
|
185
|
+
for (const meta of ModelRegistry.allModels()) {
|
|
186
|
+
for (const prop of meta.properties.values()) {
|
|
187
|
+
const t = apply(meta, prop);
|
|
188
|
+
if (t != null) {
|
|
189
|
+
this.fieldTransforms.set(StoreManager.fieldTransformKey(meta.name, prop.name), t);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.hasFieldTransforms = this.fieldTransforms.size > 0;
|
|
194
|
+
}
|
|
195
|
+
this.hasModelTouchedHandler = c.onModelTouched != null;
|
|
196
|
+
BaseModel.storeManager = this; // wire auto-commit
|
|
197
|
+
}
|
|
198
|
+
// ── Context (for identifierFn) ───────────────────────────────────────────
|
|
199
|
+
/** Push the live context (e.g. user/tenant info) used by `identifierFn`.
|
|
200
|
+
* Read at id-mint time, not captured — call this whenever the relevant
|
|
201
|
+
* context changes. The React `<SyncProvider context={...}>` prop is a
|
|
202
|
+
* thin wrapper over this. */
|
|
203
|
+
setContext(ctx) {
|
|
204
|
+
this.context = ctx;
|
|
205
|
+
}
|
|
206
|
+
/** Apply any registered field transform for `(instance, propName)` against
|
|
207
|
+
* `value`, returning the result (or `value` unchanged when no rule applies).
|
|
208
|
+
* Setters short-circuit on `hasFieldTransforms` first — by the time this
|
|
209
|
+
* runs we know at least one rule exists somewhere in the engine. */
|
|
210
|
+
applyTransform(instance, propName, value) {
|
|
211
|
+
const modelName = instance.constructor
|
|
212
|
+
._modelName;
|
|
213
|
+
if (modelName == null) {
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
const transform = this.fieldTransforms.get(StoreManager.fieldTransformKey(modelName, propName));
|
|
217
|
+
if (transform == null) {
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
return transform(value, instance, this.context);
|
|
221
|
+
}
|
|
222
|
+
static fieldTransformKey(modelName, propName) {
|
|
223
|
+
return `${modelName}:${propName}`;
|
|
224
|
+
}
|
|
225
|
+
/** Mint a fresh id, honoring `identifierFn` when configured. Folds the
|
|
226
|
+
* registry lookup in so callers can skip it entirely on the no-config
|
|
227
|
+
* path — `new Model()` is hot. */
|
|
228
|
+
mintId(instance) {
|
|
229
|
+
const fn = this.config.identifierFn;
|
|
230
|
+
if (fn == null) {
|
|
231
|
+
return crypto.randomUUID();
|
|
232
|
+
}
|
|
233
|
+
const meta = ModelRegistry.getMetaForInstance(instance);
|
|
234
|
+
return meta != null ? fn(meta, this.context) : crypto.randomUUID();
|
|
235
|
+
}
|
|
236
|
+
// ── Bootstrap phases ──────────────────────────────────────────────────────
|
|
237
|
+
get phase() {
|
|
238
|
+
return this._phase;
|
|
239
|
+
}
|
|
240
|
+
get error() {
|
|
241
|
+
return this._error;
|
|
242
|
+
}
|
|
243
|
+
get isReady() {
|
|
244
|
+
return this._phase === BootstrapPhase.Ready;
|
|
245
|
+
}
|
|
246
|
+
setPhase(phase, detail) {
|
|
247
|
+
this._phase = phase;
|
|
248
|
+
this.config.onPhaseChange?.(phase, detail);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Route an internal error to `config.onError`. No-op when the hook isn't
|
|
252
|
+
* configured. Wrapped in try/catch so a buggy adopter handler can't crash
|
|
253
|
+
* the engine.
|
|
254
|
+
*/
|
|
255
|
+
emitError(err, context) {
|
|
256
|
+
const handler = this.config.onError;
|
|
257
|
+
if (handler == null) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
handler(toError(err), context);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// user's onError threw — swallow
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// ── Bootstrap pipeline ────────────────────────────────────────────────────
|
|
268
|
+
async bootstrap() {
|
|
269
|
+
if (ModelRegistry.allModels().length === 0) {
|
|
270
|
+
throw new Error("No models registered. Import your model files before calling bootstrap().\n" +
|
|
271
|
+
'Example: import "@/lib/models"; // register models');
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
// Kick off the sync-groups hook eagerly so its network RTT overlaps
|
|
275
|
+
// with `database.connect()` + `loadPartialIndexes()` below. The
|
|
276
|
+
// attached `.catch` only suppresses the unhandled-rejection event
|
|
277
|
+
// for the early-stop path; the real `await` further down still
|
|
278
|
+
// re-throws and propagates to the outer try/catch.
|
|
279
|
+
const seededP = this.config.bootstrapSyncGroups?.();
|
|
280
|
+
seededP?.catch(() => { });
|
|
281
|
+
this.setPhase(BootstrapPhase.CreatingStores);
|
|
282
|
+
for (const meta of ModelRegistry.allModels()) {
|
|
283
|
+
let store;
|
|
284
|
+
if (meta.loadStrategy === LoadStrategy.Ephemeral) {
|
|
285
|
+
store = new EphemeralStore(meta, this.database, this.objectPool);
|
|
286
|
+
}
|
|
287
|
+
else if (meta.loadStrategy === LoadStrategy.Partial) {
|
|
288
|
+
store = new PartialStore(meta, this.database, this.objectPool);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
store = new FullStore(meta, this.database, this.objectPool);
|
|
292
|
+
}
|
|
293
|
+
this.stores.set(meta.name, store);
|
|
294
|
+
}
|
|
295
|
+
this.setPhase(BootstrapPhase.ConnectingDatabase);
|
|
296
|
+
await this.database.connect();
|
|
297
|
+
if (this.stopped) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Hydrate the in-memory partial-index cache from the persistent store.
|
|
301
|
+
// Survives reload: coverage recorded in earlier sessions is reused, so
|
|
302
|
+
// already-fetched scoped queries don't re-hit the server. Failure here
|
|
303
|
+
// is non-fatal — the cache stays empty and we re-fetch on demand.
|
|
304
|
+
try {
|
|
305
|
+
for (const entry of await this.database.loadPartialIndexes()) {
|
|
306
|
+
this.partialIndexCoverage.set(StoreManager.collectionKey(entry.modelName, entry.indexKey, entry.value), entry);
|
|
307
|
+
if (entry.indexKey === ALL_INDEX_KEY_SENTINEL) {
|
|
308
|
+
this.fullyLoadedModels.add(entry.modelName);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
this.emitError(err, { kind: "deferredBootstrap", modelNames: [] });
|
|
314
|
+
}
|
|
315
|
+
if (seededP != null) {
|
|
316
|
+
await this.applySeededSyncGroups(await seededP);
|
|
317
|
+
}
|
|
318
|
+
this.setPhase(BootstrapPhase.DeterminingBootstrapType);
|
|
319
|
+
const type = await this.database.determineBootstrapType();
|
|
320
|
+
if (this.stopped) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (type === BootstrapType.Full) {
|
|
324
|
+
await this.fullBootstrap();
|
|
325
|
+
}
|
|
326
|
+
else if (type === BootstrapType.Partial) {
|
|
327
|
+
await this.partialBootstrap();
|
|
328
|
+
// Partial deltas can't fill newly-added models — fetch them in full.
|
|
329
|
+
if (this.database.newlyAddedModels.length > 0) {
|
|
330
|
+
await this.fetchNewlyAddedModels(this.database.newlyAddedModels);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
await this.localBootstrap();
|
|
335
|
+
}
|
|
336
|
+
if (this.stopped) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.setPhase(BootstrapPhase.ConnectingSync);
|
|
340
|
+
const sseFactory = this.config.sseClientFactory ??
|
|
341
|
+
createBrowserSSEFactory(this.config.sseInit);
|
|
342
|
+
const sseErrorReporter = (err, ctx) => this.emitError(err, ctx);
|
|
343
|
+
if (this.config.syncUrl != null) {
|
|
344
|
+
this.syncConnection = new SyncConnection(this.config.syncUrl, this.database, this.objectPool, this.transactionQueue, {
|
|
345
|
+
onPacket: this.config.onDeltaPacket,
|
|
346
|
+
onSyncGroupsChanged: async (added, removed) => {
|
|
347
|
+
await this.handleSyncGroupsAdded(added);
|
|
348
|
+
await this.handleSyncGroupsRemoved(removed);
|
|
349
|
+
},
|
|
350
|
+
isCollectionLoaded: this.isCollectionLoaded.bind(this),
|
|
351
|
+
sseClientFactory: sseFactory,
|
|
352
|
+
transform: this.config.syncTransform,
|
|
353
|
+
reportError: sseErrorReporter,
|
|
354
|
+
isModelFullyLoaded: this.isModelFullyLoaded.bind(this),
|
|
355
|
+
recordInflightDelete: this.recordInflightDelete.bind(this),
|
|
356
|
+
});
|
|
357
|
+
this.syncConnection.connect();
|
|
358
|
+
// Reconnect SSE when the loaded-models set changes — server uses
|
|
359
|
+
// it as `onlyModels` for both catchup and live stream. Debounce so
|
|
360
|
+
// a burst of writes (e.g. getOrLoadCollection batch) only reconnects once.
|
|
361
|
+
this.loadedModelsUnsub = this.database.onLoadedModelsChange(() => this.scheduleSyncReconnect());
|
|
362
|
+
}
|
|
363
|
+
for (const streamConfig of this.config.modelStreams ?? []) {
|
|
364
|
+
const stream = new ModelStream(streamConfig.url, this.database, this.objectPool, streamConfig.onStatusChange, sseFactory, streamConfig.transform, sseErrorReporter);
|
|
365
|
+
stream.connect();
|
|
366
|
+
this.modelStreams.push(stream);
|
|
367
|
+
}
|
|
368
|
+
await this.transactionQueue.resendCached();
|
|
369
|
+
if (this.stopped) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.setPhase(BootstrapPhase.Ready);
|
|
373
|
+
this.config.onReady?.();
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
this._error = err;
|
|
377
|
+
this.setPhase(BootstrapPhase.Error, err.message);
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Full bootstrap — two-phase fetch. Only Eager models are ever shipped;
|
|
383
|
+
* Lazy / Partial / LocalOnly / Ephemeral load on demand
|
|
384
|
+
* or via SSE.
|
|
385
|
+
*
|
|
386
|
+
* Phase 1: critical Eager models (everything NOT in deferredModels).
|
|
387
|
+
* Write to IDB, hydrate into ObjectPool. UI can render.
|
|
388
|
+
*
|
|
389
|
+
* Phase 2: deferred Eager models (Comment, Reaction, Attachment, etc.)
|
|
390
|
+
* in the background after the engine is marked ready.
|
|
391
|
+
*
|
|
392
|
+
* If deferredModels is not configured, every Eager model is fetched in
|
|
393
|
+
* a single request.
|
|
394
|
+
*/
|
|
395
|
+
async fullBootstrap() {
|
|
396
|
+
const deferred = new Set(this.config.deferredModels ?? []);
|
|
397
|
+
const eagerModels = ModelRegistry.eagerModelNames();
|
|
398
|
+
if (deferred.size > 0) {
|
|
399
|
+
// Phase 1: critical Eager models only
|
|
400
|
+
const criticalModels = eagerModels.filter((name) => !deferred.has(name));
|
|
401
|
+
this.setPhase(BootstrapPhase.Fetching, `phase 1: ${criticalModels.length} critical models`);
|
|
402
|
+
const res = await this.config.bootstrapFetcher(BootstrapType.Full, {
|
|
403
|
+
onlyModels: criticalModels,
|
|
404
|
+
syncGroups: this.subscribedSyncGroupsForFetch(),
|
|
405
|
+
currentMeta: this.database.currentMeta,
|
|
406
|
+
});
|
|
407
|
+
this.setPhase(BootstrapPhase.WritingToDatabase);
|
|
408
|
+
await Promise.all(Object.entries(res.models).map(([name, records]) => {
|
|
409
|
+
const store = this.stores.get(name);
|
|
410
|
+
return store != null
|
|
411
|
+
? store.loadFromServer(records)
|
|
412
|
+
: Promise.resolve();
|
|
413
|
+
}));
|
|
414
|
+
await this.applyDeletedIds(res);
|
|
415
|
+
await this.persistFullBootstrapMeta(res);
|
|
416
|
+
// Phase 2: deferred models — runs AFTER bootstrap() returns and the
|
|
417
|
+
// engine is marked ready. The UI is already interactive at this point.
|
|
418
|
+
const deferredModels = eagerModels.filter((name) => deferred.has(name));
|
|
419
|
+
if (deferredModels.length > 0) {
|
|
420
|
+
this.fetchDeferredModels(deferredModels);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
// Single-phase: fetch every Eager model at once. Lazy / Partial /
|
|
425
|
+
// LocalOnly / Ephemeral models are loaded on demand
|
|
426
|
+
// (or never) and don't belong in a bootstrap payload.
|
|
427
|
+
this.setPhase(BootstrapPhase.Fetching, "full");
|
|
428
|
+
const res = await this.config.bootstrapFetcher(BootstrapType.Full, {
|
|
429
|
+
onlyModels: eagerModels,
|
|
430
|
+
syncGroups: this.subscribedSyncGroupsForFetch(),
|
|
431
|
+
currentMeta: this.database.currentMeta,
|
|
432
|
+
});
|
|
433
|
+
this.setPhase(BootstrapPhase.WritingToDatabase);
|
|
434
|
+
await Promise.all(Object.entries(res.models).map(([name, records]) => {
|
|
435
|
+
const store = this.stores.get(name);
|
|
436
|
+
return store != null
|
|
437
|
+
? store.loadFromServer(records)
|
|
438
|
+
: Promise.resolve();
|
|
439
|
+
}));
|
|
440
|
+
await this.applyDeletedIds(res);
|
|
441
|
+
await this.persistFullBootstrapMeta(res);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Background fetch for deferred models (phase 2).
|
|
446
|
+
* Runs after the engine is ready — the UI is already interactive.
|
|
447
|
+
* Uses Full bootstrap because the client has never fetched these models before.
|
|
448
|
+
* Any changes that occurred concurrently during phase 1 are covered by SSE,
|
|
449
|
+
* which connects before this method runs.
|
|
450
|
+
*
|
|
451
|
+
* Bypasses clearModelStore to avoid clobbering concurrent SSE writes to IDB.
|
|
452
|
+
* Uses writeModelsIfAbsent + tombstone filter to merge with SSE — see
|
|
453
|
+
* `agent-docs/04-lazy-loading.md` for the in-flight merge invariants.
|
|
454
|
+
*/
|
|
455
|
+
async fetchDeferredModels(modelNames) {
|
|
456
|
+
for (const name of modelNames) {
|
|
457
|
+
this.beginPendingFullLoad(name);
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const res = await this.config.bootstrapFetcher(BootstrapType.Full, {
|
|
461
|
+
onlyModels: modelNames,
|
|
462
|
+
syncGroups: this.subscribedSyncGroupsForFetch(),
|
|
463
|
+
currentMeta: this.database.currentMeta,
|
|
464
|
+
});
|
|
465
|
+
await Promise.all(Object.entries(res.models).map(async ([name, records]) => {
|
|
466
|
+
const live = this.filterTombstoned(name, records);
|
|
467
|
+
if (live.length > 0) {
|
|
468
|
+
await this.database.writeModelsIfAbsent(name, live);
|
|
469
|
+
}
|
|
470
|
+
}));
|
|
471
|
+
await this.applyDeletedIds(res);
|
|
472
|
+
const meta = this.database.currentMeta;
|
|
473
|
+
if (meta != null && res.lastSyncId > meta.lastSyncId) {
|
|
474
|
+
meta.lastSyncId = res.lastSyncId;
|
|
475
|
+
await this.database.saveMeta(meta);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
// Deferred fetch failure is non-fatal — models load on demand later.
|
|
480
|
+
// Surface to onError so adopters can monitor.
|
|
481
|
+
this.emitError(err, { kind: "deferredBootstrap", modelNames });
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
for (const name of modelNames) {
|
|
485
|
+
this.endPendingFullLoad(name);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Fold the result of `bootstrapSyncGroups` into `dbMeta`. When prior
|
|
490
|
+
* meta exists we persist immediately so `localBootstrap` (no `saveMeta`
|
|
491
|
+
* of its own) and a subsequent reload see the seeded groups; `saveMeta`
|
|
492
|
+
* is safe here because `lastSyncId` is preserved. With no prior meta,
|
|
493
|
+
* stash on the instance — calling `saveMeta` with `lastSyncId: 0` would
|
|
494
|
+
* coerce a fresh bootstrap into the `Local` path. Phase 1's `saveMeta`
|
|
495
|
+
* will fold the stashed set in. */
|
|
496
|
+
async applySeededSyncGroups(seeded) {
|
|
497
|
+
if (seeded.length === 0) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const meta = this.database.currentMeta;
|
|
501
|
+
if (meta == null) {
|
|
502
|
+
this.seededSyncGroups = seeded;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
meta.subscribedSyncGroups = StoreManager.mergeSubscribedGroups(meta.subscribedSyncGroups, seeded);
|
|
506
|
+
await this.database.saveMeta(meta);
|
|
507
|
+
}
|
|
508
|
+
/** Persist `dbMeta` after a Full bootstrap response, folding in any
|
|
509
|
+
* `seededSyncGroups` left over from `bootstrapSyncGroups`. Shared by the
|
|
510
|
+
* Phase-1 and single-phase branches of `fullBootstrap`. */
|
|
511
|
+
async persistFullBootstrapMeta(res) {
|
|
512
|
+
this.setPhase(BootstrapPhase.Hydrating, `${this.objectPool.size} models`);
|
|
513
|
+
await this.database.saveMeta({
|
|
514
|
+
lastSyncId: res.lastSyncId,
|
|
515
|
+
subscribedSyncGroups: StoreManager.mergeSubscribedGroups(this.database.currentMeta?.subscribedSyncGroups, [...res.subscribedSyncGroups, ...this.seededSyncGroups]),
|
|
516
|
+
schemaHash: ModelRegistry.schemaHash,
|
|
517
|
+
dbVersion: this.database.currentMeta?.dbVersion ?? 1,
|
|
518
|
+
backendDatabaseVersion: res.backendDatabaseVersion ?? 0,
|
|
519
|
+
});
|
|
520
|
+
this.seededSyncGroups = [];
|
|
521
|
+
}
|
|
522
|
+
/** Append-only merge: bootstrap responses never shrink the subscription set. */
|
|
523
|
+
static mergeSubscribedGroups(existing, fromResponse) {
|
|
524
|
+
return [...new Set([...(existing ?? []), ...fromResponse])];
|
|
525
|
+
}
|
|
526
|
+
/** Canonical scope for bootstrap-style fetches: persisted set, then the
|
|
527
|
+
* pre-Phase-1 seeded fallback, else `undefined`. */
|
|
528
|
+
subscribedSyncGroupsForFetch() {
|
|
529
|
+
const fromMeta = this.database.currentMeta?.subscribedSyncGroups;
|
|
530
|
+
if (fromMeta != null && fromMeta.length > 0) {
|
|
531
|
+
return fromMeta;
|
|
532
|
+
}
|
|
533
|
+
return this.seededSyncGroups.length > 0 ? this.seededSyncGroups : undefined;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Evict tombstones from IDB (skipping Ephemeral models) and the pool.
|
|
537
|
+
* Run AFTER the upsert pass — if an id is in both `res.models` and
|
|
538
|
+
* `res.deletedIds` the tombstone wins (server's delete is authoritative).
|
|
539
|
+
* Cascade/invalidate are skipped; those flow via SSE D actions.
|
|
540
|
+
*/
|
|
541
|
+
async applyDeletedIds(res) {
|
|
542
|
+
if (res.deletedIds == null) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
for (const [modelName, ids] of Object.entries(res.deletedIds)) {
|
|
546
|
+
if (ids.length === 0) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
550
|
+
if (meta?.loadStrategy !== LoadStrategy.Ephemeral) {
|
|
551
|
+
await this.database.deleteModels(modelName, ids);
|
|
552
|
+
}
|
|
553
|
+
for (const id of ids) {
|
|
554
|
+
this.objectPool.remove(modelName, id);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async partialBootstrap() {
|
|
559
|
+
const existing = this.database.currentMeta;
|
|
560
|
+
// Load from IDB first — UI renders immediately with cached data
|
|
561
|
+
this.setPhase(BootstrapPhase.Hydrating, "from IndexedDB");
|
|
562
|
+
await Promise.all([...this.stores.entries()]
|
|
563
|
+
.filter(([name]) => ModelRegistry.getModelMeta(name)?.loadStrategy ===
|
|
564
|
+
LoadStrategy.Eager)
|
|
565
|
+
.map(([, store]) => store.loadFromDatabase()));
|
|
566
|
+
// Fetch delta from server
|
|
567
|
+
this.setPhase(BootstrapPhase.Fetching, `since syncId ${existing.lastSyncId}`);
|
|
568
|
+
const res = await this.config.bootstrapFetcher(BootstrapType.Partial, {
|
|
569
|
+
sinceSyncId: existing.lastSyncId,
|
|
570
|
+
syncGroups: this.subscribedSyncGroupsForFetch(),
|
|
571
|
+
currentMeta: existing,
|
|
572
|
+
});
|
|
573
|
+
// Check backendDatabaseVersion. If the server's schema changed since our
|
|
574
|
+
// last bootstrap, the delta data might be structured differently (renamed
|
|
575
|
+
// fields, restructured models). We can't safely apply it — fall back to full.
|
|
576
|
+
if (res.backendDatabaseVersion !== undefined &&
|
|
577
|
+
existing.backendDatabaseVersion !== undefined &&
|
|
578
|
+
res.backendDatabaseVersion !== existing.backendDatabaseVersion) {
|
|
579
|
+
this.resetPoolState();
|
|
580
|
+
await this.fullBootstrap();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// Apply delta
|
|
584
|
+
this.setPhase(BootstrapPhase.WritingToDatabase);
|
|
585
|
+
for (const [name, records] of Object.entries(res.models)) {
|
|
586
|
+
await this.database.writeModels(name, records);
|
|
587
|
+
const meta = ModelRegistry.getModelMeta(name);
|
|
588
|
+
if (meta?.loadStrategy === LoadStrategy.Eager) {
|
|
589
|
+
for (const r of records) {
|
|
590
|
+
const existing = this.objectPool.getById(name, r.id);
|
|
591
|
+
if (existing != null) {
|
|
592
|
+
for (const [k, v] of Object.entries(r)) {
|
|
593
|
+
if (k !== "id") {
|
|
594
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
595
|
+
existing[k] = v;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
this.objectPool.hydrateAndPut(name, meta, r);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
await this.applyDeletedIds(res);
|
|
606
|
+
await this.database.saveMeta({
|
|
607
|
+
...existing,
|
|
608
|
+
lastSyncId: res.lastSyncId,
|
|
609
|
+
schemaHash: ModelRegistry.schemaHash,
|
|
610
|
+
dbVersion: existing.dbVersion ?? 1,
|
|
611
|
+
backendDatabaseVersion: res.backendDatabaseVersion ?? existing.backendDatabaseVersion ?? 0,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
async localBootstrap() {
|
|
615
|
+
this.setPhase(BootstrapPhase.Hydrating, "from IndexedDB");
|
|
616
|
+
await Promise.all([...this.stores.entries()]
|
|
617
|
+
.filter(([name]) => ModelRegistry.getModelMeta(name)?.loadStrategy ===
|
|
618
|
+
LoadStrategy.Eager)
|
|
619
|
+
.map(([, store]) => store.loadFromDatabase()));
|
|
620
|
+
}
|
|
621
|
+
// ── Transaction API ────────────────────────────────────────────────────────
|
|
622
|
+
commitCreate(model) {
|
|
623
|
+
const meta = ModelRegistry.getMetaForInstance(model);
|
|
624
|
+
if (meta == null) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (this.config.routeCommit != null && !this.suppressUserIntentHooks) {
|
|
628
|
+
const route = this.resolveCommitRoute({
|
|
629
|
+
kind: "create",
|
|
630
|
+
model,
|
|
631
|
+
modelName: meta.name,
|
|
632
|
+
});
|
|
633
|
+
if (this.applyCreateRoute(model, meta, route)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
model.makeModelObservable();
|
|
638
|
+
this.objectPool.put(meta.name, model);
|
|
639
|
+
const data = model.serialize();
|
|
640
|
+
this.transactionQueue.enqueueCreate(model.id, meta.name, data);
|
|
641
|
+
}
|
|
642
|
+
commitUpdate(modelId, modelName, changes) {
|
|
643
|
+
if (this.config.routeCommit != null && !this.suppressUserIntentHooks) {
|
|
644
|
+
// No pool entry → nothing for the hook to inspect; let the enqueue
|
|
645
|
+
// proceed so the txqueue's own crash-recovery / target-deleted logic
|
|
646
|
+
// is the single source of truth for "model gone."
|
|
647
|
+
const model = this.objectPool.getById(modelName, modelId);
|
|
648
|
+
if (model != null) {
|
|
649
|
+
let previousData;
|
|
650
|
+
const route = this.resolveCommitRoute({
|
|
651
|
+
kind: "update",
|
|
652
|
+
model,
|
|
653
|
+
modelName,
|
|
654
|
+
changes,
|
|
655
|
+
previousData: () => (previousData ?? (previousData = this.previousDataFor(model, changes))),
|
|
656
|
+
});
|
|
657
|
+
if (this.applyUpdateRoute(model, modelName, changes, route)) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
this.transactionQueue.enqueueUpdate(modelId, modelName, changes);
|
|
663
|
+
}
|
|
664
|
+
resolveCommitRoute(intent) {
|
|
665
|
+
const hook = this.config.routeCommit;
|
|
666
|
+
if (hook == null) {
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
return hook(intent) ?? undefined;
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
this.emitError(err, {
|
|
674
|
+
kind: "beforeCommit",
|
|
675
|
+
opKind: intent.kind,
|
|
676
|
+
modelName: intent.modelName,
|
|
677
|
+
modelId: intent.model.id,
|
|
678
|
+
});
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
applyCreateRoute(model, meta, route) {
|
|
683
|
+
if (route === undefined) {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
if (route === "skip") {
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
const modelName = route.modelName ?? meta.name;
|
|
690
|
+
const data = { ...model.serialize(), id: route.modelId };
|
|
691
|
+
this.materializePoolOnly(modelName, [data], { onCollision: "error" });
|
|
692
|
+
this.transactionQueue.enqueueCreate(route.modelId, modelName, data);
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
applyUpdateRoute(source, sourceModelName, changes, route) {
|
|
696
|
+
if (route === undefined) {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
if (route === "skip") {
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
const restoreSource = () => {
|
|
703
|
+
if (route.restoreOriginal === true) {
|
|
704
|
+
for (const [propName, change] of Object.entries(changes)) {
|
|
705
|
+
source.setQuiet(propName, change.oldValue);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
const targetModelName = route.modelName ?? sourceModelName;
|
|
710
|
+
const target = this.objectPool.getById(targetModelName, route.modelId);
|
|
711
|
+
if (target == null) {
|
|
712
|
+
this.emitError(new Error(`routeCommit redirect target not found (model=${targetModelName}, id=${route.modelId})`), {
|
|
713
|
+
kind: "beforeCommit",
|
|
714
|
+
opKind: "update",
|
|
715
|
+
modelName: sourceModelName,
|
|
716
|
+
modelId: source.id,
|
|
717
|
+
});
|
|
718
|
+
// The adopter explicitly diverted away from the source — committing the
|
|
719
|
+
// edit back onto it would be the surprising outcome. Honor the requested
|
|
720
|
+
// restore and drop the write; an SSE/refresh will reconcile the pool.
|
|
721
|
+
restoreSource();
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
restoreSource();
|
|
725
|
+
const replay = {};
|
|
726
|
+
for (const [propName, change] of Object.entries(changes)) {
|
|
727
|
+
replay[propName] = change.newValue;
|
|
728
|
+
}
|
|
729
|
+
this.suppressUserIntentHooks = true;
|
|
730
|
+
try {
|
|
731
|
+
target.assign(replay);
|
|
732
|
+
target.save();
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
this.suppressUserIntentHooks = false;
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
previousDataFor(model, changes) {
|
|
740
|
+
const previous = model.serialize();
|
|
741
|
+
for (const [propName, change] of Object.entries(changes)) {
|
|
742
|
+
previous[propName] = change.oldValue;
|
|
743
|
+
}
|
|
744
|
+
return previous;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Hydrate server-shaped records straight into the pool — no
|
|
748
|
+
* `CreateTransaction`, no server round-trip, no IDB write. Mirrors the insert
|
|
749
|
+
* path SSE uses (`ObjectPool.hydrateAndPut`), so inverse links,
|
|
750
|
+
* `@ReferenceCollection` membership, and `notifyModelChanged` reactivity all
|
|
751
|
+
* wire up automatically.
|
|
752
|
+
*/
|
|
753
|
+
materializePoolOnly(modelName, records, options = {}) {
|
|
754
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
755
|
+
if (meta == null) {
|
|
756
|
+
throw new Error(`materializePoolOnly: unknown model "${modelName}".`);
|
|
757
|
+
}
|
|
758
|
+
const onCollision = options.onCollision ?? "error";
|
|
759
|
+
const instances = [];
|
|
760
|
+
for (const record of records) {
|
|
761
|
+
if (typeof record.id !== "string" || record.id === "") {
|
|
762
|
+
throw new Error(`materializePoolOnly: record for ${modelName} must include a string id.`);
|
|
763
|
+
}
|
|
764
|
+
if (onCollision === "error" &&
|
|
765
|
+
this.objectPool.getById(modelName, record.id) != null) {
|
|
766
|
+
throw new Error(`materializePoolOnly: ${modelName}#${record.id} already exists in the pool.`);
|
|
767
|
+
}
|
|
768
|
+
instances.push(this.objectPool.hydrateAndPut(modelName, meta, record));
|
|
769
|
+
}
|
|
770
|
+
return instances;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Convenience wrapper around `materializePoolOnly` for cloning existing
|
|
774
|
+
* sources into pool-only optimistic mirrors.
|
|
775
|
+
*
|
|
776
|
+
* `transform` receives each source's serialized data plus the source
|
|
777
|
+
* instance and must return a fully-formed record with a different `id`.
|
|
778
|
+
* Use it to rewrite ids and any FK fields that should point at the
|
|
779
|
+
* new scope.
|
|
780
|
+
*
|
|
781
|
+
* Intended for optimistic in-memory mirrors while the server fork-fetch
|
|
782
|
+
* is in flight. When the server's records eventually arrive via SSE on
|
|
783
|
+
* the same ids, `hydrate()` runs in place and the pendingChanges rebase
|
|
784
|
+
* keeps any user edits the user has stacked on top.
|
|
785
|
+
*
|
|
786
|
+
* Throws if `transform` returns the source id unchanged — that would
|
|
787
|
+
* silently overwrite the original instance.
|
|
788
|
+
*/
|
|
789
|
+
clonePoolOnly(sources, transform) {
|
|
790
|
+
const clones = [];
|
|
791
|
+
for (const source of sources) {
|
|
792
|
+
const meta = ModelRegistry.getMetaForInstance(source);
|
|
793
|
+
if (meta == null) {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const cloneData = transform(source.serialize(), source);
|
|
797
|
+
if (cloneData.id === source.id) {
|
|
798
|
+
throw new Error(`clonePoolOnly: clone must have a different id than source ` +
|
|
799
|
+
`(model=${meta.name}, id=${source.id}). Rewrite \`id\` in \`transform\`.`);
|
|
800
|
+
}
|
|
801
|
+
clones.push(...this.materializePoolOnly(meta.name, [cloneData]));
|
|
802
|
+
}
|
|
803
|
+
return clones;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Delete a model WITH client-side cascade and restrict validation.
|
|
807
|
+
*
|
|
808
|
+
* Pre-validation: checks for References with onDelete: "restrict".
|
|
809
|
+
* If any model instance references the one being deleted via a restrict
|
|
810
|
+
* relationship, the delete is refused with a RestrictDeleteError.
|
|
811
|
+
*
|
|
812
|
+
* Cascade: walks the ModelRegistry for:
|
|
813
|
+
* - BackReferences pointing at this model → delete those "owned" models
|
|
814
|
+
* - References with onDelete: "cascade" → delete those dependent models
|
|
815
|
+
* - References with onDelete: "nullify" → set the reference to null
|
|
816
|
+
*
|
|
817
|
+
* All operations are grouped in a batch so undo reverses everything.
|
|
818
|
+
*/
|
|
819
|
+
deleteModel(model) {
|
|
820
|
+
const meta = ModelRegistry.getMetaForInstance(model);
|
|
821
|
+
if (meta == null) {
|
|
822
|
+
return this.transactionQueue.enqueueDelete(model);
|
|
823
|
+
}
|
|
824
|
+
// Pre-validate: check onDelete: "restrict"
|
|
825
|
+
const restriction = this.checkDeleteRestriction(meta.name, model.id);
|
|
826
|
+
if (restriction != null) {
|
|
827
|
+
throw new RestrictDeleteError(meta.name, model.id, restriction.modelName, restriction.propertyName);
|
|
828
|
+
}
|
|
829
|
+
const batchId = this.transactionQueue.hasActiveBatch
|
|
830
|
+
? null
|
|
831
|
+
: this.transactionQueue.beginBatch();
|
|
832
|
+
try {
|
|
833
|
+
this.cascadeDeleteClient(meta.name, model.id);
|
|
834
|
+
this.transactionQueue.enqueueDelete(model);
|
|
835
|
+
}
|
|
836
|
+
finally {
|
|
837
|
+
if (batchId != null) {
|
|
838
|
+
this.transactionQueue.endBatch(batchId);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** Archive a model WITH client-side cascade and restrict validation. */
|
|
843
|
+
archiveModel(model) {
|
|
844
|
+
const meta = ModelRegistry.getMetaForInstance(model);
|
|
845
|
+
if (meta == null) {
|
|
846
|
+
return this.transactionQueue.enqueueArchive(model);
|
|
847
|
+
}
|
|
848
|
+
const restriction = this.checkDeleteRestriction(meta.name, model.id);
|
|
849
|
+
if (restriction != null) {
|
|
850
|
+
throw new RestrictDeleteError(meta.name, model.id, restriction.modelName, restriction.propertyName);
|
|
851
|
+
}
|
|
852
|
+
const batchId = this.transactionQueue.hasActiveBatch
|
|
853
|
+
? null
|
|
854
|
+
: this.transactionQueue.beginBatch();
|
|
855
|
+
try {
|
|
856
|
+
this.cascadeArchiveClient(meta.name, model.id);
|
|
857
|
+
this.transactionQueue.enqueueArchive(model);
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
if (batchId != null) {
|
|
861
|
+
this.transactionQueue.endBatch(batchId);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Check if any Reference with onDelete: "restrict" blocks this deletion.
|
|
867
|
+
*
|
|
868
|
+
* Walks all registered models. For each Reference property that points
|
|
869
|
+
* to the model type being deleted and has onDelete: "restrict", checks
|
|
870
|
+
* if any instance in the ObjectPool actually references the target ID.
|
|
871
|
+
*
|
|
872
|
+
* Returns the first restriction found, or null if deletion is allowed.
|
|
873
|
+
*/
|
|
874
|
+
checkDeleteRestriction(deletedModelName, deletedModelId) {
|
|
875
|
+
for (const meta of ModelRegistry.allModels()) {
|
|
876
|
+
for (const [propName, propMeta] of meta.properties) {
|
|
877
|
+
if (propMeta.type !== PropertyType.Reference) {
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
if (propMeta.referenceTo !== deletedModelName) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (propMeta.onDelete !== "restrict") {
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
// Found a restrict relationship. Check if any instance references our target.
|
|
887
|
+
for (const model of this.objectPool.getAll(meta.name)) {
|
|
888
|
+
if (prop(model, propName) === deletedModelId) {
|
|
889
|
+
return { modelName: meta.name, propertyName: propName };
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Client-side cascade: find and delete/nullify models that reference the
|
|
898
|
+
* one being deleted. Mirrors SyncConnection.cascadeDelete but creates
|
|
899
|
+
* actual transactions (so undo works).
|
|
900
|
+
*/
|
|
901
|
+
cascadeDeleteClient(deletedModelName, deletedModelId) {
|
|
902
|
+
for (const meta of ModelRegistry.allModels()) {
|
|
903
|
+
for (const [propName, propMeta] of meta.properties) {
|
|
904
|
+
// BackReference: "owned by" the deleted model → delete them
|
|
905
|
+
if (propMeta.type === PropertyType.BackReference &&
|
|
906
|
+
propMeta.referenceTo === deletedModelName) {
|
|
907
|
+
const inverseKey = propMeta.inverseOf;
|
|
908
|
+
for (const model of this.objectPool.getAll(meta.name)) {
|
|
909
|
+
if (prop(model, inverseKey) === deletedModelId) {
|
|
910
|
+
this.transactionQueue.enqueueDelete(model);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// Reference with onDelete: "cascade" → delete dependents
|
|
915
|
+
if (propMeta.type === PropertyType.Reference &&
|
|
916
|
+
propMeta.referenceTo === deletedModelName &&
|
|
917
|
+
propMeta.onDelete === "cascade") {
|
|
918
|
+
for (const model of this.objectPool.getAll(meta.name)) {
|
|
919
|
+
if (prop(model, propName) === deletedModelId) {
|
|
920
|
+
this.transactionQueue.enqueueDelete(model);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
// Reference with onDelete: "nullify" → set reference to null
|
|
925
|
+
if (propMeta.type === PropertyType.Reference &&
|
|
926
|
+
propMeta.referenceTo === deletedModelName &&
|
|
927
|
+
propMeta.onDelete === "nullify") {
|
|
928
|
+
for (const model of this.objectPool.getAll(meta.name)) {
|
|
929
|
+
if (prop(model, propName) === deletedModelId) {
|
|
930
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
931
|
+
model[propName] = null;
|
|
932
|
+
model.save();
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/** Same cascade logic for archive. */
|
|
940
|
+
cascadeArchiveClient(archivedModelName, archivedModelId) {
|
|
941
|
+
// Archive cascade is similar but uses onArchive metadata
|
|
942
|
+
for (const meta of ModelRegistry.allModels()) {
|
|
943
|
+
for (const [_propName, propMeta] of meta.properties) {
|
|
944
|
+
if (propMeta.type === PropertyType.BackReference &&
|
|
945
|
+
propMeta.referenceTo === archivedModelName) {
|
|
946
|
+
const inverseKey = propMeta.inverseOf;
|
|
947
|
+
for (const model of this.objectPool.getAll(meta.name)) {
|
|
948
|
+
if (prop(model, inverseKey) === archivedModelId) {
|
|
949
|
+
this.transactionQueue.enqueueArchive(model);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// ── Sync group scoped loading ─────────────────────────────────────────────
|
|
957
|
+
/**
|
|
958
|
+
* Called by SyncConnection when new sync groups are added.
|
|
959
|
+
* Fetches all models scoped to those groups from the server,
|
|
960
|
+
* writes to IDB, and hydrates eager-load ones into the pool.
|
|
961
|
+
*
|
|
962
|
+
* Example: user joins team "t-design" → fetch all Issues, Comments,
|
|
963
|
+
* etc. that belong to that team.
|
|
964
|
+
*/
|
|
965
|
+
async handleSyncGroupsAdded(addedGroups) {
|
|
966
|
+
if (addedGroups.length === 0) {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const dbMeta = this.database.currentMeta;
|
|
970
|
+
if (dbMeta == null) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
// Schema-mismatch return is intentionally ignored: fetchSyncGroupModels
|
|
974
|
+
// already triggered a full re-bootstrap internally, which clears the pool
|
|
975
|
+
// and reloads from scratch. Nothing more for the SSE-driven path to do.
|
|
976
|
+
await this.fetchSyncGroupModels(addedGroups, dbMeta);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Called by SyncConnection when a delta packet's `removedSyncGroups` lists
|
|
980
|
+
* groups the client no longer has access to. SyncConnection has already
|
|
981
|
+
* updated `dbMeta.subscribedSyncGroups`.
|
|
982
|
+
*/
|
|
983
|
+
async handleSyncGroupsRemoved(removedGroups) {
|
|
984
|
+
await this.fireOnSyncGroupDelete(removedGroups);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Fire `onSyncGroupDelete` once per group, serially. Errors thrown by the
|
|
988
|
+
* adopter's callback are caught and routed to `onError` so one bad group
|
|
989
|
+
* doesn't abort cleanup for the rest.
|
|
990
|
+
*/
|
|
991
|
+
async fireOnSyncGroupDelete(groupIds) {
|
|
992
|
+
const cb = this.config.onSyncGroupDelete;
|
|
993
|
+
if (cb == null) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
for (const g of groupIds) {
|
|
997
|
+
try {
|
|
998
|
+
await cb(g, this);
|
|
999
|
+
}
|
|
1000
|
+
catch (err) {
|
|
1001
|
+
this.emitError(err, { kind: "onSyncGroupDelete", groupId: g });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Scoped bootstrap-fetcher call: same fetcher used by full/partial bootstrap,
|
|
1007
|
+
* scoped to a subset of groups via `syncGroups` and to Eager models only.
|
|
1008
|
+
*
|
|
1009
|
+
* Returns `schemaMismatch: true` if the server reports a schema version that
|
|
1010
|
+
* doesn't match what's stored — in that case a full re-bootstrap has already
|
|
1011
|
+
* been triggered and the caller should bail.
|
|
1012
|
+
*/
|
|
1013
|
+
async fetchSyncGroupModels(groups, dbMeta) {
|
|
1014
|
+
let res;
|
|
1015
|
+
try {
|
|
1016
|
+
res = await this.config.bootstrapFetcher(BootstrapType.Full, {
|
|
1017
|
+
syncGroups: groups,
|
|
1018
|
+
onlyModels: ModelRegistry.eagerModelNames(),
|
|
1019
|
+
currentMeta: dbMeta,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
this.emitError(err, { kind: "syncGroupFetch", groups });
|
|
1024
|
+
throw err;
|
|
1025
|
+
}
|
|
1026
|
+
if (res.backendDatabaseVersion !== undefined &&
|
|
1027
|
+
dbMeta.backendDatabaseVersion !== undefined &&
|
|
1028
|
+
res.backendDatabaseVersion !== dbMeta.backendDatabaseVersion) {
|
|
1029
|
+
this.resetPoolState();
|
|
1030
|
+
await this.fullBootstrap();
|
|
1031
|
+
return { schemaMismatch: true };
|
|
1032
|
+
}
|
|
1033
|
+
await this.applyBootstrapResponse(res);
|
|
1034
|
+
// Don't touch dbMeta.lastSyncId. It's a *global* checkpoint — the highest
|
|
1035
|
+
// syncId for which we've applied every event across every subscribed group.
|
|
1036
|
+
// res.lastSyncId only describes the scoped groups, so if it's ahead of the
|
|
1037
|
+
// current checkpoint, the gap [current, res.lastSyncId] may contain events
|
|
1038
|
+
// for OTHER subscribed groups that we haven't received. Advancing the
|
|
1039
|
+
// checkpoint would cause the next SSE reconnect (`?since=<lastSyncId>`) to
|
|
1040
|
+
// skip those events — silent data loss. Leave it alone; SSE will replay
|
|
1041
|
+
// anything the scoped fetcher delivered and writes are overwrite-by-id.
|
|
1042
|
+
return { schemaMismatch: false };
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Targeted full fetch for Eager models added to the registry since the
|
|
1046
|
+
* last connect. Runs after partial bootstrap so existing models keep their
|
|
1047
|
+
* delta-only path; new Eager models get a full snapshot. Non-Eager
|
|
1048
|
+
* additions are silently dropped — they load on demand or not at all.
|
|
1049
|
+
*/
|
|
1050
|
+
async fetchNewlyAddedModels(modelNames) {
|
|
1051
|
+
const eager = new Set(ModelRegistry.eagerModelNames());
|
|
1052
|
+
const targets = modelNames.filter((name) => eager.has(name));
|
|
1053
|
+
if (targets.length === 0) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
let res;
|
|
1057
|
+
try {
|
|
1058
|
+
res = await this.config.bootstrapFetcher(BootstrapType.Full, {
|
|
1059
|
+
onlyModels: targets,
|
|
1060
|
+
syncGroups: this.subscribedSyncGroupsForFetch(),
|
|
1061
|
+
currentMeta: this.database.currentMeta,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
this.emitError(err, { kind: "newModelsBootstrap", modelNames });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
await this.applyBootstrapResponse(res);
|
|
1069
|
+
}
|
|
1070
|
+
/** Write a bootstrap response into IDB + the in-memory pool, then apply
|
|
1071
|
+
* any tombstones it carries. Shared by `fetchSyncGroupModels` and
|
|
1072
|
+
* `fetchNewlyAddedModels` — both are targeted full fetches. */
|
|
1073
|
+
async applyBootstrapResponse(res) {
|
|
1074
|
+
await Promise.all(Object.entries(res.models).map(async ([modelName, records]) => {
|
|
1075
|
+
await this.database.writeModels(modelName, records);
|
|
1076
|
+
this.hydrateEagerModels(modelName, records);
|
|
1077
|
+
}));
|
|
1078
|
+
await this.applyDeletedIds(res);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Write records for Eager models into the pool. Updates existing instances
|
|
1082
|
+
* in-place; creates new ones via hydrateAndPut for models not yet in memory.
|
|
1083
|
+
*/
|
|
1084
|
+
hydrateEagerModels(modelName, records) {
|
|
1085
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1086
|
+
if (meta?.loadStrategy !== LoadStrategy.Eager) {
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
for (const record of records) {
|
|
1090
|
+
const existing = this.objectPool.getById(modelName, record.id);
|
|
1091
|
+
if (existing != null) {
|
|
1092
|
+
for (const [k, v] of Object.entries(record)) {
|
|
1093
|
+
if (k !== "id") {
|
|
1094
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1095
|
+
existing[k] = v;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
this.objectPool.hydrateAndPut(modelName, meta, record);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// ── Sync group lifecycle (user-initiated) ────────────────────────────────
|
|
1105
|
+
/**
|
|
1106
|
+
* Activate a sync group: subscribe to SSE deltas for the group and
|
|
1107
|
+
* optionally fetch its models from the server.
|
|
1108
|
+
*
|
|
1109
|
+
* By default (fetch: true) models are fetched, written to IDB, and hydrated
|
|
1110
|
+
* into the pool before reconnecting. Pass `{ fetch: false }` to subscribe
|
|
1111
|
+
* without fetching — useful when you want SSE deltas to start flowing but
|
|
1112
|
+
* will load models lazily later.
|
|
1113
|
+
*
|
|
1114
|
+
* Pass `{ ephemeral: true }` for session-scoped groups that should not
|
|
1115
|
+
* survive page reloads. The group subscription is kept in memory only —
|
|
1116
|
+
* models are still written to IDB as usual, but the group itself is not
|
|
1117
|
+
* saved to meta.
|
|
1118
|
+
*
|
|
1119
|
+
* Idempotent — does nothing if the group is already active.
|
|
1120
|
+
*
|
|
1121
|
+
* Uses the same `bootstrapFetcher` as initial bootstrap, scoped via the
|
|
1122
|
+
* `syncGroups` option. The server should return only records belonging to
|
|
1123
|
+
* those groups.
|
|
1124
|
+
*/
|
|
1125
|
+
async activateSyncGroup(groupId, { fetch = true, ephemeral = false, } = {}) {
|
|
1126
|
+
const dbMeta = this.database.currentMeta;
|
|
1127
|
+
if (dbMeta == null) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const ids = Array.isArray(groupId) ? groupId : [groupId];
|
|
1131
|
+
const newIds = ids.filter((id) => !dbMeta.subscribedSyncGroups.includes(id));
|
|
1132
|
+
if (newIds.length === 0) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (fetch) {
|
|
1136
|
+
const { schemaMismatch } = await this.fetchSyncGroupModels(newIds, dbMeta);
|
|
1137
|
+
if (schemaMismatch) {
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
const groups = new Set(dbMeta.subscribedSyncGroups);
|
|
1142
|
+
newIds.forEach((id) => groups.add(id));
|
|
1143
|
+
dbMeta.subscribedSyncGroups = [...groups];
|
|
1144
|
+
if (!ephemeral) {
|
|
1145
|
+
await this.database.saveMeta(dbMeta);
|
|
1146
|
+
}
|
|
1147
|
+
this.syncConnection?.reconnect();
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Deactivate a sync group: drop it from the subscribed list, fire
|
|
1151
|
+
* `onSyncGroupDelete` (if configured) so the app can evict pool/IDB records,
|
|
1152
|
+
* and reconnect SSE so the server stops streaming deltas for it.
|
|
1153
|
+
*
|
|
1154
|
+
* If `onSyncGroupDelete` isn't configured the group's records remain in the
|
|
1155
|
+
* pool/IDB. Use `sm.evictByIndex` / `sm.evictWhere` inside the callback, or
|
|
1156
|
+
* walk `ModelRegistry.allModels()` for a generic sweeper.
|
|
1157
|
+
*
|
|
1158
|
+
* Idempotent — does nothing if the group is not currently active.
|
|
1159
|
+
*/
|
|
1160
|
+
async deactivateSyncGroup(groupId) {
|
|
1161
|
+
const dbMeta = this.database.currentMeta;
|
|
1162
|
+
if (dbMeta == null) {
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const ids = Array.isArray(groupId) ? groupId : [groupId];
|
|
1166
|
+
const toRemove = new Set(ids.filter((id) => dbMeta.subscribedSyncGroups.includes(id)));
|
|
1167
|
+
if (toRemove.size === 0) {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
dbMeta.subscribedSyncGroups = dbMeta.subscribedSyncGroups.filter((g) => !toRemove.has(g));
|
|
1171
|
+
await this.database.saveMeta(dbMeta);
|
|
1172
|
+
await this.fireOnSyncGroupDelete(toRemove);
|
|
1173
|
+
this.syncConnection?.reconnect();
|
|
1174
|
+
}
|
|
1175
|
+
batch(fn) {
|
|
1176
|
+
const id = this.transactionQueue.beginBatch();
|
|
1177
|
+
let result;
|
|
1178
|
+
try {
|
|
1179
|
+
result = fn();
|
|
1180
|
+
}
|
|
1181
|
+
catch (err) {
|
|
1182
|
+
this.transactionQueue.endBatch(id);
|
|
1183
|
+
throw err;
|
|
1184
|
+
}
|
|
1185
|
+
if (result instanceof Promise) {
|
|
1186
|
+
return result
|
|
1187
|
+
.finally(() => this.transactionQueue.endBatch(id))
|
|
1188
|
+
.then(() => id);
|
|
1189
|
+
}
|
|
1190
|
+
this.transactionQueue.endBatch(id);
|
|
1191
|
+
return id;
|
|
1192
|
+
}
|
|
1193
|
+
beginBatch() {
|
|
1194
|
+
return this.transactionQueue.beginBatch();
|
|
1195
|
+
}
|
|
1196
|
+
endBatch(id) {
|
|
1197
|
+
this.transactionQueue.endBatch(id);
|
|
1198
|
+
}
|
|
1199
|
+
atomic(fn) {
|
|
1200
|
+
if (this.activeAtomicScope != null) {
|
|
1201
|
+
throw new Error("Nested atomic() is not supported. The outer scope must resolve " +
|
|
1202
|
+
"before opening another.");
|
|
1203
|
+
}
|
|
1204
|
+
const scope = new Set();
|
|
1205
|
+
this.activeAtomicScope = scope;
|
|
1206
|
+
const finalize = (didThrow) => {
|
|
1207
|
+
try {
|
|
1208
|
+
if (didThrow) {
|
|
1209
|
+
for (const m of scope) {
|
|
1210
|
+
m.discardUnsavedChanges();
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
else {
|
|
1214
|
+
this.batch(() => {
|
|
1215
|
+
for (const m of scope) {
|
|
1216
|
+
if (m.hasUnsavedChanges) {
|
|
1217
|
+
m.save();
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
finally {
|
|
1224
|
+
this.activeAtomicScope = null;
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
let result;
|
|
1228
|
+
try {
|
|
1229
|
+
result = fn();
|
|
1230
|
+
}
|
|
1231
|
+
catch (err) {
|
|
1232
|
+
finalize(true);
|
|
1233
|
+
throw err;
|
|
1234
|
+
}
|
|
1235
|
+
if (result instanceof Promise) {
|
|
1236
|
+
return result.then((v) => {
|
|
1237
|
+
finalize(false);
|
|
1238
|
+
return v;
|
|
1239
|
+
}, (err) => {
|
|
1240
|
+
finalize(true);
|
|
1241
|
+
throw err;
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
finalize(false);
|
|
1245
|
+
return result;
|
|
1246
|
+
}
|
|
1247
|
+
/** @internal */
|
|
1248
|
+
registerAtomicTouch(model) {
|
|
1249
|
+
this.activeAtomicScope?.add(model);
|
|
1250
|
+
}
|
|
1251
|
+
/** @internal Called from `BaseModel.propertyChanged` on the clean→dirty
|
|
1252
|
+
* transition. Suppressed during the engine's own redirect replay so the
|
|
1253
|
+
* draft target's `assign()` doesn't re-trigger a user-facing "first edit".
|
|
1254
|
+
* BaseModel guards on `hasModelTouchedHandler` before calling. */
|
|
1255
|
+
fireModelTouched(model, modelName) {
|
|
1256
|
+
if (this.suppressUserIntentHooks) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const hook = this.config.onModelTouched;
|
|
1260
|
+
if (hook == null) {
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
hook(model, modelName);
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
this.emitError(err, {
|
|
1268
|
+
kind: "onModelTouched",
|
|
1269
|
+
modelName,
|
|
1270
|
+
modelId: model.id,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// ── Undo / Redo ───────────────────────────────────────────────────────────
|
|
1275
|
+
undo() {
|
|
1276
|
+
return this.transactionQueue.undo();
|
|
1277
|
+
}
|
|
1278
|
+
redo() {
|
|
1279
|
+
return this.transactionQueue.redo();
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Run a remote side-effect that returns a `changeLogId`, and record it on
|
|
1283
|
+
* the undo stack so the next `undo()` invokes the consumer's
|
|
1284
|
+
* `undoableActions.undo` handler with that id.
|
|
1285
|
+
*
|
|
1286
|
+
* The function may return either the `changeLogId` string directly, or any
|
|
1287
|
+
* object with a `changeLogId` field — in which case the full object is
|
|
1288
|
+
* returned to the caller. Inside an open `batch()`, the action joins the
|
|
1289
|
+
* batch and undoes alongside the model transactions.
|
|
1290
|
+
*
|
|
1291
|
+
* If `fn` throws, nothing is recorded.
|
|
1292
|
+
*/
|
|
1293
|
+
async runUndoable(fn, opts) {
|
|
1294
|
+
const result = await fn();
|
|
1295
|
+
const changeLogId = typeof result === "string" ? result : result.changeLogId;
|
|
1296
|
+
const action = {
|
|
1297
|
+
id: crypto.randomUUID(),
|
|
1298
|
+
changeLogId,
|
|
1299
|
+
actionType: opts?.actionType,
|
|
1300
|
+
metadata: opts?.metadata,
|
|
1301
|
+
timestamp: Date.now(),
|
|
1302
|
+
};
|
|
1303
|
+
this.transactionQueue.enqueueAction(action);
|
|
1304
|
+
return result;
|
|
1305
|
+
}
|
|
1306
|
+
// ── Lazy loading ──────────────────────────────────────────────────────────
|
|
1307
|
+
/**
|
|
1308
|
+
* Builds the `partialIndexCoverage` cache key. The `indexKey` segment is
|
|
1309
|
+
* usually a real model field name, but the value `ALL_INDEX_KEY_SENTINEL`
|
|
1310
|
+
* (`"*"`) is reserved for `getOrLoadAll` whole-table coverage and must
|
|
1311
|
+
* not collide with any real field name.
|
|
1312
|
+
*/
|
|
1313
|
+
static collectionKey(modelName, indexKey, value) {
|
|
1314
|
+
return `${modelName}:${indexKey}:${value}`;
|
|
1315
|
+
}
|
|
1316
|
+
static modelIdKey(modelName, id) {
|
|
1317
|
+
return `${modelName}:${id}`;
|
|
1318
|
+
}
|
|
1319
|
+
/** Pool-first collection lookup where indexKey === value (e.g. all Issues for a team). */
|
|
1320
|
+
async getOrLoadCollection(modelName, indexKey, value) {
|
|
1321
|
+
const inMemory = this.objectPool
|
|
1322
|
+
.getAll(modelName)
|
|
1323
|
+
.filter((m) => prop(m, indexKey) === value);
|
|
1324
|
+
const inMemoryIds = new Set(inMemory.map((m) => m.id));
|
|
1325
|
+
const key = StoreManager.collectionKey(modelName, indexKey, value);
|
|
1326
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1327
|
+
const isEphemeral = meta?.loadStrategy === LoadStrategy.Ephemeral;
|
|
1328
|
+
const results = [...inMemory];
|
|
1329
|
+
// Single resolved fetcher — either the batched loader or the per-triple
|
|
1330
|
+
// callback. Routing both through one local lets TS narrow the null check.
|
|
1331
|
+
const fetchFromServer = this.indexBatchLoader != null
|
|
1332
|
+
? (m, k, v) => this.indexBatchLoader.load({ modelName: m, indexKey: k, value: v })
|
|
1333
|
+
: this.config.onDemandFetcher;
|
|
1334
|
+
if (meta?.loadStrategy !== LoadStrategy.Eager &&
|
|
1335
|
+
fetchFromServer != null &&
|
|
1336
|
+
!this.partialIndexCoverage.has(key) &&
|
|
1337
|
+
// Compound coverage only ever exists when the adopter opted into
|
|
1338
|
+
// server-side compound index keys; skip the parent/FK walk otherwise.
|
|
1339
|
+
(this.config.serverSupportsCompoundIndexKeys !== true ||
|
|
1340
|
+
!this.isCoveredByCompound(modelName, indexKey, value))) {
|
|
1341
|
+
// The server fetch intentionally happens before the IDB read.
|
|
1342
|
+
//
|
|
1343
|
+
// IDB may already contain some records for this collection — written by
|
|
1344
|
+
// prior SSE delta packets — but those are a partial view. There is no way
|
|
1345
|
+
// to tell from IDB alone whether the set is complete. The server is the
|
|
1346
|
+
// only authoritative source for "all records where indexKey = value".
|
|
1347
|
+
//
|
|
1348
|
+
// By fetching first and writing the results into IDB, the subsequent IDB
|
|
1349
|
+
// read below acts as a merge: it picks up both the freshly fetched records
|
|
1350
|
+
// and anything SSE had already written. loadedCollections is then marked,
|
|
1351
|
+
// so future calls skip the server entirely and trust IDB as complete.
|
|
1352
|
+
//
|
|
1353
|
+
// Contrast with getOrLoadById: a single ID lookup is binary — either the record
|
|
1354
|
+
// is in IDB or it isn't — so the server is only consulted as a last resort.
|
|
1355
|
+
const serverRecords = await fetchFromServer(modelName, indexKey, value);
|
|
1356
|
+
if (serverRecords.length > 0) {
|
|
1357
|
+
if (isEphemeral) {
|
|
1358
|
+
// Ephemeral models skip IDB — hydrate directly into the pool
|
|
1359
|
+
for (const record of serverRecords) {
|
|
1360
|
+
if (!inMemoryIds.has(record.id)) {
|
|
1361
|
+
results.push(this.objectPool.hydrateAndPut(modelName, meta, record));
|
|
1362
|
+
inMemoryIds.add(record.id);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
else {
|
|
1367
|
+
await this.database.writeModels(modelName, serverRecords);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
// Empty result still expresses "we asked for this model" — mark it
|
|
1371
|
+
// loaded so the SSE catchup URL includes it and future inserts arrive.
|
|
1372
|
+
this.database.markModelLoaded(modelName);
|
|
1373
|
+
// Mark loaded before the IDB read so SSE inserts arriving during
|
|
1374
|
+
// that read are hydrated directly rather than waiting for next access.
|
|
1375
|
+
// The persistent record is set later via markPartialIndexLoaded.
|
|
1376
|
+
this.partialIndexCoverage.set(key, {
|
|
1377
|
+
modelName,
|
|
1378
|
+
indexKey,
|
|
1379
|
+
value,
|
|
1380
|
+
firstSyncId: this.database.currentMeta?.lastSyncId ?? 0,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
if (!isEphemeral) {
|
|
1384
|
+
const idbRecords = await this.database.readModelsByIndex(modelName, indexKey, value);
|
|
1385
|
+
if (meta != null) {
|
|
1386
|
+
for (const record of idbRecords) {
|
|
1387
|
+
if (!inMemoryIds.has(record.id)) {
|
|
1388
|
+
results.push(this.objectPool.hydrateAndPut(modelName, meta, record));
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
await this.markPartialIndexLoaded(modelName, indexKey, value);
|
|
1394
|
+
return results;
|
|
1395
|
+
}
|
|
1396
|
+
isCollectionLoaded(modelName, indexKey, value) {
|
|
1397
|
+
return this.partialIndexCoverage.has(StoreManager.collectionKey(modelName, indexKey, value));
|
|
1398
|
+
}
|
|
1399
|
+
/** True when the model has `*`-coverage (a completed `getOrLoadAll`) or
|
|
1400
|
+
* a full-load fetch is currently in flight. Read on the SSE insert hot
|
|
1401
|
+
* path via `shouldHydrateInsert` — the in-flight branch is what makes
|
|
1402
|
+
* deltas land in the pool during the fetch window. */
|
|
1403
|
+
isModelFullyLoaded(modelName) {
|
|
1404
|
+
return (this.fullyLoadedModels.has(modelName) ||
|
|
1405
|
+
this.pendingFullLoadRefcount.has(modelName));
|
|
1406
|
+
}
|
|
1407
|
+
/** Called by SSE D/A processing whenever a delete arrives. While a model
|
|
1408
|
+
* has a pending full-load, the id is recorded so the in-flight fetch's
|
|
1409
|
+
* merge step can drop a stale-snapshot resurrection. No-op when no fetch
|
|
1410
|
+
* is pending — keeps the hot path cheap. */
|
|
1411
|
+
recordInflightDelete(modelName, id) {
|
|
1412
|
+
if (!this.pendingFullLoadRefcount.has(modelName)) {
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
let set = this.inflightDeletes.get(modelName);
|
|
1416
|
+
if (set == null) {
|
|
1417
|
+
set = new Set();
|
|
1418
|
+
this.inflightDeletes.set(modelName, set);
|
|
1419
|
+
}
|
|
1420
|
+
set.add(id);
|
|
1421
|
+
}
|
|
1422
|
+
/** Increment the in-flight refcount for `modelName`. Must be paired with
|
|
1423
|
+
* `endPendingFullLoad`. While refcount > 0, `isModelFullyLoaded` returns
|
|
1424
|
+
* true (admitting SSE inserts to the pool) and `recordInflightDelete`
|
|
1425
|
+
* tracks tombstones. */
|
|
1426
|
+
beginPendingFullLoad(modelName) {
|
|
1427
|
+
const prev = this.pendingFullLoadRefcount.get(modelName) ?? 0;
|
|
1428
|
+
this.pendingFullLoadRefcount.set(modelName, prev + 1);
|
|
1429
|
+
}
|
|
1430
|
+
/** Decrement the in-flight refcount. When it hits 0, drop the tombstone
|
|
1431
|
+
* set — any snapshot that needed it has already merged. */
|
|
1432
|
+
endPendingFullLoad(modelName) {
|
|
1433
|
+
const prev = this.pendingFullLoadRefcount.get(modelName) ?? 0;
|
|
1434
|
+
if (prev <= 1) {
|
|
1435
|
+
this.pendingFullLoadRefcount.delete(modelName);
|
|
1436
|
+
this.inflightDeletes.delete(modelName);
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
this.pendingFullLoadRefcount.set(modelName, prev - 1);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
/** Strip records whose id was tombstoned by an SSE delete during the
|
|
1443
|
+
* in-flight full-load window. Common case (no pending deletes) returns
|
|
1444
|
+
* the input unchanged so the caller doesn't allocate. */
|
|
1445
|
+
filterTombstoned(modelName, records) {
|
|
1446
|
+
const t = this.inflightDeletes.get(modelName);
|
|
1447
|
+
return t != null && t.size > 0
|
|
1448
|
+
? records.filter((r) => !t.has(r.id))
|
|
1449
|
+
: records;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Derive-on-read for compound coverage: a direct triple
|
|
1453
|
+
* `(modelName, indexKey, value)` is implicitly covered when a previously-
|
|
1454
|
+
* fetched compound query `(modelName, "indexKey.fk", parent.fk)` exists
|
|
1455
|
+
* AND the parent of `value` shares that FK value. Walks one hop on the
|
|
1456
|
+
* parent — must stay in sync with `collapseGroup` in
|
|
1457
|
+
* `CompoundIndexFetcher.ts`. If the rewriter ever recurses (e.g. to
|
|
1458
|
+
* `taskId.projectId.workspaceId`), this loop needs the same recursion
|
|
1459
|
+
* or covered reads will silently miss and re-fetch.
|
|
1460
|
+
*
|
|
1461
|
+
* Skipped (returns false) when:
|
|
1462
|
+
* - `indexKey` is already a dotted path (we only check direct keys)
|
|
1463
|
+
* - the FK's referent model isn't registered
|
|
1464
|
+
* - `value`'s parent isn't in the pool
|
|
1465
|
+
* - no outgoing FK on the parent has a matching compound coverage
|
|
1466
|
+
*/
|
|
1467
|
+
isCoveredByCompound(modelName, indexKey, value) {
|
|
1468
|
+
if (indexKey.includes(".")) {
|
|
1469
|
+
return false;
|
|
1470
|
+
}
|
|
1471
|
+
const childMeta = ModelRegistry.getModelMeta(modelName);
|
|
1472
|
+
const fkProp = childMeta?.properties.get(indexKey);
|
|
1473
|
+
if (fkProp?.type !== PropertyType.Reference || fkProp.referenceTo == null) {
|
|
1474
|
+
return false;
|
|
1475
|
+
}
|
|
1476
|
+
const parent = this.objectPool.getById(fkProp.referenceTo, value);
|
|
1477
|
+
if (parent == null) {
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
const parentMeta = ModelRegistry.getModelMeta(fkProp.referenceTo);
|
|
1481
|
+
if (parentMeta == null) {
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
for (const prop of parentMeta.properties.values()) {
|
|
1485
|
+
if (prop.type !== PropertyType.Reference || prop.referenceTo == null) {
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
const v = readFk(parent, prop.name);
|
|
1489
|
+
if (v == null) {
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
const compoundKey = StoreManager.collectionKey(modelName, `${indexKey}.${prop.name}`, v);
|
|
1493
|
+
if (this.partialIndexCoverage.has(compoundKey)) {
|
|
1494
|
+
return true;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return false;
|
|
1498
|
+
}
|
|
1499
|
+
/** Mark a `(modelName, indexKey, value)` query as fully covered locally as
|
|
1500
|
+
* of `firstSyncId`. Updates the in-memory hot cache and the storage
|
|
1501
|
+
* adapter's persistent store. */
|
|
1502
|
+
async markPartialIndexLoaded(modelName, indexKey, value) {
|
|
1503
|
+
const firstSyncId = this.database.currentMeta?.lastSyncId ?? 0;
|
|
1504
|
+
this.partialIndexCoverage.set(StoreManager.collectionKey(modelName, indexKey, value), { modelName, indexKey, value, firstSyncId });
|
|
1505
|
+
if (indexKey === ALL_INDEX_KEY_SENTINEL) {
|
|
1506
|
+
this.fullyLoadedModels.add(modelName);
|
|
1507
|
+
}
|
|
1508
|
+
await this.database.recordPartialIndex(modelName, indexKey, value, firstSyncId);
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Absorb the response from a synthetic compound query produced by
|
|
1512
|
+
* `wrapCompoundFetcher`. The full response bag is written to IDB so
|
|
1513
|
+
* future direct lookups within the compound's coverage area find their
|
|
1514
|
+
* records — `BatchModelLoader.flush` only delivers per-waiter slices,
|
|
1515
|
+
* which would otherwise drop records for parents that weren't in the
|
|
1516
|
+
* original batch. The compound key itself is recorded in
|
|
1517
|
+
* `partialIndexCoverage` so derive-on-read can short-circuit subsequent
|
|
1518
|
+
* direct loads.
|
|
1519
|
+
*/
|
|
1520
|
+
async absorbCompoundResponse(compound, bag) {
|
|
1521
|
+
const meta = ModelRegistry.getModelMeta(compound.modelName);
|
|
1522
|
+
if (meta?.loadStrategy !== LoadStrategy.Ephemeral && bag.length > 0) {
|
|
1523
|
+
await this.database.writeModels(compound.modelName, bag);
|
|
1524
|
+
}
|
|
1525
|
+
await this.markPartialIndexLoaded(compound.modelName, compound.indexKey, compound.value);
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Returns every recorded `(modelName, indexKey, value, firstSyncId)` tuple
|
|
1529
|
+
* known to this client. Adopters can ship the result to the server alongside
|
|
1530
|
+
* a partial fetch so it can return only deltas since each scope's
|
|
1531
|
+
* `firstSyncId` instead of re-shipping the full snapshot.
|
|
1532
|
+
*/
|
|
1533
|
+
getPartialIndexCoverage() {
|
|
1534
|
+
return [...this.partialIndexCoverage.values()];
|
|
1535
|
+
}
|
|
1536
|
+
// ── Eviction helpers ──────────────────────────────────────────────────────
|
|
1537
|
+
/** Walk the pool for `modelName`, removing instances matching `predicate`. */
|
|
1538
|
+
evictFromPool(modelName, predicate) {
|
|
1539
|
+
let count = 0;
|
|
1540
|
+
for (const m of this.objectPool.getAll(modelName)) {
|
|
1541
|
+
if (predicate(m)) {
|
|
1542
|
+
this.objectPool.remove(modelName, m.id);
|
|
1543
|
+
this.loadedIds.delete(StoreManager.modelIdKey(modelName, m.id));
|
|
1544
|
+
count++;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return count;
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Remove every record of `modelName` matching `predicate` from pool and IDB.
|
|
1551
|
+
* Predicate receives hydrated instances (pool) and raw records (IDB); write
|
|
1552
|
+
* predicates that test plain property values so they work on both shapes.
|
|
1553
|
+
* IDB side is a full cursor scan — prefer `evictByIndex` when the match is
|
|
1554
|
+
* "indexed column equals value".
|
|
1555
|
+
*/
|
|
1556
|
+
async evictWhere(modelName, predicate) {
|
|
1557
|
+
const poolCount = this.evictFromPool(modelName, predicate);
|
|
1558
|
+
const records = await this.database.readAllModels(modelName);
|
|
1559
|
+
const ids = records.filter(predicate).map((r) => r.id);
|
|
1560
|
+
if (ids.length > 0) {
|
|
1561
|
+
await this.database.deleteModels(modelName, ids);
|
|
1562
|
+
}
|
|
1563
|
+
return poolCount + ids.length;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Remove every record where `record[indexKey] === value`, using the IDB
|
|
1567
|
+
* index for the database side. Pool side is still a linear scan (no
|
|
1568
|
+
* secondary in-memory index by field value). Also clears the matching
|
|
1569
|
+
* `loadedCollections` cache key so a future `getOrLoadCollection(modelName,
|
|
1570
|
+
* indexKey, value)` re-fetches from the server instead of trusting IDB.
|
|
1571
|
+
*/
|
|
1572
|
+
async evictByIndex(modelName, indexKey, value) {
|
|
1573
|
+
this.evictFromPool(modelName, (m) => m[indexKey] === value);
|
|
1574
|
+
await this.database.deleteModelsByIndex(modelName, indexKey, value);
|
|
1575
|
+
this.partialIndexCoverage.delete(StoreManager.collectionKey(modelName, indexKey, value));
|
|
1576
|
+
if (indexKey === ALL_INDEX_KEY_SENTINEL) {
|
|
1577
|
+
// Multiple scopes can coexist for the same model — only flip the
|
|
1578
|
+
// mirror set off when no other `*` entry remains.
|
|
1579
|
+
let stillCovered = false;
|
|
1580
|
+
for (const entry of this.partialIndexCoverage.values()) {
|
|
1581
|
+
if (entry.modelName === modelName &&
|
|
1582
|
+
entry.indexKey === ALL_INDEX_KEY_SENTINEL) {
|
|
1583
|
+
stillCovered = true;
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
if (!stillCovered) {
|
|
1588
|
+
this.fullyLoadedModels.delete(modelName);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
await this.database.clearPartialIndex(modelName, indexKey, value);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Cascade `evictByIndex` across every model type that owns this FK. Use
|
|
1595
|
+
* when an "owner" id (workspaceId, teamId, userId, …) goes away and the
|
|
1596
|
+
* client should drop every related row in one call. Models that don't
|
|
1597
|
+
* declare `indexKey` as `indexed: true` are skipped — `deleteModelsByIndex`
|
|
1598
|
+
* falls back to a full-store cursor scan when the index is missing, and
|
|
1599
|
+
* walking every store at every call is rarely what the caller wants.
|
|
1600
|
+
*/
|
|
1601
|
+
async evictAllByIndex(indexKey, value) {
|
|
1602
|
+
const models = ModelRegistry.allModels().filter((meta) => meta.properties.get(indexKey)?.indexed === true);
|
|
1603
|
+
await Promise.all(models.map((meta) => this.evictByIndex(meta.name, indexKey, value)));
|
|
1604
|
+
}
|
|
1605
|
+
/** Pool-first bulk lookup by ID (for OwnedCollection resolution). */
|
|
1606
|
+
async getOrLoadByIds(modelName, ids) {
|
|
1607
|
+
if (ids.length === 0) {
|
|
1608
|
+
return [];
|
|
1609
|
+
}
|
|
1610
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1611
|
+
if (meta == null) {
|
|
1612
|
+
return [];
|
|
1613
|
+
}
|
|
1614
|
+
const missingFromPool = ids.filter((id) => this.objectPool.getById(modelName, id) == null);
|
|
1615
|
+
const isEphemeral = meta.loadStrategy === LoadStrategy.Ephemeral;
|
|
1616
|
+
if (missingFromPool.length > 0) {
|
|
1617
|
+
let stillMissing = missingFromPool;
|
|
1618
|
+
if (!isEphemeral) {
|
|
1619
|
+
const idbResults = await Promise.all(missingFromPool.map((id) => this.database.readModel(modelName, id)));
|
|
1620
|
+
stillMissing = [];
|
|
1621
|
+
for (let i = 0; i < missingFromPool.length; i++) {
|
|
1622
|
+
const record = idbResults[i];
|
|
1623
|
+
if (record != null) {
|
|
1624
|
+
this.objectPool.hydrateAndPut(modelName, meta, record);
|
|
1625
|
+
}
|
|
1626
|
+
else {
|
|
1627
|
+
stillMissing.push(missingFromPool[i]);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (stillMissing.length > 0) {
|
|
1632
|
+
const unloaded = stillMissing.filter((id) => !this.loadedIds.has(StoreManager.modelIdKey(modelName, id)));
|
|
1633
|
+
if (unloaded.length > 0) {
|
|
1634
|
+
if (this.config.onDemandBatchFetcher != null) {
|
|
1635
|
+
const serverRecords = await this.config.onDemandBatchFetcher(modelName, unloaded);
|
|
1636
|
+
if (serverRecords.length > 0) {
|
|
1637
|
+
if (!isEphemeral) {
|
|
1638
|
+
await this.database.writeModels(modelName, serverRecords);
|
|
1639
|
+
}
|
|
1640
|
+
for (const record of serverRecords) {
|
|
1641
|
+
this.objectPool.hydrateAndPut(modelName, meta, record);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
// Empty result still expresses "we asked for this model" — mark
|
|
1645
|
+
// it loaded so the SSE catchup URL includes it and future
|
|
1646
|
+
// inserts arrive. Mirrors the same call in `getOrLoadById`.
|
|
1647
|
+
this.database.markModelLoaded(modelName);
|
|
1648
|
+
for (const id of unloaded) {
|
|
1649
|
+
this.loadedIds.add(StoreManager.modelIdKey(modelName, id));
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
await Promise.all(unloaded.map((id) => this.getOrLoadById(modelName, id)));
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return ids
|
|
1659
|
+
.map((id) => this.objectPool.getById(modelName, id))
|
|
1660
|
+
.filter((m) => m != null);
|
|
1661
|
+
}
|
|
1662
|
+
/** Pool-first single-model lookup by ID. */
|
|
1663
|
+
async getOrLoadById(modelName, id) {
|
|
1664
|
+
const existing = this.objectPool.getById(modelName, id);
|
|
1665
|
+
if (existing != null) {
|
|
1666
|
+
return existing;
|
|
1667
|
+
}
|
|
1668
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1669
|
+
const isEphemeral = meta?.loadStrategy === LoadStrategy.Ephemeral;
|
|
1670
|
+
// Check IDB before hitting the server — server is last resort.
|
|
1671
|
+
let record = isEphemeral
|
|
1672
|
+
? null
|
|
1673
|
+
: await this.database.readModel(modelName, id);
|
|
1674
|
+
const idKey = StoreManager.modelIdKey(modelName, id);
|
|
1675
|
+
if (record == null &&
|
|
1676
|
+
this.config.onDemandFetcher != null &&
|
|
1677
|
+
!this.loadedIds.has(idKey)) {
|
|
1678
|
+
const serverRecords = await this.config.onDemandFetcher(modelName, "id", id);
|
|
1679
|
+
if (serverRecords.length > 0) {
|
|
1680
|
+
if (isEphemeral) {
|
|
1681
|
+
record = serverRecords.find((r) => r.id === id) ?? null;
|
|
1682
|
+
}
|
|
1683
|
+
else {
|
|
1684
|
+
await this.database.writeModels(modelName, serverRecords);
|
|
1685
|
+
record = await this.database.readModel(modelName, id);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
// Empty result still expresses "we asked for this model" — mark it as
|
|
1689
|
+
// loaded so the SSE catchup URL includes it and future inserts arrive.
|
|
1690
|
+
this.database.markModelLoaded(modelName);
|
|
1691
|
+
this.loadedIds.add(idKey);
|
|
1692
|
+
}
|
|
1693
|
+
if (record == null) {
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
if (meta == null) {
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
return this.objectPool.hydrateAndPut(modelName, meta, record);
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Load every instance of `modelName`, optionally scoped to a set of sync
|
|
1703
|
+
* groups. Triggers a Full bootstrap fetch on first call, hydrates the
|
|
1704
|
+
* results, and records coverage so subsequent same-scope calls short-circuit.
|
|
1705
|
+
*
|
|
1706
|
+
* Per-strategy behavior:
|
|
1707
|
+
* - Eager / Ephemeral — already fully resident; returns pool snapshot.
|
|
1708
|
+
* - Local — returns IDB contents (no server hit).
|
|
1709
|
+
* - Lazy / Partial — fetches and hydrates.
|
|
1710
|
+
*
|
|
1711
|
+
* Coverage is tracked in `partialIndexCoverage` under the
|
|
1712
|
+
* `ALL_INDEX_KEY_SENTINEL` reserved indexKey — adopters never see it but it
|
|
1713
|
+
* coexists with real indexKeys, so callers must avoid using "*" themselves.
|
|
1714
|
+
*
|
|
1715
|
+
* Concurrent SSE deltas during the fetch are merged via a pending-flag +
|
|
1716
|
+
* tombstone scheme — see `agent-docs/04-lazy-loading.md` for the full
|
|
1717
|
+
* invariants. Concurrent calls with the same `(modelName, scope)` coalesce
|
|
1718
|
+
* into one fetch via `inflightFullLoads`.
|
|
1719
|
+
*/
|
|
1720
|
+
async getOrLoadAll(modelName, opts = {}) {
|
|
1721
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1722
|
+
if (meta == null) {
|
|
1723
|
+
return [];
|
|
1724
|
+
}
|
|
1725
|
+
const { loadStrategy } = meta;
|
|
1726
|
+
if (loadStrategy === LoadStrategy.Eager ||
|
|
1727
|
+
loadStrategy === LoadStrategy.Ephemeral) {
|
|
1728
|
+
return this.objectPool.getAll(modelName);
|
|
1729
|
+
}
|
|
1730
|
+
const scope = (opts.syncGroups ?? []).slice().sort();
|
|
1731
|
+
// Per-element encode so commas inside any ID don't collide with the join.
|
|
1732
|
+
const coverageValue = encodeCsvList(scope);
|
|
1733
|
+
const coverageKey = StoreManager.collectionKey(modelName, ALL_INDEX_KEY_SENTINEL, coverageValue);
|
|
1734
|
+
const isLocal = loadStrategy === LoadStrategy.LocalOnly;
|
|
1735
|
+
const alreadyCovered = isLocal || this.partialIndexCoverage.has(coverageKey);
|
|
1736
|
+
// Fast path: pool was already hydrated this session AND coverage is in
|
|
1737
|
+
// place. SSE keeps the pool current for `*`-covered models (see
|
|
1738
|
+
// `isModelFullyLoaded` + `shouldHydrateInsert`), so no IDB scan needed.
|
|
1739
|
+
if (alreadyCovered && this.poolSyncedFromIDB.has(modelName)) {
|
|
1740
|
+
return this.objectPool.getAll(modelName);
|
|
1741
|
+
}
|
|
1742
|
+
const inflight = this.inflightFullLoads.get(coverageKey);
|
|
1743
|
+
if (inflight != null) {
|
|
1744
|
+
return inflight;
|
|
1745
|
+
}
|
|
1746
|
+
const work = alreadyCovered
|
|
1747
|
+
? this.hydrateFullLoadFromIDB(modelName, meta)
|
|
1748
|
+
: this.fetchAndMergeFullLoad(modelName, scope);
|
|
1749
|
+
this.inflightFullLoads.set(coverageKey, work);
|
|
1750
|
+
try {
|
|
1751
|
+
return (await work);
|
|
1752
|
+
}
|
|
1753
|
+
finally {
|
|
1754
|
+
this.inflightFullLoads.delete(coverageKey);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
/** Hydrate every IDB row for a covered (or Local) model into the pool.
|
|
1758
|
+
* Used the first time `getOrLoadAll` runs this session against a model
|
|
1759
|
+
* whose `*`-coverage was already recorded (e.g. on a warm reload). */
|
|
1760
|
+
async hydrateFullLoadFromIDB(modelName, meta) {
|
|
1761
|
+
const idbRecords = await this.database.readAllModels(modelName);
|
|
1762
|
+
for (const record of idbRecords) {
|
|
1763
|
+
this.objectPool.hydrateAndPut(modelName, meta, record);
|
|
1764
|
+
}
|
|
1765
|
+
this.poolSyncedFromIDB.add(modelName);
|
|
1766
|
+
return this.objectPool.getAll(modelName);
|
|
1767
|
+
}
|
|
1768
|
+
/** Fetch the snapshot and merge it with whatever the SSE pipeline wrote
|
|
1769
|
+
* during the in-flight window. See the JSDoc on `getOrLoadAll` for the
|
|
1770
|
+
* merge invariants (skip pool-present, drop tombstoned, IDB if-absent). */
|
|
1771
|
+
async fetchAndMergeFullLoad(modelName, scope) {
|
|
1772
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1773
|
+
if (meta == null) {
|
|
1774
|
+
return [];
|
|
1775
|
+
}
|
|
1776
|
+
const coverageValue = encodeCsvList(scope);
|
|
1777
|
+
this.beginPendingFullLoad(modelName);
|
|
1778
|
+
try {
|
|
1779
|
+
let res;
|
|
1780
|
+
try {
|
|
1781
|
+
res = await this.config.bootstrapFetcher(BootstrapType.Full, {
|
|
1782
|
+
onlyModels: [modelName],
|
|
1783
|
+
syncGroups: scope.length > 0 ? scope : this.subscribedSyncGroupsForFetch(),
|
|
1784
|
+
currentMeta: this.database.currentMeta,
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
catch (err) {
|
|
1788
|
+
this.emitError(err, { kind: "syncGroupFetch", groups: scope });
|
|
1789
|
+
throw err;
|
|
1790
|
+
}
|
|
1791
|
+
const live = this.filterTombstoned(modelName, res.models[modelName] ?? []);
|
|
1792
|
+
if (live.length > 0) {
|
|
1793
|
+
await this.database.writeModelsIfAbsent(modelName, live);
|
|
1794
|
+
for (const record of live) {
|
|
1795
|
+
const id = record.id;
|
|
1796
|
+
if (id != null && this.objectPool.getById(modelName, id) != null) {
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
this.objectPool.hydrateAndPut(modelName, meta, record);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
this.database.markModelLoaded(modelName);
|
|
1803
|
+
await this.markPartialIndexLoaded(modelName, ALL_INDEX_KEY_SENTINEL, coverageValue);
|
|
1804
|
+
this.poolSyncedFromIDB.add(modelName);
|
|
1805
|
+
return this.objectPool.getAll(modelName);
|
|
1806
|
+
}
|
|
1807
|
+
finally {
|
|
1808
|
+
this.endPendingFullLoad(modelName);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// ── Test / Storybook seeding ─────────────────────────────────────────────
|
|
1812
|
+
//
|
|
1813
|
+
// Pool-only helpers for injecting fixtures without going through
|
|
1814
|
+
// `bootstrapFetcher` or any I/O. The accepted shape mirrors
|
|
1815
|
+
// `BootstrapResponse.models` so adopters can paste fixtures from one to
|
|
1816
|
+
// the other. Re-seeding the same id is idempotent — `hydrateAndPut`
|
|
1817
|
+
// re-hydrates in place rather than constructing a new instance.
|
|
1818
|
+
//
|
|
1819
|
+
// No IDB write, no `partialIndexCoverage` mutation, no `loadedModels`
|
|
1820
|
+
// change. Adopters who want "this collection is fully covered, don't
|
|
1821
|
+
// refetch on subsequent getOrLoadCollection" can additionally call
|
|
1822
|
+
// `getOrLoadCollection` with a no-op fetcher to mark coverage.
|
|
1823
|
+
/** Hydrate `records` into the pool as instances of `modelName` and
|
|
1824
|
+
* return them. Skips any record whose model isn't registered.
|
|
1825
|
+
* Intended for stories and tests, not production. */
|
|
1826
|
+
seed(modelName, records) {
|
|
1827
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1828
|
+
if (meta == null) {
|
|
1829
|
+
return [];
|
|
1830
|
+
}
|
|
1831
|
+
return records.map((record) => this.objectPool.hydrateAndPut(modelName, meta, record));
|
|
1832
|
+
}
|
|
1833
|
+
/** Bulk seed: takes the same shape as `BootstrapResponse.models`. Useful
|
|
1834
|
+
* for one-shot story setup that hydrates a graph in one call. */
|
|
1835
|
+
seedMany(modelsByName) {
|
|
1836
|
+
for (const [modelName, records] of Object.entries(modelsByName)) {
|
|
1837
|
+
this.seed(modelName, records);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
// ── Refresh ──────────────────────────────────────────────────────────────
|
|
1841
|
+
/**
|
|
1842
|
+
* Sync filter over the pool for records of `modelName` whose `indexKey`
|
|
1843
|
+
* field matches `value`. Used by the typed `store.<entity>.peekByIndex`
|
|
1844
|
+
* surface and shared with the diff path inside `refreshCollection`.
|
|
1845
|
+
*/
|
|
1846
|
+
peekByIndex(modelName, indexKey, value) {
|
|
1847
|
+
return this.objectPool
|
|
1848
|
+
.getAll(modelName)
|
|
1849
|
+
.filter((m) => prop(m, indexKey) === value);
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Re-fetch a collection from the server, replacing stale pool data.
|
|
1853
|
+
* Existing instances are updated in-place so references held by
|
|
1854
|
+
* components/hooks remain valid. Models the server no longer returns
|
|
1855
|
+
* are removed from the pool.
|
|
1856
|
+
*/
|
|
1857
|
+
async refreshCollection(modelName, indexKey, value) {
|
|
1858
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1859
|
+
if (meta == null || this.config.onDemandFetcher == null) {
|
|
1860
|
+
return [];
|
|
1861
|
+
}
|
|
1862
|
+
const isEphemeral = meta.loadStrategy === LoadStrategy.Ephemeral;
|
|
1863
|
+
const previousIds = new Set(this.peekByIndex(modelName, indexKey, value).map((m) => m.id));
|
|
1864
|
+
const serverRecords = await this.config.onDemandFetcher(modelName, indexKey, value);
|
|
1865
|
+
if (!isEphemeral) {
|
|
1866
|
+
await this.database.deleteModelsByIndex(modelName, indexKey, value);
|
|
1867
|
+
if (serverRecords.length > 0) {
|
|
1868
|
+
await this.database.writeModels(modelName, serverRecords);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const results = [];
|
|
1872
|
+
for (const record of serverRecords) {
|
|
1873
|
+
results.push(this.objectPool.hydrateAndPut(modelName, meta, record));
|
|
1874
|
+
}
|
|
1875
|
+
const freshIds = new Set(serverRecords.map((r) => r.id));
|
|
1876
|
+
for (const id of previousIds) {
|
|
1877
|
+
if (!freshIds.has(id)) {
|
|
1878
|
+
this.objectPool.remove(modelName, id);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
await this.markPartialIndexLoaded(modelName, indexKey, value);
|
|
1882
|
+
return results;
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Re-fetch specific models by ID from the server.
|
|
1886
|
+
* Existing instances are updated in-place so references remain valid.
|
|
1887
|
+
*/
|
|
1888
|
+
async refreshModels(modelName, ids) {
|
|
1889
|
+
if (ids.length === 0) {
|
|
1890
|
+
return [];
|
|
1891
|
+
}
|
|
1892
|
+
const meta = ModelRegistry.getModelMeta(modelName);
|
|
1893
|
+
if (meta == null) {
|
|
1894
|
+
return [];
|
|
1895
|
+
}
|
|
1896
|
+
const isEphemeral = meta.loadStrategy === LoadStrategy.Ephemeral;
|
|
1897
|
+
let serverRecords = [];
|
|
1898
|
+
if (this.config.onDemandBatchFetcher != null) {
|
|
1899
|
+
serverRecords = await this.config.onDemandBatchFetcher(modelName, ids);
|
|
1900
|
+
}
|
|
1901
|
+
else if (this.config.onDemandFetcher != null) {
|
|
1902
|
+
const fetched = await Promise.all(ids.map((id) => this.config.onDemandFetcher(modelName, "id", id)));
|
|
1903
|
+
serverRecords = fetched.flat();
|
|
1904
|
+
}
|
|
1905
|
+
if (!isEphemeral) {
|
|
1906
|
+
await this.database.deleteModels(modelName, ids);
|
|
1907
|
+
if (serverRecords.length > 0) {
|
|
1908
|
+
await this.database.writeModels(modelName, serverRecords);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
for (const record of serverRecords) {
|
|
1912
|
+
this.objectPool.hydrateAndPut(modelName, meta, record);
|
|
1913
|
+
}
|
|
1914
|
+
const returnedIds = new Set(serverRecords.map((r) => r.id));
|
|
1915
|
+
for (const id of ids) {
|
|
1916
|
+
if (!returnedIds.has(id)) {
|
|
1917
|
+
this.objectPool.remove(modelName, id);
|
|
1918
|
+
}
|
|
1919
|
+
this.loadedIds.add(StoreManager.modelIdKey(modelName, id));
|
|
1920
|
+
}
|
|
1921
|
+
return ids
|
|
1922
|
+
.map((id) => this.objectPool.getById(modelName, id))
|
|
1923
|
+
.filter((m) => m != null);
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Re-fetch all previously loaded collections and models for a given model type.
|
|
1927
|
+
* Existing instances are updated in-place so references remain valid.
|
|
1928
|
+
* Models the server no longer returns are removed from the pool.
|
|
1929
|
+
*/
|
|
1930
|
+
async refreshAllOfModel(modelName) {
|
|
1931
|
+
const prefix = `${modelName}:`;
|
|
1932
|
+
const collectionKeys = [];
|
|
1933
|
+
for (const entry of this.partialIndexCoverage.values()) {
|
|
1934
|
+
if (entry.modelName === modelName) {
|
|
1935
|
+
collectionKeys.push({ indexKey: entry.indexKey, value: entry.value });
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const modelIds = [];
|
|
1939
|
+
for (const key of [...this.loadedIds]) {
|
|
1940
|
+
if (key.startsWith(prefix)) {
|
|
1941
|
+
modelIds.push(key.slice(prefix.length));
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
const collectionResults = await Promise.all(collectionKeys.map(({ indexKey, value }) => this.refreshCollection(modelName, indexKey, value)));
|
|
1945
|
+
// Only re-fetch IDs not already covered by a collection refresh
|
|
1946
|
+
const refreshedIds = new Set(collectionResults.flat().map((m) => m.id));
|
|
1947
|
+
const uncoveredIds = modelIds.filter((id) => !refreshedIds.has(id));
|
|
1948
|
+
if (uncoveredIds.length > 0) {
|
|
1949
|
+
await this.refreshModels(modelName, uncoveredIds);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
// ── Status ────────────────────────────────────────────────────────────────
|
|
1953
|
+
status() {
|
|
1954
|
+
return {
|
|
1955
|
+
phase: this._phase,
|
|
1956
|
+
error: this._error?.message,
|
|
1957
|
+
workspaceId: this.config.workspaceId,
|
|
1958
|
+
objectPoolSize: this.objectPool.size,
|
|
1959
|
+
objectPoolCounts: this.objectPool.counts(),
|
|
1960
|
+
pending: this.transactionQueue.pendingCount,
|
|
1961
|
+
undoDepth: this.transactionQueue.undoDepth,
|
|
1962
|
+
redoDepth: this.transactionQueue.redoDepth,
|
|
1963
|
+
syncConnected: this.syncConnection?.isConnected ?? false,
|
|
1964
|
+
lastSyncId: this.database.currentMeta?.lastSyncId ?? 0,
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
/** Drop in-memory pool + the per-session "we hydrated from IDB" mirror.
|
|
1968
|
+
* Used by the schema-mismatch fallback in delta processing and sync-
|
|
1969
|
+
* group fetches: when the server's data shape changes we throw away the
|
|
1970
|
+
* pool and re-bootstrap. The two clears are an invariant pair —
|
|
1971
|
+
* extracting the helper enforces it across both call sites instead of
|
|
1972
|
+
* trusting future authors to remember. `partialIndexCoverage` is NOT
|
|
1973
|
+
* cleared here because the schema-mismatch path keeps coverage entries;
|
|
1974
|
+
* the `fullyLoadedModels` mirror stays consistent with that. */
|
|
1975
|
+
resetPoolState() {
|
|
1976
|
+
this.objectPool.clear();
|
|
1977
|
+
this.poolSyncedFromIDB.clear();
|
|
1978
|
+
this.pendingFullLoadRefcount.clear();
|
|
1979
|
+
this.inflightDeletes.clear();
|
|
1980
|
+
this.inflightFullLoads.clear();
|
|
1981
|
+
this.seededSyncGroups = [];
|
|
1982
|
+
}
|
|
1983
|
+
async teardown() {
|
|
1984
|
+
this.stopped = true;
|
|
1985
|
+
BaseModel.storeManager = null;
|
|
1986
|
+
this.loadedModelsUnsub?.();
|
|
1987
|
+
this.loadedModelsUnsub = null;
|
|
1988
|
+
if (this.syncReconnectTimer != null) {
|
|
1989
|
+
clearTimeout(this.syncReconnectTimer);
|
|
1990
|
+
this.syncReconnectTimer = null;
|
|
1991
|
+
}
|
|
1992
|
+
this.syncConnection?.disconnect();
|
|
1993
|
+
this.syncConnection = null;
|
|
1994
|
+
for (const stream of this.modelStreams) {
|
|
1995
|
+
stream.disconnect();
|
|
1996
|
+
}
|
|
1997
|
+
this.modelStreams = [];
|
|
1998
|
+
this.transactionQueue.destroy();
|
|
1999
|
+
this.indexBatchLoader?.dispose();
|
|
2000
|
+
this.indexBatchLoader = null;
|
|
2001
|
+
await this.database.close();
|
|
2002
|
+
this.objectPool.clear();
|
|
2003
|
+
this.stores.clear();
|
|
2004
|
+
this.partialIndexCoverage.clear();
|
|
2005
|
+
this.fullyLoadedModels.clear();
|
|
2006
|
+
this.pendingFullLoadRefcount.clear();
|
|
2007
|
+
this.inflightDeletes.clear();
|
|
2008
|
+
this.inflightFullLoads.clear();
|
|
2009
|
+
this.seededSyncGroups = [];
|
|
2010
|
+
this.loadedIds.clear();
|
|
2011
|
+
this.poolSyncedFromIDB.clear();
|
|
2012
|
+
this.fieldTransforms.clear();
|
|
2013
|
+
this.hasFieldTransforms = false;
|
|
2014
|
+
this.hasModelTouchedHandler = false;
|
|
2015
|
+
this.setPhase(BootstrapPhase.Idle);
|
|
2016
|
+
}
|
|
2017
|
+
/** Debounced reconnect for SSE when `loadedModels` mutates. A burst of
|
|
2018
|
+
* transitions in the same tick (or across awaited writes in the same
|
|
2019
|
+
* async chain) coalesces into a single reconnect. setTimeout — not
|
|
2020
|
+
* queueMicrotask — so consecutive `await db.writeModels(A); await
|
|
2021
|
+
* db.writeModels(B)` doesn't reconnect twice. */
|
|
2022
|
+
scheduleSyncReconnect() {
|
|
2023
|
+
if (this.syncReconnectTimer != null || this.syncConnection == null) {
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
this.syncReconnectTimer = setTimeout(() => {
|
|
2027
|
+
this.syncReconnectTimer = null;
|
|
2028
|
+
if (this.stopped) {
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
this.syncConnection?.reconnect();
|
|
2032
|
+
}, 0);
|
|
2033
|
+
}
|
|
2034
|
+
}
|