zerodrift 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/dist/core/BaseModel.d.ts +76 -0
- package/dist/core/BaseModel.js +505 -0
- package/dist/core/BaseSSEConnection.d.ts +31 -0
- package/dist/core/BaseSSEConnection.js +91 -0
- package/dist/core/BatchModelLoader.d.ts +27 -0
- package/dist/core/BatchModelLoader.js +70 -0
- package/dist/core/CompoundIndexFetcher.d.ts +46 -0
- package/dist/core/CompoundIndexFetcher.js +177 -0
- package/dist/core/Database.d.ts +303 -0
- package/dist/core/Database.js +837 -0
- package/dist/core/LazyCollection.d.ts +168 -0
- package/dist/core/LazyCollection.js +403 -0
- package/dist/core/LazyOwnedCollection.d.ts +35 -0
- package/dist/core/LazyOwnedCollection.js +66 -0
- package/dist/core/MemoryAdapter.d.ts +67 -0
- package/dist/core/MemoryAdapter.js +243 -0
- package/dist/core/ModelRegistry.d.ts +64 -0
- package/dist/core/ModelRegistry.js +217 -0
- package/dist/core/ModelStream.d.ts +33 -0
- package/dist/core/ModelStream.js +68 -0
- package/dist/core/ObjectPool.d.ts +113 -0
- package/dist/core/ObjectPool.js +339 -0
- package/dist/core/Store.d.ts +40 -0
- package/dist/core/Store.js +73 -0
- package/dist/core/StoreManager.d.ts +839 -0
- package/dist/core/StoreManager.js +2034 -0
- package/dist/core/SyncConnection.d.ts +105 -0
- package/dist/core/SyncConnection.js +348 -0
- package/dist/core/Transaction.d.ts +114 -0
- package/dist/core/Transaction.js +147 -0
- package/dist/core/TransactionQueue.d.ts +110 -0
- package/dist/core/TransactionQueue.js +601 -0
- package/dist/core/decorators.d.ts +66 -0
- package/dist/core/decorators.js +278 -0
- package/dist/core/hash.d.ts +6 -0
- package/dist/core/hash.js +12 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +18 -0
- package/dist/core/internal.d.ts +27 -0
- package/dist/core/internal.js +25 -0
- package/dist/core/observability.d.ts +21 -0
- package/dist/core/observability.js +66 -0
- package/dist/core/refAccessors.d.ts +43 -0
- package/dist/core/refAccessors.js +80 -0
- package/dist/core/serializers.d.ts +2 -0
- package/dist/core/serializers.js +2 -0
- package/dist/core/types.d.ts +320 -0
- package/dist/core/types.js +84 -0
- package/dist/react/index.d.ts +82 -0
- package/dist/react/index.js +373 -0
- package/dist/schema/builders.d.ts +29 -0
- package/dist/schema/builders.js +81 -0
- package/dist/schema/compile.d.ts +28 -0
- package/dist/schema/compile.js +334 -0
- package/dist/schema/createStore.d.ts +235 -0
- package/dist/schema/createStore.js +264 -0
- package/dist/schema/extend.d.ts +46 -0
- package/dist/schema/extend.js +6 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/infer.d.ts +102 -0
- package/dist/schema/infer.js +1 -0
- package/dist/schema/types.d.ts +76 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/zod.d.ts +90 -0
- package/dist/schema/zod.js +101 -0
- package/package.json +99 -0
|
@@ -0,0 +1,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
|
+
}
|