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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +358 -0
  3. package/dist/core/BaseModel.d.ts +76 -0
  4. package/dist/core/BaseModel.js +505 -0
  5. package/dist/core/BaseSSEConnection.d.ts +31 -0
  6. package/dist/core/BaseSSEConnection.js +91 -0
  7. package/dist/core/BatchModelLoader.d.ts +27 -0
  8. package/dist/core/BatchModelLoader.js +70 -0
  9. package/dist/core/CompoundIndexFetcher.d.ts +46 -0
  10. package/dist/core/CompoundIndexFetcher.js +177 -0
  11. package/dist/core/Database.d.ts +303 -0
  12. package/dist/core/Database.js +837 -0
  13. package/dist/core/LazyCollection.d.ts +168 -0
  14. package/dist/core/LazyCollection.js +403 -0
  15. package/dist/core/LazyOwnedCollection.d.ts +35 -0
  16. package/dist/core/LazyOwnedCollection.js +66 -0
  17. package/dist/core/MemoryAdapter.d.ts +67 -0
  18. package/dist/core/MemoryAdapter.js +243 -0
  19. package/dist/core/ModelRegistry.d.ts +64 -0
  20. package/dist/core/ModelRegistry.js +217 -0
  21. package/dist/core/ModelStream.d.ts +33 -0
  22. package/dist/core/ModelStream.js +68 -0
  23. package/dist/core/ObjectPool.d.ts +113 -0
  24. package/dist/core/ObjectPool.js +339 -0
  25. package/dist/core/Store.d.ts +40 -0
  26. package/dist/core/Store.js +73 -0
  27. package/dist/core/StoreManager.d.ts +839 -0
  28. package/dist/core/StoreManager.js +2034 -0
  29. package/dist/core/SyncConnection.d.ts +105 -0
  30. package/dist/core/SyncConnection.js +348 -0
  31. package/dist/core/Transaction.d.ts +114 -0
  32. package/dist/core/Transaction.js +147 -0
  33. package/dist/core/TransactionQueue.d.ts +110 -0
  34. package/dist/core/TransactionQueue.js +601 -0
  35. package/dist/core/decorators.d.ts +66 -0
  36. package/dist/core/decorators.js +278 -0
  37. package/dist/core/hash.d.ts +6 -0
  38. package/dist/core/hash.js +12 -0
  39. package/dist/core/index.d.ts +16 -0
  40. package/dist/core/index.js +18 -0
  41. package/dist/core/internal.d.ts +27 -0
  42. package/dist/core/internal.js +25 -0
  43. package/dist/core/observability.d.ts +21 -0
  44. package/dist/core/observability.js +66 -0
  45. package/dist/core/refAccessors.d.ts +43 -0
  46. package/dist/core/refAccessors.js +80 -0
  47. package/dist/core/serializers.d.ts +2 -0
  48. package/dist/core/serializers.js +2 -0
  49. package/dist/core/types.d.ts +320 -0
  50. package/dist/core/types.js +84 -0
  51. package/dist/react/index.d.ts +82 -0
  52. package/dist/react/index.js +373 -0
  53. package/dist/schema/builders.d.ts +29 -0
  54. package/dist/schema/builders.js +81 -0
  55. package/dist/schema/compile.d.ts +28 -0
  56. package/dist/schema/compile.js +334 -0
  57. package/dist/schema/createStore.d.ts +235 -0
  58. package/dist/schema/createStore.js +264 -0
  59. package/dist/schema/extend.d.ts +46 -0
  60. package/dist/schema/extend.js +6 -0
  61. package/dist/schema/index.d.ts +13 -0
  62. package/dist/schema/index.js +8 -0
  63. package/dist/schema/infer.d.ts +102 -0
  64. package/dist/schema/infer.js +1 -0
  65. package/dist/schema/types.d.ts +76 -0
  66. package/dist/schema/types.js +1 -0
  67. package/dist/schema/zod.d.ts +90 -0
  68. package/dist/schema/zod.js +101 -0
  69. 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
+ }