@gonvex/client 0.5.2-staging.13 → 0.5.2-staging.15

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.
@@ -1,12 +1,39 @@
1
1
  import { Dexie } from "dexie";
2
+ import { entityRecord, indexedDBReadView } from './indexeddb-read-view.js';
3
+ import { mergeReplicaRecord } from './replica-record.js';
2
4
  const defaultReplicaScope = "default";
3
- const replicaSchemaVersion = 3;
5
+ const replicaSchemaVersion = 5;
6
+ // Keep native IDB request bursts short so another database's durable intent
7
+ // journal can run between batches. All batches remain in one atomic transaction.
8
+ const writeBatchSize = 4;
9
+ // Small replica checkpoints usually change only a few rows. IndexedDB getAll
10
+ // avoids a JS/Dexie continuation per row. The fallback remains streaming so a
11
+ // large peer update cannot pull an unbounded table into a temporary array.
12
+ async function visitRecords(collection, visit) {
13
+ const page = await collection.clone().limit(128).toArray();
14
+ for (const record of page)
15
+ visit(record);
16
+ if (page.length === 128)
17
+ await collection.clone().offset(128).each(visit);
18
+ }
4
19
  /** Atomic normalized web persistence for the Gonvex Local Replica. */
5
20
  export class IndexedDBLocalReplicaStorage {
6
21
  database;
7
22
  initialized;
23
+ channel;
24
+ peers = new Set();
8
25
  constructor(name = "gonvex-local-replica") {
9
- this.database = new Dexie(name);
26
+ this.database = new Dexie(name, { cache: 'disabled' });
27
+ // Gonvex owns subscription delivery and peer synchronization. These private
28
+ // tables are never queried through Dexie.liveQuery; collecting index ranges
29
+ // and retaining a second query cache duplicates work on every replica write.
30
+ this.database.unuse({ stack: 'dbcore', name: 'Cache' });
31
+ this.database.unuse({ stack: 'dbcore', name: 'Observability' });
32
+ this.channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(`gonvex-replica:${name}`) : undefined;
33
+ if (this.channel)
34
+ this.channel.onmessage = event => { if (typeof event.data?.scope === 'string')
35
+ for (const listener of this.peers)
36
+ listener(event.data.scope); };
10
37
  this.database.version(1).stores({ snapshots: "&key" });
11
38
  this.database.version(2).stores({ snapshots: "&scope" }).upgrade(async (transaction) => {
12
39
  await transaction.table("snapshots").toCollection().modify((record) => {
@@ -14,7 +41,7 @@ export class IndexedDBLocalReplicaStorage {
14
41
  delete record.key;
15
42
  });
16
43
  });
17
- this.database.version(replicaSchemaVersion).stores({
44
+ this.database.version(3).stores({
18
45
  entities: "[scope+entity+id], scope, [scope+entity]",
19
46
  windows: "[scope+signature], scope",
20
47
  meta: "[scope+key], scope",
@@ -47,10 +74,170 @@ export class IndexedDBLocalReplicaStorage {
47
74
  // row-bearing server-state store indefinitely.
48
75
  await transaction.table("snapshots").clear();
49
76
  });
77
+ this.database.version(4).stores({
78
+ entities: '[scope+entity+id], scope, [scope+entity], *lookupKeys',
79
+ }).upgrade(async (transaction) => {
80
+ await transaction.table('entities').toCollection().modify((record) => {
81
+ record.lookupKeys = entityRecord(record.scope, record.entity, record.id, JSON.parse(record.value)).lookupKeys;
82
+ });
83
+ });
84
+ this.database.version(replicaSchemaVersion).stores({
85
+ entities: '[scope+entity+id], scope, [scope+entity], *lookupKeys, [scope+sequence]',
86
+ windows: '[scope+signature], scope, [scope+sequence]',
87
+ });
88
+ }
89
+ subscribePeer(listener) { this.peers.add(listener); return () => { this.peers.delete(listener); }; }
90
+ async nextSequence(scope) {
91
+ const prior = await this.database.meta.get([scope, 'sequence']);
92
+ const sequence = Number(prior?.value ?? 0) + 1;
93
+ await this.database.meta.put({ scope, key: 'sequence', value: String(sequence) });
94
+ return sequence;
95
+ }
96
+ versionedRecord(scope, entity, id, row, cursor, sequence, previous) {
97
+ const resolved = cursor ?? { epoch: previous?.authority?.epoch ?? '', revision: 0 };
98
+ const before = previous ? { row: previous.deleted ? null : JSON.parse(previous.value), authority: previous.authority ?? { epoch: resolved.epoch, fields: {} } } : undefined;
99
+ const merged = mergeReplicaRecord(before, row, resolved);
100
+ // Reconnect snapshots often repeat the same projected values/revisions.
101
+ // Preserve the stored record for a no-op; a revision-only advance reuses
102
+ // its serialized value and secondary keys while updating authority.
103
+ if (previous && merged === before)
104
+ return previous;
105
+ if (previous && before && merged.row === before.row)
106
+ return { ...previous, authority: merged.authority, sequence };
107
+ const record = merged.row ? entityRecord(scope, entity, id, merged.row) : { scope, entity, id, value: 'null', lookupKeys: [], deleted: true };
108
+ return { ...record, authority: merged.authority, sequence };
109
+ }
110
+ async writeEntity(scope, entity, id, row, cursor, sequence) {
111
+ const previous = await this.database.entities.get([scope, entity, id]);
112
+ const record = this.versionedRecord(scope, entity, id, row, cursor, sequence, previous);
113
+ if (record !== previous)
114
+ await this.database.entities.put(record);
115
+ return record;
116
+ }
117
+ async writeRows(scope, entity, key, rows, cursor, sequence) {
118
+ return this.writeRowEntries(scope, entity, rows.map(row => [String(row[key]), row]), cursor, sequence);
119
+ }
120
+ async writeRowEntries(scope, entity, rows, cursor, sequence) {
121
+ // Bounded batches retain only the incoming rows and their previous values.
122
+ for (let offset = 0; offset < rows.length; offset += writeBatchSize) {
123
+ const batch = rows.slice(offset, offset + writeBatchSize);
124
+ const prior = await this.database.entities.bulkGet(batch.map(([id]) => [scope, entity, id]));
125
+ const records = batch.map(([id, row], index) => this.versionedRecord(scope, entity, id, row, cursor, sequence, prior[index])).filter((record, index) => record !== prior[index]);
126
+ if (records.length)
127
+ await this.database.entities.bulkPut(records);
128
+ }
129
+ }
130
+ async writeWindows(scope, windows, sequence) {
131
+ const prior = await this.database.windows.bulkGet(windows.map(window => [scope, window.signature]));
132
+ await this.database.windows.bulkPut(windows.flatMap((window, index) => {
133
+ const record = prior[index];
134
+ const before = record && !record.deleted ? JSON.parse(record.value) : undefined;
135
+ if (before?.cursor && (!window.cursor || (before.cursor.epoch === window.cursor.epoch && before.cursor.revision > window.cursor.revision)))
136
+ return [{ ...record, sequence }];
137
+ return [{ scope, sequence, signature: window.signature, value: JSON.stringify(normalizeWindow(window)) }];
138
+ }));
139
+ }
140
+ async saveCursor(scope, cursor) {
141
+ if (!cursor)
142
+ return;
143
+ const epoch = await this.database.meta.get([scope, 'epoch']);
144
+ if (epoch && JSON.parse(epoch.value).current !== cursor.epoch)
145
+ throw new Error('This tab has an obsolete replica epoch. Reconnect before continuing.');
146
+ const prior = await this.database.meta.get([scope, 'cursor']);
147
+ const before = prior ? JSON.parse(prior.value) : undefined;
148
+ if (before?.epoch === cursor.epoch && before.revision > cursor.revision)
149
+ return;
150
+ await this.database.meta.put({ scope, key: 'cursor', value: JSON.stringify(cursor) });
151
+ }
152
+ async acceptEpoch(scope, cursor, sequence) {
153
+ if (!cursor)
154
+ return;
155
+ const prior = await this.database.meta.get([scope, 'epoch']);
156
+ const legacyCursor = prior ? undefined : await this.database.meta.get([scope, 'cursor']);
157
+ const state = prior ? JSON.parse(prior.value) : legacyCursor ? { current: JSON.parse(legacyCursor.value).epoch, retired: [] } : undefined;
158
+ if (state?.current === cursor.epoch) {
159
+ if (!prior)
160
+ await this.database.meta.put({ scope, key: 'epoch', value: JSON.stringify(state) });
161
+ return;
162
+ }
163
+ if (state?.retired.includes(cursor.epoch))
164
+ throw new Error('This tab has an obsolete replica epoch. Reconnect before continuing.');
165
+ if (state) {
166
+ await this.database.entities.where('scope').equals(scope).delete();
167
+ await this.database.windows.where('scope').equals(scope).delete();
168
+ await this.database.meta.put({ scope, key: 'resetSequence', value: String(sequence) });
169
+ }
170
+ await this.database.meta.put({ scope, key: 'epoch', value: JSON.stringify({ current: cursor.epoch, retired: state ? [...state.retired, state.current] : [] }) });
171
+ }
172
+ async pruneRows(scope, window, ids, cursor, sequence) {
173
+ if (!ids.length)
174
+ return;
175
+ const retained = new Set();
176
+ await visitRecords(this.database.windows.where('scope').equals(scope), record => {
177
+ if (record.deleted || record.signature === window.signature)
178
+ return;
179
+ const other = JSON.parse(record.value);
180
+ if (other.entity === window.entity)
181
+ for (const id of other.ids)
182
+ retained.add(id);
183
+ });
184
+ for (const id of ids)
185
+ if (!retained.has(id))
186
+ await this.writeEntity(scope, window.entity, id, null, cursor, sequence);
187
+ }
188
+ async readChanges(scope, afterSequence, interest) {
189
+ await this.initialize();
190
+ return this.database.transaction('r', this.database.entities, this.database.windows, this.database.meta, async () => {
191
+ const sequence = Number((await this.database.meta.get([scope, 'sequence']))?.value ?? 0);
192
+ const reset = afterSequence < Number((await this.database.meta.get([scope, 'resetSequence']))?.value ?? 0);
193
+ const changes = { sequence, reset, entities: {}, windows: {} };
194
+ if (sequence === afterSequence)
195
+ return changes;
196
+ const wanted = interest ? new Map(Object.entries(interest.rows).map(([table, ids]) => [table, new Set(ids)])) : undefined;
197
+ let availableRows = interest ? reset ? interest.maxRows : interest.availableRows : 0;
198
+ let availableBytes = interest ? reset ? interest.maxBytes : interest.availableBytes : 0;
199
+ const windows = reset ? this.database.windows.where('scope').equals(scope) : this.database.windows.where('[scope+sequence]').between([scope, afterSequence], [scope, sequence], false, true);
200
+ await visitRecords(windows, record => {
201
+ const window = record.deleted ? null : JSON.parse(record.value);
202
+ changes.windows[record.signature] = window;
203
+ if (window && wanted && interest?.windows.includes(record.signature)) {
204
+ let ids = wanted.get(window.entity);
205
+ if (!ids) {
206
+ ids = new Set();
207
+ wanted.set(window.entity, ids);
208
+ }
209
+ for (const id of window.ids)
210
+ ids.add(id);
211
+ }
212
+ });
213
+ const rows = reset ? this.database.entities.where('scope').equals(scope) : this.database.entities.where('[scope+sequence]').between([scope, afterSequence], [scope, sequence], false, true);
214
+ await visitRecords(rows, record => {
215
+ if (wanted && !wanted.get(record.entity)?.has(record.id)) {
216
+ const size = record.value.length * 2 + 256;
217
+ if (record.deleted || availableRows <= 0 || availableBytes < size)
218
+ return;
219
+ availableRows--;
220
+ availableBytes -= size;
221
+ }
222
+ (changes.entities[record.entity] ??= {})[record.id] = record.deleted ? null : JSON.parse(record.value);
223
+ });
224
+ return changes;
225
+ });
50
226
  }
51
227
  initialize() {
52
228
  return this.initialized ??= this.database.open().then(() => undefined);
53
229
  }
230
+ async withReadView(scope, coverage, run) {
231
+ await this.initialize();
232
+ return this.database.transaction('r', this.database.entities, async () => {
233
+ // Database reads run normally in this transaction. Dexie.waitFor must
234
+ // never surround work that also uses this transaction; keep it limited
235
+ // to the deterministic crypto promise supplied by the ID allocator.
236
+ return run({ ...indexedDBReadView(this.database.entities, normalizeScope(scope), coverage),
237
+ keepAliveFor: promise => Dexie.waitFor(promise),
238
+ });
239
+ });
240
+ }
54
241
  async listScopes() {
55
242
  await this.initialize();
56
243
  const [entities, windows, meta] = await Promise.all([
@@ -75,64 +262,160 @@ export class IndexedDBLocalReplicaStorage {
75
262
  async load(scope = defaultReplicaScope) {
76
263
  await this.initialize();
77
264
  const normalizedScope = normalizeScope(scope);
78
- const [entityRecords, windowRecords, cursor] = await Promise.all([
79
- this.database.entities.where("scope").equals(normalizedScope).toArray(),
80
- this.database.windows.where("scope").equals(normalizedScope).toArray(),
81
- this.database.meta.get([normalizedScope, "cursor"]),
82
- ]);
83
- if (entityRecords.length === 0 && windowRecords.length === 0 && !cursor)
84
- return undefined;
85
- const entities = {};
86
- for (const record of entityRecords)
87
- (entities[record.entity] ??= {})[record.id] = JSON.parse(record.value);
88
- const liveQueries = {};
89
- for (const record of windowRecords)
90
- liveQueries[record.signature] = JSON.parse(record.value);
91
- return {
92
- cursor: cursor ? JSON.parse(cursor.value) : undefined,
93
- entities,
94
- liveQueries,
95
- };
265
+ return this.database.transaction('r', this.database.entities, this.database.windows, this.database.meta, async () => {
266
+ const [entityRecords, windowRecords, cursor] = await Promise.all([
267
+ this.database.entities.where("scope").equals(normalizedScope).toArray(),
268
+ this.database.windows.where("scope").equals(normalizedScope).toArray(),
269
+ this.database.meta.get([normalizedScope, "cursor"]),
270
+ ]);
271
+ if (entityRecords.length === 0 && windowRecords.length === 0 && !cursor)
272
+ return undefined;
273
+ const entities = {};
274
+ for (const record of entityRecords)
275
+ if (!record.deleted)
276
+ (entities[record.entity] ??= {})[record.id] = JSON.parse(record.value);
277
+ const liveQueries = {};
278
+ for (const record of windowRecords)
279
+ if (!record.deleted)
280
+ liveQueries[record.signature] = JSON.parse(record.value);
281
+ return {
282
+ storageSequence: Number((await this.database.meta.get([normalizedScope, 'sequence']))?.value ?? 0),
283
+ cursor: cursor ? JSON.parse(cursor.value) : undefined,
284
+ entities,
285
+ liveQueries,
286
+ };
287
+ });
288
+ }
289
+ async loadWorkingSet(scope, budget) {
290
+ await this.initialize();
291
+ return this.database.transaction('r', this.database.entities, this.database.windows, this.database.meta, async () => {
292
+ const entities = {}, liveQueries = {}, partial = new Set();
293
+ let bytes = 0, count = 0, hasRows = false, full = false;
294
+ // getAll-backed pages avoid one JS/Dexie cursor continuation per row.
295
+ // Keep both temporary decoding and retained rows bounded independently.
296
+ let after;
297
+ while (!full) {
298
+ const page = await this.database.entities.where('[scope+entity+id]')
299
+ .between(after ?? [scope], [scope, []], !after, false)
300
+ .limit(Math.min(256, Math.max(1, budget.maxRows - count + 1))).toArray();
301
+ if (!page.length)
302
+ break;
303
+ for (const record of page) {
304
+ if (record.deleted)
305
+ continue;
306
+ hasRows = true;
307
+ const size = record.value.length * 2 + 256;
308
+ if (count >= budget.maxRows || bytes + size > budget.maxBytes) {
309
+ partial.add(record.entity);
310
+ full = true;
311
+ break;
312
+ }
313
+ (entities[record.entity] ??= {})[record.id] = JSON.parse(record.value);
314
+ bytes += size;
315
+ count++;
316
+ }
317
+ const last = page[page.length - 1];
318
+ after = [last.scope, last.entity, last.id];
319
+ }
320
+ await visitRecords(this.database.windows.where('scope').equals(scope), record => {
321
+ if (record.deleted)
322
+ return;
323
+ const window = JSON.parse(record.value);
324
+ liveQueries[record.signature] = window;
325
+ if (window.ids.some(id => !entities[window.entity]?.[id]))
326
+ partial.add(window.entity);
327
+ });
328
+ const cursor = await this.database.meta.get([scope, 'cursor']);
329
+ if (!hasRows && !Object.keys(liveQueries).length && !cursor)
330
+ return undefined;
331
+ return { entities, liveQueries, residentPartialTables: [...partial], cursor: cursor ? JSON.parse(cursor.value) : undefined, storageSequence: Number((await this.database.meta.get([scope, 'sequence']))?.value ?? 0) };
332
+ });
333
+ }
334
+ async loadEntityRows(scope, entity, ids) {
335
+ await this.initialize();
336
+ return this.database.transaction('r', this.database.entities, async () => {
337
+ const rows = [];
338
+ for (let offset = 0; offset < ids.length; offset += 500) {
339
+ const records = await this.database.entities.bulkGet(ids.slice(offset, offset + 500).map(id => [scope, entity, id]));
340
+ for (const record of records)
341
+ if (record && !record.deleted)
342
+ rows.push({ id: record.id, row: JSON.parse(record.value) });
343
+ }
344
+ return rows;
345
+ });
346
+ }
347
+ async loadWindowRows(scope, signature) {
348
+ await this.initialize();
349
+ return this.database.transaction('r', this.database.entities, this.database.windows, async () => {
350
+ const record = await this.database.windows.get([scope, signature]);
351
+ if (!record || record.deleted)
352
+ return undefined;
353
+ const window = JSON.parse(record.value);
354
+ return { window, rows: (await this.loadEntityRows(scope, window.entity, window.ids)).map(entry => entry.row) };
355
+ });
96
356
  }
97
357
  async applyTransaction(transaction, _snapshot, scope = defaultReplicaScope) {
98
358
  await this.initialize();
99
359
  const normalizedScope = normalizeScope(scope);
100
360
  await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
101
- const previous = await this.database.meta.get([normalizedScope, "cursor"]);
102
- if (previous && JSON.parse(previous.value).epoch !== transaction.cursor.epoch) {
103
- await this.database.entities.where("scope").equals(normalizedScope).delete();
104
- await this.database.windows.where("scope").equals(normalizedScope).delete();
105
- }
361
+ const sequence = await this.nextSequence(normalizedScope);
362
+ await this.acceptEpoch(normalizedScope, transaction.cursor, sequence);
363
+ const grouped = new Map();
106
364
  for (const change of transaction.changes) {
107
- if (change.operation === "delete") {
108
- await this.database.entities.delete([normalizedScope, change.entity, change.id]);
109
- const windows = await this.database.windows.where("scope").equals(normalizedScope).toArray();
110
- for (const record of windows) {
111
- const window = JSON.parse(record.value);
112
- if (!window.ids.includes(change.id))
365
+ const key = JSON.stringify([change.entity, change.id]);
366
+ let entry = grouped.get(key);
367
+ if (!entry) {
368
+ entry = { entity: change.entity, id: change.id, changes: [] };
369
+ grouped.set(key, entry);
370
+ }
371
+ entry.changes.push(change);
372
+ }
373
+ const entries = [...grouped.values()];
374
+ const deleted = new Map();
375
+ for (let offset = 0; offset < entries.length; offset += writeBatchSize) {
376
+ const batch = entries.slice(offset, offset + writeBatchSize);
377
+ const previous = await this.database.entities.bulkGet(batch.map(entry => [normalizedScope, entry.entity, entry.id]));
378
+ const records = [];
379
+ for (const [index, entry] of batch.entries()) {
380
+ let record = previous[index];
381
+ for (const change of entry.changes) {
382
+ if (change.operation !== 'delete' && !change.newValue)
113
383
  continue;
114
- window.ids = window.ids.filter((id) => id !== change.id);
115
- await this.database.windows.put({ ...record, value: JSON.stringify(window) });
384
+ record = this.versionedRecord(normalizedScope, entry.entity, entry.id, change.operation === 'delete' ? null : change.newValue, transaction.cursor, sequence, record);
385
+ }
386
+ if (!record)
387
+ continue;
388
+ records.push(record);
389
+ if (record.deleted) {
390
+ let ids = deleted.get(entry.entity);
391
+ if (!ids) {
392
+ ids = new Set();
393
+ deleted.set(entry.entity, ids);
394
+ }
395
+ ids.add(entry.id);
116
396
  }
117
397
  }
118
- else if (change.newValue) {
119
- await this.database.entities.put({
120
- scope: normalizedScope,
121
- entity: change.entity,
122
- id: change.id,
123
- value: JSON.stringify(change.newValue),
124
- });
125
- }
398
+ await this.database.entities.bulkPut(records);
126
399
  }
127
- for (const membership of transaction.memberships ?? []) {
128
- await this.database.windows.put({
129
- scope: normalizedScope,
130
- signature: membership.signature,
131
- value: JSON.stringify(normalizeWindow(membership)),
132
- });
400
+ if (deleted.size) {
401
+ const windows = await this.database.windows.where('scope').equals(normalizedScope).toArray();
402
+ const changed = [];
403
+ for (const record of windows) {
404
+ if (record.deleted)
405
+ continue;
406
+ const window = JSON.parse(record.value);
407
+ const ids = deleted.get(window.entity);
408
+ if (!ids || !window.ids.some(id => ids.has(id)) || (window.cursor?.epoch === transaction.cursor.epoch && window.cursor.revision > transaction.cursor.revision))
409
+ continue;
410
+ window.ids = window.ids.filter(id => !ids.has(id));
411
+ changed.push({ ...record, sequence, value: JSON.stringify(window) });
412
+ }
413
+ await this.database.windows.bulkPut(changed);
133
414
  }
134
- await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(transaction.cursor) });
415
+ await this.writeWindows(normalizedScope, transaction.memberships ?? [], sequence);
416
+ await this.saveCursor(normalizedScope, transaction.cursor);
135
417
  });
418
+ this.channel?.postMessage({ scope: normalizedScope });
136
419
  }
137
420
  async advanceWatermark(windows, cursor, scope = defaultReplicaScope) {
138
421
  await this.initialize();
@@ -141,101 +424,98 @@ export class IndexedDBLocalReplicaStorage {
141
424
  // window metadata and the shared cursor so large normalized replicas are
142
425
  // never rewritten once per retained collection.
143
426
  await this.database.transaction("rw", this.database.windows, this.database.meta, async () => {
144
- for (const window of windows) {
145
- await this.database.windows.put({
146
- scope: normalizedScope,
147
- signature: window.signature,
148
- value: JSON.stringify(normalizeWindow(window)),
149
- });
150
- }
151
- if (cursor) {
152
- await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(cursor) });
153
- }
427
+ const sequence = await this.nextSequence(normalizedScope);
428
+ await this.writeWindows(normalizedScope, windows, sequence);
429
+ await this.saveCursor(normalizedScope, cursor);
154
430
  });
431
+ this.channel?.postMessage({ scope: normalizedScope });
155
432
  }
156
- async replaceWindow(window, snapshot, scope = defaultReplicaScope) {
433
+ async replaceWindow(window, snapshot, scope = defaultReplicaScope, projection) {
157
434
  await this.initialize();
158
435
  const normalizedScope = normalizeScope(scope);
159
436
  await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
437
+ const sequence = await this.nextSequence(normalizedScope);
438
+ await this.acceptEpoch(normalizedScope, window.cursor ?? snapshot.cursor, sequence);
160
439
  // A window replacement owns only its projected entity rows. Rewriting the
161
440
  // complete normalized replica for every snapshot made startup quadratic:
162
441
  // each newly opened collection persisted every entity loaded by all prior
163
442
  // collections, and `replica.ready` repeated the same work. Persist this
164
443
  // window atomically while leaving unrelated entity tables untouched.
165
- const rows = snapshot.entities[window.entity] ?? {};
444
+ const rows = () => snapshot.entities[window.entity] ?? {};
166
445
  const previousRecord = await this.database.windows.get([normalizedScope, window.signature]);
167
- if (previousRecord) {
446
+ if (previousRecord && !previousRecord.deleted) {
168
447
  const previous = JSON.parse(previousRecord.value);
169
448
  const nextIDs = new Set(window.ids);
170
- for (const id of previous.ids) {
171
- // The in-memory snapshot already accounts for every other window and
172
- // transaction owner. Absence here means the entity is no longer
173
- // authorized/referenced and must not survive restart hydration.
174
- if (!nextIDs.has(id) && rows[id] === undefined) {
175
- await this.database.entities.delete([normalizedScope, window.entity, id]);
176
- }
177
- }
449
+ await this.pruneRows(normalizedScope, window, previous.ids.filter(id => !nextIDs.has(id) && rows()[id] === undefined), window.cursor ?? snapshot.cursor, sequence);
178
450
  }
179
- for (const id of window.ids) {
180
- const value = rows[id];
181
- if (value !== undefined) {
182
- await this.database.entities.put({
183
- scope: normalizedScope,
184
- entity: window.entity,
185
- id,
186
- value: JSON.stringify(value),
187
- });
188
- }
189
- }
190
- await this.database.windows.put({ scope: normalizedScope, signature: window.signature, value: JSON.stringify(normalizeWindow(window)) });
191
- if (snapshot.cursor)
192
- await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(snapshot.cursor) });
451
+ await this.writeRows(normalizedScope, window.entity, window.key, projection ?? window.ids.flatMap(id => rows()[id] ? [rows()[id]] : []), window.cursor ?? snapshot.cursor, sequence);
452
+ await this.writeWindows(normalizedScope, [window], sequence);
453
+ await this.saveCursor(normalizedScope, snapshot.cursor);
193
454
  });
455
+ this.channel?.postMessage({ scope: normalizedScope });
194
456
  }
195
- async applyWindowDelta(window, _delta, snapshot, scope = defaultReplicaScope) {
196
- // The in-memory replica has already computed the normalized result. The
197
- // storage transaction receives that result and persists it atomically;
198
- // retaining unrelated entities is deliberate and conservative.
199
- await this.replaceWindow(window, snapshot, scope);
457
+ async applyWindowDelta(window, delta, snapshot, scope = defaultReplicaScope) {
458
+ await this.initialize();
459
+ const normalizedScope = normalizeScope(scope);
460
+ await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
461
+ const sequence = await this.nextSequence(normalizedScope);
462
+ await this.acceptEpoch(normalizedScope, window.cursor ?? snapshot.cursor, sequence);
463
+ const rows = delta.deleted.length ? snapshot.entities[window.entity] ?? {} : {};
464
+ await this.pruneRows(normalizedScope, window, delta.deleted.filter(id => rows[id] === undefined), window.cursor ?? snapshot.cursor, sequence);
465
+ await this.writeRows(normalizedScope, window.entity, window.key, delta.upserts, window.cursor ?? snapshot.cursor, sequence);
466
+ await this.writeWindows(normalizedScope, [window], sequence);
467
+ await this.saveCursor(normalizedScope, snapshot.cursor);
468
+ });
469
+ this.channel?.postMessage({ scope: normalizedScope });
200
470
  }
201
471
  async removeWindow(signature, snapshot, scope = defaultReplicaScope) {
202
472
  await this.initialize();
203
473
  const normalizedScope = normalizeScope(scope);
204
474
  await this.database.transaction("rw", this.database.windows, this.database.meta, async () => {
205
- await this.database.windows.delete([normalizedScope, signature]);
206
- if (snapshot.cursor)
207
- await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(snapshot.cursor) });
475
+ const sequence = await this.nextSequence(normalizedScope);
476
+ await this.database.windows.put({ scope: normalizedScope, signature, value: 'null', deleted: true, sequence });
477
+ await this.saveCursor(normalizedScope, snapshot.cursor);
208
478
  });
479
+ this.channel?.postMessage({ scope: normalizedScope });
209
480
  }
210
481
  async replaceSnapshot(snapshot, scope = defaultReplicaScope) {
211
482
  await this.initialize();
212
483
  const normalizedScope = normalizeScope(scope);
213
484
  await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
485
+ const sequence = await this.nextSequence(normalizedScope);
486
+ await this.acceptEpoch(normalizedScope, snapshot.cursor, sequence);
214
487
  await this.database.entities.where("scope").equals(normalizedScope).delete();
215
488
  await this.database.windows.where("scope").equals(normalizedScope).delete();
216
489
  await this.database.meta.delete([normalizedScope, "cursor"]);
490
+ await this.database.meta.put({ scope: normalizedScope, key: 'resetSequence', value: String(sequence) });
217
491
  for (const [entity, rows] of Object.entries(snapshot.entities)) {
218
- for (const [id, value] of Object.entries(rows)) {
219
- await this.database.entities.put({ scope: normalizedScope, entity, id, value: JSON.stringify(value) });
220
- }
221
- }
222
- for (const [signature, window] of Object.entries(snapshot.liveQueries)) {
223
- await this.database.windows.put({ scope: normalizedScope, signature, value: JSON.stringify(normalizeWindow(window)) });
492
+ const entries = Object.entries(rows);
493
+ // Empty tables have no IndexedDB work. Repeatedly awaiting already
494
+ // resolved native async helpers can let a browser commit this transaction
495
+ // before the next populated table is written (Dexie.PrematureCommitError).
496
+ if (entries.length)
497
+ await this.writeRowEntries(normalizedScope, entity, entries, snapshot.cursor, sequence);
224
498
  }
225
- if (snapshot.cursor)
226
- await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(snapshot.cursor) });
499
+ await this.writeWindows(normalizedScope, Object.values(snapshot.liveQueries), sequence);
500
+ await this.saveCursor(normalizedScope, snapshot.cursor);
227
501
  });
502
+ this.channel?.postMessage({ scope: normalizedScope });
228
503
  }
229
504
  async clear(scope = defaultReplicaScope) {
230
505
  await this.initialize();
231
506
  const normalizedScope = normalizeScope(scope);
232
507
  await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
508
+ const sequence = await this.nextSequence(normalizedScope);
233
509
  await this.database.entities.where("scope").equals(normalizedScope).delete();
234
510
  await this.database.windows.where("scope").equals(normalizedScope).delete();
235
- await this.database.meta.where("scope").equals(normalizedScope).delete();
511
+ await this.database.meta.where("scope").equals(normalizedScope).and(record => record.key !== 'sequence' && record.key !== 'epoch').delete();
512
+ await this.database.meta.put({ scope: normalizedScope, key: 'resetSequence', value: String(sequence) });
236
513
  });
514
+ this.channel?.postMessage({ scope: normalizedScope });
237
515
  }
238
516
  close() {
517
+ this.channel?.close();
518
+ this.peers.clear();
239
519
  this.database.close();
240
520
  }
241
521
  }