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,837 @@
1
+ /**
2
+ * Database — wraps IndexedDB for a single workspace.
3
+ *
4
+ * Schema Migration:
5
+ * Instead of falling back to full bootstrap on every schemaHash change,
6
+ * we run actual IDB migrations:
7
+ * 1. Open the DB at its current version to read meta
8
+ * 2. If schemaHash matches → use as-is
9
+ * 3. If schemaHash differs → close, reopen at version+1
10
+ * 4. In onupgradeneeded: add new stores, remove old stores, update indexes
11
+ * This preserves existing data for unchanged models.
12
+ *
13
+ * Determines bootstrap type:
14
+ * - Full: no DB or meta, or a critical migration that can't be handled
15
+ * - Partial: DB exists with valid data, just need delta since lastSyncId
16
+ * - Local: DB exists, no server contact needed (offline start)
17
+ */
18
+ import { ModelRegistry } from "./ModelRegistry";
19
+ export var BootstrapType;
20
+ (function (BootstrapType) {
21
+ BootstrapType["Full"] = "full";
22
+ BootstrapType["Partial"] = "partial";
23
+ BootstrapType["Local"] = "local";
24
+ })(BootstrapType || (BootstrapType = {}));
25
+ /** Snapshot of every registered model's current `schemaVersion`. */
26
+ export function currentModelVersions() {
27
+ const out = {};
28
+ for (const meta of ModelRegistry.allModels()) {
29
+ out[meta.name] = meta.schemaVersion;
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Diff stored vs current per-model schemaVersions. `cleared` lists models
35
+ * whose version bumped (rows + partial-index coverage wiped) and models
36
+ * removed from the registry (coverage wiped). `newlyAdded` lists models
37
+ * present in the registry but missing from a non-empty `stored` snapshot —
38
+ * the caller targets these in a follow-up full-bootstrap call.
39
+ */
40
+ export async function diffModelVersions(adapter, stored) {
41
+ const cleared = [];
42
+ const newlyAdded = [];
43
+ const current = currentModelVersions();
44
+ const storedMap = stored ?? {};
45
+ const knownStored = Object.keys(storedMap).length > 0;
46
+ for (const [name, version] of Object.entries(current)) {
47
+ const previous = storedMap[name];
48
+ if (previous == null) {
49
+ // No record for this model. Treat as "newly added" only when the
50
+ // adopter has previously persisted some versions (i.e. they upgraded
51
+ // the engine *and* added a model). Otherwise it's a legacy meta and we
52
+ // trust the existing rows.
53
+ if (knownStored) {
54
+ newlyAdded.push(name);
55
+ }
56
+ continue;
57
+ }
58
+ if (previous === version) {
59
+ continue;
60
+ }
61
+ await adapter.clearModelStore(name);
62
+ await adapter.clearPartialIndexesForModel(name);
63
+ cleared.push(name);
64
+ }
65
+ // Models removed from the registry: clear leftover partial-index rows so
66
+ // the `__partialIndexes` store doesn't accumulate orphans. (The model's
67
+ // own object store is already deleted by the IDB schema migration.)
68
+ for (const name of Object.keys(storedMap)) {
69
+ if (!(name in current)) {
70
+ await adapter.clearPartialIndexesForModel(name);
71
+ cleared.push(name);
72
+ }
73
+ }
74
+ return { cleared, newlyAdded };
75
+ }
76
+ /**
77
+ * Tracks which models have at least one row in storage and notifies
78
+ * listeners on add/remove transitions. Composed by both `Database` and
79
+ * `MemoryAdapter` (the trio of mark/unmark/onChange + listener Set is
80
+ * adapter-agnostic, so the duplication doesn't have to live in each).
81
+ */
82
+ export class LoadedModelsTracker {
83
+ constructor() {
84
+ this.set = new Set();
85
+ this.listeners = new Set();
86
+ }
87
+ get loadedModels() {
88
+ return this.set;
89
+ }
90
+ /** Mark a model as having data. Notifies listeners on the first add. */
91
+ markLoaded(modelName) {
92
+ if (this.set.has(modelName)) {
93
+ return;
94
+ }
95
+ this.set.add(modelName);
96
+ this.notify();
97
+ }
98
+ /** Mark a model as empty (e.g. after `clearModelStore`). */
99
+ markUnloaded(modelName) {
100
+ if (!this.set.has(modelName)) {
101
+ return;
102
+ }
103
+ this.set.delete(modelName);
104
+ this.notify();
105
+ }
106
+ /** Empty the tracker without firing listeners — used at the start of
107
+ * `connect()` before re-seeding. */
108
+ reset() {
109
+ this.set.clear();
110
+ }
111
+ /** Seed without notifying — used by `connect()` to populate from storage. */
112
+ seed(modelName) {
113
+ this.set.add(modelName);
114
+ }
115
+ onChange(cb) {
116
+ this.listeners.add(cb);
117
+ return () => this.listeners.delete(cb);
118
+ }
119
+ notify() {
120
+ for (const cb of this.listeners) {
121
+ try {
122
+ cb();
123
+ }
124
+ catch {
125
+ // A misbehaving listener mustn't reject the write that triggered it.
126
+ }
127
+ }
128
+ }
129
+ }
130
+ export class Database {
131
+ get loadedModels() {
132
+ return this.loadedTracker.loadedModels;
133
+ }
134
+ onLoadedModelsChange(cb) {
135
+ return this.loadedTracker.onChange(cb);
136
+ }
137
+ markModelLoaded(modelName) {
138
+ this.loadedTracker.markLoaded(modelName);
139
+ }
140
+ constructor(workspaceId) {
141
+ this.db = null;
142
+ this.meta = null;
143
+ this.newlyAddedModels = [];
144
+ /** Set to true if connect() cleared rows for one or more models because
145
+ * their per-model `schemaVersion` bumped. Forces a Full bootstrap so the
146
+ * cleared rows refill from the server. */
147
+ this.migrationClearedModels = false;
148
+ this.loadedTracker = new LoadedModelsTracker();
149
+ this.workspaceId = workspaceId;
150
+ }
151
+ // =========================================================================
152
+ // Connection with schema migration
153
+ // =========================================================================
154
+ async connect() {
155
+ // Reset per-connect flags so reconnects don't carry forward a previous
156
+ // session's "force Full" signal.
157
+ this.migrationClearedModels = false;
158
+ this.newlyAddedModels = [];
159
+ this.loadedTracker.reset();
160
+ // Gracefully handle environments without IndexedDB (Node.js, agents).
161
+ // All methods guard on this.db == null, so the engine runs in-memory.
162
+ if (typeof indexedDB === "undefined") {
163
+ return;
164
+ }
165
+ const dbName = `sync_${this.workspaceId}`;
166
+ // Step 1: Open at current version to read meta and check schema
167
+ this.db = await this.openDB(dbName);
168
+ const meta = await this.loadMeta();
169
+ // Step 2: If schema matches (or first-time connect with no saved meta),
170
+ // the DB is already in the right shape — no migration needed.
171
+ // On a first connect, createAllStores just ran via onupgradeneeded and
172
+ // created all current model stores; closing and reopening would only risk
173
+ // losing that work on some IDB implementations.
174
+ if (meta == null || meta.schemaHash === ModelRegistry.schemaHash) {
175
+ return;
176
+ }
177
+ // Step 3: Schema changed. Close and reopen at a higher version to trigger migration.
178
+ const oldVersion = this.db.version;
179
+ const newVersion = (meta.dbVersion ?? oldVersion) + 1;
180
+ this.db.close();
181
+ this.db = null;
182
+ // Step 4: Reopen at newVersion → triggers onupgradeneeded
183
+ this.db = await this.openDBWithMigration(dbName, newVersion);
184
+ // Step 5: Diff per-model schemaVersions. Bumped models get their rows +
185
+ // partial-index coverage wiped (the IDB structure migrated in step 4 but
186
+ // the rows are still in the old shape). Newly added models are reported
187
+ // for a targeted follow-up fetch by StoreManager.
188
+ if (meta != null) {
189
+ const { cleared, newlyAdded } = await diffModelVersions(this, meta.modelSchemaVersions);
190
+ this.migrationClearedModels = cleared.length > 0;
191
+ // migrateSchema already pushed any model whose IDB store was newly
192
+ // created (covering the legacy-meta + new-model case where stored
193
+ // versions are empty); on the typical "adopter added a new model" path
194
+ // both sources fire for the same name, so we dedupe.
195
+ this.newlyAddedModels = [
196
+ ...new Set([...this.newlyAddedModels, ...newlyAdded]),
197
+ ];
198
+ }
199
+ // Update the dbVersion + per-model versions in meta after migration
200
+ if (meta != null) {
201
+ meta.dbVersion = newVersion;
202
+ meta.schemaHash = ModelRegistry.schemaHash;
203
+ meta.modelSchemaVersions = currentModelVersions();
204
+ await this.saveMeta(meta);
205
+ }
206
+ await this.seedLoadedModels();
207
+ }
208
+ /** One IDB count() per store to seed `loadedModels` with anything that
209
+ * survived from a prior session. Runs once per connect. */
210
+ async seedLoadedModels() {
211
+ if (this.db == null) {
212
+ return;
213
+ }
214
+ const names = [...this.db.objectStoreNames].filter((name) => !name.startsWith("__"));
215
+ if (names.length === 0) {
216
+ return;
217
+ }
218
+ const tx = this.db.transaction(names, "readonly");
219
+ await Promise.all(names.map((name) => new Promise((resolve, reject) => {
220
+ const r = tx.objectStore(name).count();
221
+ r.onsuccess = () => {
222
+ if (r.result > 0) {
223
+ this.loadedTracker.seed(name);
224
+ }
225
+ resolve();
226
+ };
227
+ r.onerror = () => reject(r.error);
228
+ })));
229
+ }
230
+ /**
231
+ * IDB blocks schema upgrades and deletions until all open connections close.
232
+ * onversionchange is the browser's signal to us: "another tab needs you to
233
+ * let go." Close immediately so the other tab's open/deleteDatabase call
234
+ * can proceed.
235
+ */
236
+ attachVersionChangeHandler(db) {
237
+ db.onversionchange = () => {
238
+ db.close();
239
+ this.db = null;
240
+ };
241
+ }
242
+ /** Open DB at its current version (no migration). */
243
+ openDB(dbName) {
244
+ return new Promise((resolve, reject) => {
245
+ const request = indexedDB.open(dbName);
246
+ request.onupgradeneeded = (event) => {
247
+ // First time creating this DB — set up everything from scratch
248
+ this.createAllStores(event.target.result);
249
+ };
250
+ request.onsuccess = () => {
251
+ const db = request.result;
252
+ this.attachVersionChangeHandler(db);
253
+ resolve(db);
254
+ };
255
+ request.onerror = () => reject(request.error);
256
+ });
257
+ }
258
+ /** Open DB at a specific version, triggering migration in onupgradeneeded. */
259
+ openDBWithMigration(dbName, version) {
260
+ return new Promise((resolve, reject) => {
261
+ const request = indexedDB.open(dbName, version);
262
+ request.onblocked = () => {
263
+ console.warn(`[DB] upgrade to v${version} blocked — another tab has "${dbName}" open`);
264
+ };
265
+ request.onupgradeneeded = (event) => {
266
+ const db = event.target.result;
267
+ // IMPORTANT: use the upgrade transaction from the event, not db.transaction().
268
+ // IDB doesn't allow new transactions during an upgrade.
269
+ const upgradeTx = event.target.transaction;
270
+ this.migrateSchema(db, upgradeTx);
271
+ };
272
+ request.onsuccess = () => {
273
+ const db = request.result;
274
+ this.attachVersionChangeHandler(db);
275
+ resolve(db);
276
+ };
277
+ request.onerror = () => reject(request.error);
278
+ });
279
+ }
280
+ // =========================================================================
281
+ // Schema migration logic
282
+ //
283
+ // Diffs the current IDB object stores against the ModelRegistry:
284
+ // - New models → create object store + indexes
285
+ // - Removed models → delete object store
286
+ // - Changed models → add/remove indexes
287
+ // =========================================================================
288
+ /** Create the engine's reserved stores (`__`-prefixed) if they don't yet
289
+ * exist. Called from both first-time creation and incremental migration —
290
+ * adding a new system store means one entry here, not two. */
291
+ ensureSystemStores(db) {
292
+ if (!db.objectStoreNames.contains("__meta")) {
293
+ db.createObjectStore("__meta");
294
+ }
295
+ if (!db.objectStoreNames.contains("__transactions")) {
296
+ db.createObjectStore("__transactions", { autoIncrement: true });
297
+ }
298
+ if (!db.objectStoreNames.contains("__partialIndexes")) {
299
+ db.createObjectStore("__partialIndexes", {
300
+ keyPath: ["modelName", "indexKey", "value"],
301
+ });
302
+ }
303
+ if (!db.objectStoreNames.contains("__syncActions")) {
304
+ const syncActions = db.createObjectStore("__syncActions", {
305
+ keyPath: ["syncId", "modelName", "modelId"],
306
+ });
307
+ syncActions.createIndex("byModel", ["modelName", "modelId"]);
308
+ syncActions.createIndex("bySyncId", "syncId");
309
+ }
310
+ }
311
+ /** Create all stores from scratch (first-time DB creation). */
312
+ createAllStores(db) {
313
+ this.ensureSystemStores(db);
314
+ for (const modelMeta of ModelRegistry.allModels()) {
315
+ this.createModelStore(db, modelMeta.name);
316
+ }
317
+ }
318
+ /** Run an incremental migration: add/remove/update stores. */
319
+ migrateSchema(db, upgradeTx) {
320
+ this.ensureSystemStores(db);
321
+ const registeredModels = new Set(ModelRegistry.allModels().map((m) => m.name));
322
+ const existingStores = new Set();
323
+ for (let i = 0; i < db.objectStoreNames.length; i++) {
324
+ const name = db.objectStoreNames[i];
325
+ if (!name.startsWith("__")) {
326
+ existingStores.add(name);
327
+ }
328
+ }
329
+ // Add new model stores
330
+ for (const modelName of registeredModels) {
331
+ if (!existingStores.has(modelName)) {
332
+ this.createModelStore(db, modelName);
333
+ this.newlyAddedModels.push(modelName);
334
+ }
335
+ }
336
+ // Remove stores for models that no longer exist
337
+ for (const storeName of existingStores) {
338
+ if (!registeredModels.has(storeName)) {
339
+ db.deleteObjectStore(storeName);
340
+ }
341
+ }
342
+ // Update indexes on existing stores using the upgrade transaction
343
+ for (const modelName of registeredModels) {
344
+ if (existingStores.has(modelName)) {
345
+ this.migrateIndexes(upgradeTx, modelName);
346
+ }
347
+ }
348
+ }
349
+ /** Create an object store for a model with its indexed properties. */
350
+ createModelStore(db, modelName) {
351
+ const store = db.createObjectStore(modelName, { keyPath: "id" });
352
+ const meta = ModelRegistry.getModelMeta(modelName);
353
+ if (meta != null) {
354
+ for (const [propName, propMeta] of meta.properties) {
355
+ if (propMeta.indexed === true) {
356
+ store.createIndex(propName, propName, { unique: false });
357
+ }
358
+ }
359
+ }
360
+ }
361
+ /** Add/remove indexes on an existing store to match current ModelRegistry. */
362
+ migrateIndexes(upgradeTx, modelName) {
363
+ const meta = ModelRegistry.getModelMeta(modelName);
364
+ if (meta == null) {
365
+ return;
366
+ }
367
+ // Use the upgrade transaction — the only transaction that can modify indexes.
368
+ const store = upgradeTx.objectStore(modelName);
369
+ // Indexes that should exist based on current metadata
370
+ const wantedIndexes = new Set();
371
+ for (const [propName, propMeta] of meta.properties) {
372
+ if (propMeta.indexed === true) {
373
+ wantedIndexes.add(propName);
374
+ }
375
+ }
376
+ // Remove indexes that shouldn't exist anymore
377
+ const existingIndexes = [];
378
+ for (let i = 0; i < store.indexNames.length; i++) {
379
+ existingIndexes.push(store.indexNames[i]);
380
+ }
381
+ for (const indexName of existingIndexes) {
382
+ if (!wantedIndexes.has(indexName)) {
383
+ store.deleteIndex(indexName);
384
+ }
385
+ }
386
+ // Add indexes that don't exist yet
387
+ for (const indexName of wantedIndexes) {
388
+ if (!store.indexNames.contains(indexName)) {
389
+ store.createIndex(indexName, indexName, { unique: false });
390
+ }
391
+ }
392
+ }
393
+ // =========================================================================
394
+ // Bootstrap type detection
395
+ // =========================================================================
396
+ async determineBootstrapType() {
397
+ const meta = await this.loadMeta();
398
+ // No meta → first time → full bootstrap
399
+ if (meta == null) {
400
+ return BootstrapType.Full;
401
+ }
402
+ // If migration added new model stores AND there's no prior sync, fall
403
+ // back to Full — there's nothing to fetch deltas against. With a
404
+ // lastSyncId in hand, partial bootstrap proceeds and StoreManager runs
405
+ // a targeted fullBootstrap call for just `newlyAddedModels` after.
406
+ if (this.newlyAddedModels.length > 0 && meta.lastSyncId <= 0) {
407
+ return BootstrapType.Full;
408
+ }
409
+ // A schemaVersion bump cleared rows for one or more models — partial
410
+ // bootstrap won't refill them (it only ships deltas since lastSyncId).
411
+ // Force a Full bootstrap so cleared model stores get repopulated.
412
+ if (this.migrationClearedModels) {
413
+ return BootstrapType.Full;
414
+ }
415
+ // Valid data exists
416
+ if (meta.lastSyncId > 0) {
417
+ return BootstrapType.Partial;
418
+ }
419
+ return BootstrapType.Local;
420
+ }
421
+ // =========================================================================
422
+ // Meta
423
+ // =========================================================================
424
+ async loadMeta() {
425
+ if (this.db == null) {
426
+ return null;
427
+ }
428
+ try {
429
+ const result = await this.idbGet("__meta", "meta");
430
+ this.meta = result;
431
+ return result;
432
+ }
433
+ catch {
434
+ // __meta store might not exist yet (first open before upgrade)
435
+ return null;
436
+ }
437
+ }
438
+ async saveMeta(meta) {
439
+ if (this.db == null) {
440
+ return;
441
+ }
442
+ // Default the per-model schemaVersion snapshot from the live registry
443
+ // when the caller didn't provide one — so bumps are detectable on the
444
+ // next connect. Caller-supplied values win.
445
+ const merged = {
446
+ ...meta,
447
+ modelSchemaVersions: meta.modelSchemaVersions ?? currentModelVersions(),
448
+ };
449
+ this.meta = merged;
450
+ await this.idbPut("__meta", merged, "meta");
451
+ }
452
+ get currentMeta() {
453
+ return this.meta;
454
+ }
455
+ // =========================================================================
456
+ // Model data operations
457
+ // =========================================================================
458
+ async writeModels(modelName, records) {
459
+ if (!this.hasStore(modelName) || records.length === 0) {
460
+ return;
461
+ }
462
+ const tx = this.db.transaction(modelName, "readwrite");
463
+ const store = tx.objectStore(modelName);
464
+ for (const record of records) {
465
+ store.put(record);
466
+ }
467
+ await this.waitForTransaction(tx);
468
+ this.loadedTracker.markLoaded(modelName);
469
+ }
470
+ async writeModelsIfAbsent(modelName, records) {
471
+ if (!this.hasStore(modelName) || records.length === 0) {
472
+ return;
473
+ }
474
+ // IDB transactions on a single connection are serialized, so no gap between
475
+ // the read and write can let a concurrent write slip through.
476
+ const existingKeys = await new Promise((resolve, reject) => {
477
+ const r = this.db.transaction(modelName, "readonly")
478
+ .objectStore(modelName)
479
+ .getAllKeys();
480
+ r.onsuccess = () => resolve(new Set(r.result));
481
+ r.onerror = () => reject(r.error);
482
+ });
483
+ const newRecords = records.filter((r) => !existingKeys.has(r.id));
484
+ if (newRecords.length === 0) {
485
+ return;
486
+ }
487
+ const tx = this.db.transaction(modelName, "readwrite");
488
+ const store = tx.objectStore(modelName);
489
+ for (const record of newRecords) {
490
+ store.put(record);
491
+ }
492
+ await this.waitForTransaction(tx);
493
+ this.loadedTracker.markLoaded(modelName);
494
+ }
495
+ async readAllModels(modelName) {
496
+ if (!this.hasStore(modelName)) {
497
+ return [];
498
+ }
499
+ return this.idbGetAll(modelName);
500
+ }
501
+ async readModel(modelName, id) {
502
+ if (!this.hasStore(modelName)) {
503
+ return null;
504
+ }
505
+ return this.idbGet(modelName, id);
506
+ }
507
+ async readModelsByIndex(modelName, indexName, value) {
508
+ if (!this.hasStore(modelName)) {
509
+ return [];
510
+ }
511
+ return new Promise((resolve, reject) => {
512
+ const tx = this.db.transaction(modelName, "readonly");
513
+ const store = tx.objectStore(modelName);
514
+ if (store.indexNames.contains(indexName)) {
515
+ const r = store.index(indexName).getAll(value);
516
+ r.onsuccess = () => resolve(r.result ?? []);
517
+ r.onerror = () => reject(r.error);
518
+ }
519
+ else {
520
+ // Fallback: full scan + filter (slower, but correct)
521
+ const r = store.getAll();
522
+ r.onsuccess = () => resolve((r.result ?? []).filter((rec) => rec[indexName] === value));
523
+ r.onerror = () => reject(r.error);
524
+ }
525
+ });
526
+ }
527
+ async deleteModel(modelName, id) {
528
+ if (!this.hasStore(modelName)) {
529
+ return;
530
+ }
531
+ const tx = this.db.transaction(modelName, "readwrite");
532
+ tx.objectStore(modelName).delete(id);
533
+ return this.waitForTransaction(tx);
534
+ }
535
+ /** Delete multiple records in a single IDB transaction. */
536
+ async deleteModels(modelName, ids) {
537
+ if (!this.hasStore(modelName) || ids.length === 0) {
538
+ return;
539
+ }
540
+ const tx = this.db.transaction(modelName, "readwrite");
541
+ const store = tx.objectStore(modelName);
542
+ for (const id of ids) {
543
+ store.delete(id);
544
+ }
545
+ return this.waitForTransaction(tx);
546
+ }
547
+ async deleteModelsByIndex(modelName, indexName, value) {
548
+ if (!this.hasStore(modelName)) {
549
+ return;
550
+ }
551
+ return new Promise((resolve, reject) => {
552
+ const tx = this.db.transaction(modelName, "readwrite");
553
+ const store = tx.objectStore(modelName);
554
+ const request = store.indexNames.contains(indexName)
555
+ ? store.index(indexName).openCursor(IDBKeyRange.only(value))
556
+ : store.openCursor();
557
+ request.onsuccess = (event) => {
558
+ const cursor = event.target.result;
559
+ if (cursor == null) {
560
+ return;
561
+ }
562
+ if (!store.indexNames.contains(indexName) &&
563
+ cursor.value[indexName] !== value) {
564
+ cursor.continue();
565
+ return;
566
+ }
567
+ cursor.delete();
568
+ cursor.continue();
569
+ };
570
+ request.onerror = () => reject(request.error);
571
+ tx.oncomplete = () => resolve();
572
+ tx.onerror = () => reject(tx.error);
573
+ });
574
+ }
575
+ async clearModelStore(modelName) {
576
+ if (!this.hasStore(modelName)) {
577
+ return;
578
+ }
579
+ const tx = this.db.transaction(modelName, "readwrite");
580
+ tx.objectStore(modelName).clear();
581
+ await this.waitForTransaction(tx);
582
+ this.loadedTracker.markUnloaded(modelName);
583
+ }
584
+ // =========================================================================
585
+ // Transaction cache
586
+ // =========================================================================
587
+ /**
588
+ * Open a `__transactions` transaction, tolerating the brief window where
589
+ * the connection is closing but not yet nulled — a cross-tab `versionchange`
590
+ * upgrade, or teardown racing an SSE reconnect. In that window `this.db` is
591
+ * still non-null yet `.transaction()` throws `InvalidStateError` ("the
592
+ * database connection is closing"). Returns `null` so callers degrade
593
+ * gracefully: the transaction cache is a best-effort resend buffer that
594
+ * self-heals on the next clean connection.
595
+ */
596
+ openTxCacheTx(mode) {
597
+ if (this.db == null) {
598
+ return null;
599
+ }
600
+ try {
601
+ return this.db.transaction("__transactions", mode);
602
+ }
603
+ catch (err) {
604
+ if (err?.name === "InvalidStateError") {
605
+ return null;
606
+ }
607
+ throw err;
608
+ }
609
+ }
610
+ async cacheTransaction(data) {
611
+ const tx = this.openTxCacheTx("readwrite");
612
+ if (tx == null) {
613
+ return null;
614
+ }
615
+ return new Promise((resolve, reject) => {
616
+ const r = tx.objectStore("__transactions").add(data);
617
+ r.onsuccess = () => resolve(r.result);
618
+ r.onerror = () => reject(r.error);
619
+ });
620
+ }
621
+ async getCachedTransactions() {
622
+ const tx = this.openTxCacheTx("readonly");
623
+ if (tx == null) {
624
+ return [];
625
+ }
626
+ return new Promise((resolve, reject) => {
627
+ const store = tx.objectStore("__transactions");
628
+ const out = [];
629
+ const cursor = store.openCursor();
630
+ cursor.onsuccess = () => {
631
+ const c = cursor.result;
632
+ if (c == null) {
633
+ resolve(out);
634
+ return;
635
+ }
636
+ out.push({ idbKey: c.primaryKey, data: c.value });
637
+ c.continue();
638
+ };
639
+ cursor.onerror = () => reject(cursor.error);
640
+ });
641
+ }
642
+ async deleteCachedTransactions(idbKeys) {
643
+ if (idbKeys.length === 0) {
644
+ return;
645
+ }
646
+ const tx = this.openTxCacheTx("readwrite");
647
+ if (tx == null) {
648
+ return;
649
+ }
650
+ const store = tx.objectStore("__transactions");
651
+ for (const key of idbKeys) {
652
+ store.delete(key);
653
+ }
654
+ return this.waitForTransaction(tx);
655
+ }
656
+ async clearCachedTransactions() {
657
+ const tx = this.openTxCacheTx("readwrite");
658
+ if (tx == null) {
659
+ return;
660
+ }
661
+ tx.objectStore("__transactions").clear();
662
+ return this.waitForTransaction(tx);
663
+ }
664
+ async updateCachedTransaction(idbKey, data) {
665
+ const tx = this.openTxCacheTx("readwrite");
666
+ if (tx == null) {
667
+ return;
668
+ }
669
+ tx.objectStore("__transactions").put(data, idbKey);
670
+ return this.waitForTransaction(tx);
671
+ }
672
+ // =========================================================================
673
+ // SyncAction store — persisted change-log headers for crash recovery.
674
+ // =========================================================================
675
+ async recordSyncActions(actions) {
676
+ if (this.db == null || actions.length === 0) {
677
+ return;
678
+ }
679
+ const tx = this.db.transaction("__syncActions", "readwrite");
680
+ const store = tx.objectStore("__syncActions");
681
+ for (const a of actions) {
682
+ store.put(a);
683
+ }
684
+ return this.waitForTransaction(tx);
685
+ }
686
+ async hasSyncAction(syncId) {
687
+ if (this.db == null) {
688
+ return false;
689
+ }
690
+ return new Promise((resolve, reject) => {
691
+ const r = this.db.transaction("__syncActions", "readonly")
692
+ .objectStore("__syncActions")
693
+ .index("bySyncId")
694
+ .getKey(syncId);
695
+ r.onsuccess = () => resolve(r.result != null);
696
+ r.onerror = () => reject(r.error);
697
+ });
698
+ }
699
+ async findSyncActionsForModel(modelName, modelId) {
700
+ if (this.db == null) {
701
+ return [];
702
+ }
703
+ return new Promise((resolve, reject) => {
704
+ const r = this.db.transaction("__syncActions", "readonly")
705
+ .objectStore("__syncActions")
706
+ .index("byModel")
707
+ .getAll([modelName, modelId]);
708
+ r.onsuccess = () => {
709
+ const rows = (r.result ?? []);
710
+ resolve(rows.map((row) => ({ syncId: row.syncId, action: row.action })));
711
+ };
712
+ r.onerror = () => reject(r.error);
713
+ });
714
+ }
715
+ async pruneSyncActionsBelow(belowSyncId) {
716
+ if (this.db == null) {
717
+ return;
718
+ }
719
+ return new Promise((resolve, reject) => {
720
+ const tx = this.db.transaction("__syncActions", "readwrite");
721
+ const store = tx.objectStore("__syncActions");
722
+ const cursor = store
723
+ .index("bySyncId")
724
+ .openCursor(IDBKeyRange.upperBound(belowSyncId, true));
725
+ cursor.onsuccess = () => {
726
+ const c = cursor.result;
727
+ if (c == null) {
728
+ return;
729
+ }
730
+ c.delete();
731
+ c.continue();
732
+ };
733
+ tx.oncomplete = () => resolve();
734
+ tx.onerror = () => reject(tx.error);
735
+ });
736
+ }
737
+ // =========================================================================
738
+ // Partial-index coverage store
739
+ //
740
+ // Records `(modelName, indexKey, value)` triples for which getOrLoadCollection has
741
+ // fetched in full. Survives reload — on next bootstrap the engine populates
742
+ // its in-memory cache from this store and skips redundant network/IDB work.
743
+ // =========================================================================
744
+ async recordPartialIndex(modelName, indexKey, value, firstSyncId) {
745
+ if (this.db == null) {
746
+ return;
747
+ }
748
+ const tx = this.db.transaction("__partialIndexes", "readwrite");
749
+ tx.objectStore("__partialIndexes").put({
750
+ modelName,
751
+ indexKey,
752
+ value,
753
+ firstSyncId,
754
+ });
755
+ return this.waitForTransaction(tx);
756
+ }
757
+ async clearPartialIndex(modelName, indexKey, value) {
758
+ if (this.db == null) {
759
+ return;
760
+ }
761
+ const tx = this.db.transaction("__partialIndexes", "readwrite");
762
+ tx.objectStore("__partialIndexes").delete([modelName, indexKey, value]);
763
+ return this.waitForTransaction(tx);
764
+ }
765
+ async clearPartialIndexesForModel(modelName) {
766
+ if (this.db == null) {
767
+ return;
768
+ }
769
+ const tx = this.db.transaction("__partialIndexes", "readwrite");
770
+ // IDB delete accepts a key range — drops every entry whose first compound
771
+ // component is `modelName` in a single op.
772
+ tx.objectStore("__partialIndexes").delete(IDBKeyRange.bound([modelName], [modelName, []], false, false));
773
+ return this.waitForTransaction(tx);
774
+ }
775
+ async loadPartialIndexes() {
776
+ if (this.db == null) {
777
+ return [];
778
+ }
779
+ return this.idbGetAll("__partialIndexes");
780
+ }
781
+ // =========================================================================
782
+ // Cleanup
783
+ // =========================================================================
784
+ /** Close the IDB connection without deleting any data. */
785
+ async close() {
786
+ this.db?.close();
787
+ this.db = null;
788
+ }
789
+ /** Close the connection AND delete all persisted data for this workspace. */
790
+ async destroy() {
791
+ await this.close();
792
+ if (typeof indexedDB !== "undefined") {
793
+ indexedDB.deleteDatabase(`sync_${this.workspaceId}`);
794
+ }
795
+ }
796
+ get isConnected() {
797
+ return this.db !== null;
798
+ }
799
+ // =========================================================================
800
+ // Helpers
801
+ // =========================================================================
802
+ hasStore(name) {
803
+ return this.db != null && this.db.objectStoreNames.contains(name);
804
+ }
805
+ idbGet(storeName, key) {
806
+ return new Promise((resolve, reject) => {
807
+ const r = this.db.transaction(storeName, "readonly")
808
+ .objectStore(storeName)
809
+ .get(key);
810
+ r.onsuccess = () => resolve(r.result ?? null);
811
+ r.onerror = () => reject(r.error);
812
+ });
813
+ }
814
+ idbGetAll(storeName) {
815
+ return new Promise((resolve, reject) => {
816
+ const r = this.db.transaction(storeName, "readonly")
817
+ .objectStore(storeName)
818
+ .getAll();
819
+ r.onsuccess = () => resolve(r.result ?? []);
820
+ r.onerror = () => reject(r.error);
821
+ });
822
+ }
823
+ idbPut(storeName, value, key) {
824
+ return new Promise((resolve, reject) => {
825
+ const tx = this.db.transaction(storeName, "readwrite");
826
+ tx.objectStore(storeName).put(value, key);
827
+ tx.oncomplete = () => resolve();
828
+ tx.onerror = () => reject(tx.error);
829
+ });
830
+ }
831
+ waitForTransaction(tx) {
832
+ return new Promise((resolve, reject) => {
833
+ tx.oncomplete = () => resolve();
834
+ tx.onerror = () => reject(tx.error);
835
+ });
836
+ }
837
+ }