@gonvex/client 0.5.2-staging.2 → 0.5.2-staging.21
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/README.md +68 -2
- package/dist/browser-upgrades.d.ts +19 -0
- package/dist/browser-upgrades.js +157 -0
- package/dist/browser-upgrades.js.map +1 -0
- package/dist/client-upgrades.d.ts +26 -0
- package/dist/client-upgrades.js +41 -0
- package/dist/client-upgrades.js.map +1 -0
- package/dist/control.d.ts +2 -0
- package/dist/control.js +1 -0
- package/dist/control.js.map +1 -1
- package/dist/index.d.ts +348 -9
- package/dist/index.js +1576 -192
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-read-view.d.ts +24 -0
- package/dist/indexeddb-read-view.js +164 -0
- package/dist/indexeddb-read-view.js.map +1 -0
- package/dist/indexeddb-replica.d.ts +41 -3
- package/dist/indexeddb-replica.js +405 -111
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +127 -4
- package/dist/local-replica.js +836 -85
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +118 -10
- package/dist/outbox.js +391 -89
- package/dist/outbox.js.map +1 -1
- package/dist/paged-read-view.d.ts +13 -0
- package/dist/paged-read-view.js +60 -0
- package/dist/paged-read-view.js.map +1 -0
- package/dist/query-expression.d.ts +39 -2
- package/dist/query-expression.js +106 -5
- package/dist/query-expression.js.map +1 -1
- package/dist/replica-integrity.d.ts +2 -0
- package/dist/replica-integrity.js +10 -0
- package/dist/replica-integrity.js.map +1 -1
- package/dist/replica-record.d.ts +14 -0
- package/dist/replica-record.js +46 -0
- package/dist/replica-record.js.map +1 -0
- package/package.json +4 -2
|
@@ -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 =
|
|
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(
|
|
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,71 +74,351 @@ 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
|
+
// Serialization already isolates persisted values. Copying ordered IDs
|
|
138
|
+
// and large integrity maps before stringifying doubled temporary memory
|
|
139
|
+
// for every cursor-only checkpoint.
|
|
140
|
+
return [{ scope, sequence, signature: window.signature, value: JSON.stringify({ ...window, kind: window.kind ?? 'live', key: window.key ?? 'id' }) }];
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
async saveCursor(scope, cursor) {
|
|
144
|
+
if (!cursor)
|
|
145
|
+
return;
|
|
146
|
+
const epoch = await this.database.meta.get([scope, 'epoch']);
|
|
147
|
+
if (epoch && JSON.parse(epoch.value).current !== cursor.epoch)
|
|
148
|
+
throw new Error('This tab has an obsolete replica epoch. Reconnect before continuing.');
|
|
149
|
+
const prior = await this.database.meta.get([scope, 'cursor']);
|
|
150
|
+
const before = prior ? JSON.parse(prior.value) : undefined;
|
|
151
|
+
if (before?.epoch === cursor.epoch && before.revision > cursor.revision)
|
|
152
|
+
return;
|
|
153
|
+
await this.database.meta.put({ scope, key: 'cursor', value: JSON.stringify(cursor) });
|
|
154
|
+
}
|
|
155
|
+
async acceptEpoch(scope, cursor, sequence) {
|
|
156
|
+
if (!cursor)
|
|
157
|
+
return;
|
|
158
|
+
const prior = await this.database.meta.get([scope, 'epoch']);
|
|
159
|
+
const legacyCursor = prior ? undefined : await this.database.meta.get([scope, 'cursor']);
|
|
160
|
+
const state = prior ? JSON.parse(prior.value) : legacyCursor ? { current: JSON.parse(legacyCursor.value).epoch, retired: [] } : undefined;
|
|
161
|
+
if (state?.current === cursor.epoch) {
|
|
162
|
+
if (!prior)
|
|
163
|
+
await this.database.meta.put({ scope, key: 'epoch', value: JSON.stringify(state) });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (state?.retired.includes(cursor.epoch))
|
|
167
|
+
throw new Error('This tab has an obsolete replica epoch. Reconnect before continuing.');
|
|
168
|
+
if (state) {
|
|
169
|
+
await this.database.entities.where('scope').equals(scope).delete();
|
|
170
|
+
await this.database.windows.where('scope').equals(scope).delete();
|
|
171
|
+
await this.database.meta.put({ scope, key: 'resetSequence', value: String(sequence) });
|
|
172
|
+
}
|
|
173
|
+
await this.database.meta.put({ scope, key: 'epoch', value: JSON.stringify({ current: cursor.epoch, retired: state ? [...state.retired, state.current] : [] }) });
|
|
174
|
+
}
|
|
175
|
+
async pruneRows(scope, window, ids, cursor, sequence) {
|
|
176
|
+
if (!ids.length)
|
|
177
|
+
return;
|
|
178
|
+
const retained = new Set();
|
|
179
|
+
await visitRecords(this.database.windows.where('scope').equals(scope), record => {
|
|
180
|
+
if (record.deleted || record.signature === window.signature)
|
|
181
|
+
return;
|
|
182
|
+
const other = JSON.parse(record.value);
|
|
183
|
+
if (other.entity === window.entity)
|
|
184
|
+
for (const id of other.ids)
|
|
185
|
+
retained.add(id);
|
|
186
|
+
});
|
|
187
|
+
for (const id of ids)
|
|
188
|
+
if (!retained.has(id))
|
|
189
|
+
await this.writeEntity(scope, window.entity, id, null, cursor, sequence);
|
|
190
|
+
}
|
|
191
|
+
async readChanges(scope, afterSequence, interest) {
|
|
192
|
+
await this.initialize();
|
|
193
|
+
return this.database.transaction('r', this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
194
|
+
const sequence = Number((await this.database.meta.get([scope, 'sequence']))?.value ?? 0);
|
|
195
|
+
const reset = afterSequence < Number((await this.database.meta.get([scope, 'resetSequence']))?.value ?? 0);
|
|
196
|
+
const changes = { sequence, reset, entities: {}, windows: {} };
|
|
197
|
+
if (sequence === afterSequence)
|
|
198
|
+
return changes;
|
|
199
|
+
const wanted = interest ? new Map(Object.entries(interest.rows).map(([table, ids]) => [table, new Set(ids)])) : undefined;
|
|
200
|
+
let availableRows = interest ? reset ? interest.maxRows : interest.availableRows : 0;
|
|
201
|
+
let availableBytes = interest ? reset ? interest.maxBytes : interest.availableBytes : 0;
|
|
202
|
+
const windows = reset ? this.database.windows.where('scope').equals(scope) : this.database.windows.where('[scope+sequence]').between([scope, afterSequence], [scope, sequence], false, true);
|
|
203
|
+
await visitRecords(windows, record => {
|
|
204
|
+
const window = record.deleted ? null : JSON.parse(record.value);
|
|
205
|
+
changes.windows[record.signature] = window;
|
|
206
|
+
if (window && wanted && interest?.windows.includes(record.signature)) {
|
|
207
|
+
let ids = wanted.get(window.entity);
|
|
208
|
+
if (!ids) {
|
|
209
|
+
ids = new Set();
|
|
210
|
+
wanted.set(window.entity, ids);
|
|
211
|
+
}
|
|
212
|
+
for (const id of window.ids)
|
|
213
|
+
ids.add(id);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
const rows = reset ? this.database.entities.where('scope').equals(scope) : this.database.entities.where('[scope+sequence]').between([scope, afterSequence], [scope, sequence], false, true);
|
|
217
|
+
await visitRecords(rows, record => {
|
|
218
|
+
if (wanted && !wanted.get(record.entity)?.has(record.id)) {
|
|
219
|
+
const size = record.value.length * 2 + 256;
|
|
220
|
+
if (record.deleted || availableRows <= 0 || availableBytes < size)
|
|
221
|
+
return;
|
|
222
|
+
availableRows--;
|
|
223
|
+
availableBytes -= size;
|
|
224
|
+
}
|
|
225
|
+
(changes.entities[record.entity] ??= {})[record.id] = record.deleted ? null : JSON.parse(record.value);
|
|
226
|
+
});
|
|
227
|
+
return changes;
|
|
228
|
+
});
|
|
50
229
|
}
|
|
51
230
|
initialize() {
|
|
52
231
|
return this.initialized ??= this.database.open().then(() => undefined);
|
|
53
232
|
}
|
|
233
|
+
async withReadView(scope, coverage, run) {
|
|
234
|
+
await this.initialize();
|
|
235
|
+
return this.database.transaction('r', this.database.entities, async () => {
|
|
236
|
+
// Database reads run normally in this transaction. Dexie.waitFor must
|
|
237
|
+
// never surround work that also uses this transaction; keep it limited
|
|
238
|
+
// to the deterministic crypto promise supplied by the ID allocator.
|
|
239
|
+
return run({ ...indexedDBReadView(this.database.entities, normalizeScope(scope), coverage),
|
|
240
|
+
keepAliveFor: promise => Dexie.waitFor(promise),
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async listScopes() {
|
|
245
|
+
await this.initialize();
|
|
246
|
+
const [entities, windows, meta] = await Promise.all([
|
|
247
|
+
this.database.entities.orderBy("scope").uniqueKeys(),
|
|
248
|
+
this.database.windows.orderBy("scope").uniqueKeys(),
|
|
249
|
+
this.database.meta.orderBy("scope").uniqueKeys(),
|
|
250
|
+
]);
|
|
251
|
+
return [...new Set([...entities, ...windows, ...meta].map(String))];
|
|
252
|
+
}
|
|
253
|
+
async loadSession(scope) {
|
|
254
|
+
await this.initialize();
|
|
255
|
+
const record = await this.database.meta.get([scope, "session"]);
|
|
256
|
+
return record ? JSON.parse(record.value) : undefined;
|
|
257
|
+
}
|
|
258
|
+
async saveSession(scope, session) {
|
|
259
|
+
await this.initialize();
|
|
260
|
+
if (session)
|
|
261
|
+
await this.database.meta.put({ scope, key: "session", value: JSON.stringify(session) });
|
|
262
|
+
else
|
|
263
|
+
await this.database.meta.delete([scope, "session"]);
|
|
264
|
+
}
|
|
54
265
|
async load(scope = defaultReplicaScope) {
|
|
55
266
|
await this.initialize();
|
|
56
267
|
const normalizedScope = normalizeScope(scope);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
liveQueries
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
268
|
+
return this.database.transaction('r', this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
269
|
+
const [entityRecords, windowRecords, cursor] = await Promise.all([
|
|
270
|
+
this.database.entities.where("scope").equals(normalizedScope).toArray(),
|
|
271
|
+
this.database.windows.where("scope").equals(normalizedScope).toArray(),
|
|
272
|
+
this.database.meta.get([normalizedScope, "cursor"]),
|
|
273
|
+
]);
|
|
274
|
+
if (entityRecords.length === 0 && windowRecords.length === 0 && !cursor)
|
|
275
|
+
return undefined;
|
|
276
|
+
const entities = {};
|
|
277
|
+
for (const record of entityRecords)
|
|
278
|
+
if (!record.deleted)
|
|
279
|
+
(entities[record.entity] ??= {})[record.id] = JSON.parse(record.value);
|
|
280
|
+
const liveQueries = {};
|
|
281
|
+
for (const record of windowRecords)
|
|
282
|
+
if (!record.deleted)
|
|
283
|
+
liveQueries[record.signature] = JSON.parse(record.value);
|
|
284
|
+
return {
|
|
285
|
+
storageSequence: Number((await this.database.meta.get([normalizedScope, 'sequence']))?.value ?? 0),
|
|
286
|
+
cursor: cursor ? JSON.parse(cursor.value) : undefined,
|
|
287
|
+
entities,
|
|
288
|
+
liveQueries,
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async loadWorkingSet(scope, budget) {
|
|
293
|
+
await this.initialize();
|
|
294
|
+
return this.database.transaction('r', this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
295
|
+
const entities = {}, liveQueries = {}, partial = new Set();
|
|
296
|
+
let bytes = 0, count = 0, hasRows = false, full = false;
|
|
297
|
+
// getAll-backed pages avoid one JS/Dexie cursor continuation per row.
|
|
298
|
+
// Keep both temporary decoding and retained rows bounded independently.
|
|
299
|
+
let after;
|
|
300
|
+
while (!full) {
|
|
301
|
+
const page = await this.database.entities.where('[scope+entity+id]')
|
|
302
|
+
.between(after ?? [scope], [scope, []], !after, false)
|
|
303
|
+
.limit(Math.min(256, Math.max(1, budget.maxRows - count + 1))).toArray();
|
|
304
|
+
if (!page.length)
|
|
305
|
+
break;
|
|
306
|
+
for (const record of page) {
|
|
307
|
+
if (record.deleted)
|
|
308
|
+
continue;
|
|
309
|
+
hasRows = true;
|
|
310
|
+
const size = record.value.length * 2 + 256;
|
|
311
|
+
if (count >= budget.maxRows || bytes + size > budget.maxBytes) {
|
|
312
|
+
partial.add(record.entity);
|
|
313
|
+
full = true;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
(entities[record.entity] ??= {})[record.id] = JSON.parse(record.value);
|
|
317
|
+
bytes += size;
|
|
318
|
+
count++;
|
|
319
|
+
}
|
|
320
|
+
const last = page[page.length - 1];
|
|
321
|
+
after = [last.scope, last.entity, last.id];
|
|
322
|
+
}
|
|
323
|
+
await visitRecords(this.database.windows.where('scope').equals(scope), record => {
|
|
324
|
+
if (record.deleted)
|
|
325
|
+
return;
|
|
326
|
+
const window = JSON.parse(record.value);
|
|
327
|
+
liveQueries[record.signature] = window;
|
|
328
|
+
if (window.ids.some(id => !entities[window.entity]?.[id]))
|
|
329
|
+
partial.add(window.entity);
|
|
330
|
+
});
|
|
331
|
+
const cursor = await this.database.meta.get([scope, 'cursor']);
|
|
332
|
+
if (!hasRows && !Object.keys(liveQueries).length && !cursor)
|
|
333
|
+
return undefined;
|
|
334
|
+
return { entities, liveQueries, residentPartialTables: [...partial], cursor: cursor ? JSON.parse(cursor.value) : undefined, storageSequence: Number((await this.database.meta.get([scope, 'sequence']))?.value ?? 0) };
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
async loadEntityRows(scope, entity, ids) {
|
|
338
|
+
await this.initialize();
|
|
339
|
+
return this.database.transaction('r', this.database.entities, async () => {
|
|
340
|
+
const rows = [];
|
|
341
|
+
for (let offset = 0; offset < ids.length; offset += 500) {
|
|
342
|
+
const records = await this.database.entities.bulkGet(ids.slice(offset, offset + 500).map(id => [scope, entity, id]));
|
|
343
|
+
for (const record of records)
|
|
344
|
+
if (record && !record.deleted)
|
|
345
|
+
rows.push({ id: record.id, row: JSON.parse(record.value) });
|
|
346
|
+
}
|
|
347
|
+
return rows;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async loadWindowRows(scope, signature) {
|
|
351
|
+
await this.initialize();
|
|
352
|
+
return this.database.transaction('r', this.database.entities, this.database.windows, async () => {
|
|
353
|
+
const record = await this.database.windows.get([scope, signature]);
|
|
354
|
+
if (!record || record.deleted)
|
|
355
|
+
return undefined;
|
|
356
|
+
const window = JSON.parse(record.value);
|
|
357
|
+
return { window, rows: (await this.loadEntityRows(scope, window.entity, window.ids)).map(entry => entry.row) };
|
|
358
|
+
});
|
|
75
359
|
}
|
|
76
360
|
async applyTransaction(transaction, _snapshot, scope = defaultReplicaScope) {
|
|
77
361
|
await this.initialize();
|
|
78
362
|
const normalizedScope = normalizeScope(scope);
|
|
79
363
|
await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await this.database.windows.where("scope").equals(normalizedScope).delete();
|
|
84
|
-
}
|
|
364
|
+
const sequence = await this.nextSequence(normalizedScope);
|
|
365
|
+
await this.acceptEpoch(normalizedScope, transaction.cursor, sequence);
|
|
366
|
+
const grouped = new Map();
|
|
85
367
|
for (const change of transaction.changes) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
368
|
+
const key = JSON.stringify([change.entity, change.id]);
|
|
369
|
+
let entry = grouped.get(key);
|
|
370
|
+
if (!entry) {
|
|
371
|
+
entry = { entity: change.entity, id: change.id, changes: [] };
|
|
372
|
+
grouped.set(key, entry);
|
|
373
|
+
}
|
|
374
|
+
entry.changes.push(change);
|
|
375
|
+
}
|
|
376
|
+
const entries = [...grouped.values()];
|
|
377
|
+
const deleted = new Map();
|
|
378
|
+
for (let offset = 0; offset < entries.length; offset += writeBatchSize) {
|
|
379
|
+
const batch = entries.slice(offset, offset + writeBatchSize);
|
|
380
|
+
const previous = await this.database.entities.bulkGet(batch.map(entry => [normalizedScope, entry.entity, entry.id]));
|
|
381
|
+
const records = [];
|
|
382
|
+
for (const [index, entry] of batch.entries()) {
|
|
383
|
+
let record = previous[index];
|
|
384
|
+
for (const change of entry.changes) {
|
|
385
|
+
if (change.operation !== 'delete' && !change.newValue)
|
|
92
386
|
continue;
|
|
93
|
-
|
|
94
|
-
|
|
387
|
+
record = this.versionedRecord(normalizedScope, entry.entity, entry.id, change.operation === 'delete' ? null : change.newValue, transaction.cursor, sequence, record);
|
|
388
|
+
}
|
|
389
|
+
if (!record)
|
|
390
|
+
continue;
|
|
391
|
+
records.push(record);
|
|
392
|
+
if (record.deleted) {
|
|
393
|
+
let ids = deleted.get(entry.entity);
|
|
394
|
+
if (!ids) {
|
|
395
|
+
ids = new Set();
|
|
396
|
+
deleted.set(entry.entity, ids);
|
|
397
|
+
}
|
|
398
|
+
ids.add(entry.id);
|
|
95
399
|
}
|
|
96
400
|
}
|
|
97
|
-
|
|
98
|
-
await this.database.entities.put({
|
|
99
|
-
scope: normalizedScope,
|
|
100
|
-
entity: change.entity,
|
|
101
|
-
id: change.id,
|
|
102
|
-
value: JSON.stringify(change.newValue),
|
|
103
|
-
});
|
|
104
|
-
}
|
|
401
|
+
await this.database.entities.bulkPut(records);
|
|
105
402
|
}
|
|
106
|
-
|
|
107
|
-
await this.database.windows.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
403
|
+
if (deleted.size) {
|
|
404
|
+
const windows = await this.database.windows.where('scope').equals(normalizedScope).toArray();
|
|
405
|
+
const changed = [];
|
|
406
|
+
for (const record of windows) {
|
|
407
|
+
if (record.deleted)
|
|
408
|
+
continue;
|
|
409
|
+
const window = JSON.parse(record.value);
|
|
410
|
+
const ids = deleted.get(window.entity);
|
|
411
|
+
if (!ids || !window.ids.some(id => ids.has(id)) || (window.cursor?.epoch === transaction.cursor.epoch && window.cursor.revision > transaction.cursor.revision))
|
|
412
|
+
continue;
|
|
413
|
+
window.ids = window.ids.filter(id => !ids.has(id));
|
|
414
|
+
changed.push({ ...record, sequence, value: JSON.stringify(window) });
|
|
415
|
+
}
|
|
416
|
+
await this.database.windows.bulkPut(changed);
|
|
112
417
|
}
|
|
113
|
-
await this.
|
|
418
|
+
await this.writeWindows(normalizedScope, transaction.memberships ?? [], sequence);
|
|
419
|
+
await this.saveCursor(normalizedScope, transaction.cursor);
|
|
114
420
|
});
|
|
421
|
+
this.channel?.postMessage({ scope: normalizedScope });
|
|
115
422
|
}
|
|
116
423
|
async advanceWatermark(windows, cursor, scope = defaultReplicaScope) {
|
|
117
424
|
await this.initialize();
|
|
@@ -120,101 +427,98 @@ export class IndexedDBLocalReplicaStorage {
|
|
|
120
427
|
// window metadata and the shared cursor so large normalized replicas are
|
|
121
428
|
// never rewritten once per retained collection.
|
|
122
429
|
await this.database.transaction("rw", this.database.windows, this.database.meta, async () => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
signature: window.signature,
|
|
127
|
-
value: JSON.stringify(normalizeWindow(window)),
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
if (cursor) {
|
|
131
|
-
await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(cursor) });
|
|
132
|
-
}
|
|
430
|
+
const sequence = await this.nextSequence(normalizedScope);
|
|
431
|
+
await this.writeWindows(normalizedScope, windows, sequence);
|
|
432
|
+
await this.saveCursor(normalizedScope, cursor);
|
|
133
433
|
});
|
|
434
|
+
this.channel?.postMessage({ scope: normalizedScope });
|
|
134
435
|
}
|
|
135
|
-
async replaceWindow(window, snapshot, scope = defaultReplicaScope) {
|
|
436
|
+
async replaceWindow(window, snapshot, scope = defaultReplicaScope, projection) {
|
|
136
437
|
await this.initialize();
|
|
137
438
|
const normalizedScope = normalizeScope(scope);
|
|
138
439
|
await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
440
|
+
const sequence = await this.nextSequence(normalizedScope);
|
|
441
|
+
await this.acceptEpoch(normalizedScope, window.cursor ?? snapshot.cursor, sequence);
|
|
139
442
|
// A window replacement owns only its projected entity rows. Rewriting the
|
|
140
443
|
// complete normalized replica for every snapshot made startup quadratic:
|
|
141
444
|
// each newly opened collection persisted every entity loaded by all prior
|
|
142
445
|
// collections, and `replica.ready` repeated the same work. Persist this
|
|
143
446
|
// window atomically while leaving unrelated entity tables untouched.
|
|
144
|
-
const rows = snapshot.entities[window.entity] ?? {};
|
|
447
|
+
const rows = () => snapshot.entities[window.entity] ?? {};
|
|
145
448
|
const previousRecord = await this.database.windows.get([normalizedScope, window.signature]);
|
|
146
|
-
if (previousRecord) {
|
|
449
|
+
if (previousRecord && !previousRecord.deleted) {
|
|
147
450
|
const previous = JSON.parse(previousRecord.value);
|
|
148
451
|
const nextIDs = new Set(window.ids);
|
|
149
|
-
|
|
150
|
-
// The in-memory snapshot already accounts for every other window and
|
|
151
|
-
// transaction owner. Absence here means the entity is no longer
|
|
152
|
-
// authorized/referenced and must not survive restart hydration.
|
|
153
|
-
if (!nextIDs.has(id) && rows[id] === undefined) {
|
|
154
|
-
await this.database.entities.delete([normalizedScope, window.entity, id]);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
452
|
+
await this.pruneRows(normalizedScope, window, previous.ids.filter(id => !nextIDs.has(id) && rows()[id] === undefined), window.cursor ?? snapshot.cursor, sequence);
|
|
157
453
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await this.database.entities.put({
|
|
162
|
-
scope: normalizedScope,
|
|
163
|
-
entity: window.entity,
|
|
164
|
-
id,
|
|
165
|
-
value: JSON.stringify(value),
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
await this.database.windows.put({ scope: normalizedScope, signature: window.signature, value: JSON.stringify(normalizeWindow(window)) });
|
|
170
|
-
if (snapshot.cursor)
|
|
171
|
-
await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(snapshot.cursor) });
|
|
454
|
+
await this.writeRows(normalizedScope, window.entity, window.key, projection ?? window.ids.flatMap(id => rows()[id] ? [rows()[id]] : []), window.cursor ?? snapshot.cursor, sequence);
|
|
455
|
+
await this.writeWindows(normalizedScope, [window], sequence);
|
|
456
|
+
await this.saveCursor(normalizedScope, snapshot.cursor);
|
|
172
457
|
});
|
|
458
|
+
this.channel?.postMessage({ scope: normalizedScope });
|
|
173
459
|
}
|
|
174
|
-
async applyWindowDelta(window,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
460
|
+
async applyWindowDelta(window, delta, snapshot, scope = defaultReplicaScope) {
|
|
461
|
+
await this.initialize();
|
|
462
|
+
const normalizedScope = normalizeScope(scope);
|
|
463
|
+
await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
464
|
+
const sequence = await this.nextSequence(normalizedScope);
|
|
465
|
+
await this.acceptEpoch(normalizedScope, window.cursor ?? snapshot.cursor, sequence);
|
|
466
|
+
const rows = delta.deleted.length ? snapshot.entities[window.entity] ?? {} : {};
|
|
467
|
+
await this.pruneRows(normalizedScope, window, delta.deleted.filter(id => rows[id] === undefined), window.cursor ?? snapshot.cursor, sequence);
|
|
468
|
+
await this.writeRows(normalizedScope, window.entity, window.key, delta.upserts, window.cursor ?? snapshot.cursor, sequence);
|
|
469
|
+
await this.writeWindows(normalizedScope, [window], sequence);
|
|
470
|
+
await this.saveCursor(normalizedScope, snapshot.cursor);
|
|
471
|
+
});
|
|
472
|
+
this.channel?.postMessage({ scope: normalizedScope });
|
|
179
473
|
}
|
|
180
474
|
async removeWindow(signature, snapshot, scope = defaultReplicaScope) {
|
|
181
475
|
await this.initialize();
|
|
182
476
|
const normalizedScope = normalizeScope(scope);
|
|
183
477
|
await this.database.transaction("rw", this.database.windows, this.database.meta, async () => {
|
|
184
|
-
await this.
|
|
185
|
-
|
|
186
|
-
|
|
478
|
+
const sequence = await this.nextSequence(normalizedScope);
|
|
479
|
+
await this.database.windows.put({ scope: normalizedScope, signature, value: 'null', deleted: true, sequence });
|
|
480
|
+
await this.saveCursor(normalizedScope, snapshot.cursor);
|
|
187
481
|
});
|
|
482
|
+
this.channel?.postMessage({ scope: normalizedScope });
|
|
188
483
|
}
|
|
189
484
|
async replaceSnapshot(snapshot, scope = defaultReplicaScope) {
|
|
190
485
|
await this.initialize();
|
|
191
486
|
const normalizedScope = normalizeScope(scope);
|
|
192
487
|
await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
488
|
+
const sequence = await this.nextSequence(normalizedScope);
|
|
489
|
+
await this.acceptEpoch(normalizedScope, snapshot.cursor, sequence);
|
|
193
490
|
await this.database.entities.where("scope").equals(normalizedScope).delete();
|
|
194
491
|
await this.database.windows.where("scope").equals(normalizedScope).delete();
|
|
195
492
|
await this.database.meta.delete([normalizedScope, "cursor"]);
|
|
493
|
+
await this.database.meta.put({ scope: normalizedScope, key: 'resetSequence', value: String(sequence) });
|
|
196
494
|
for (const [entity, rows] of Object.entries(snapshot.entities)) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
495
|
+
const entries = Object.entries(rows);
|
|
496
|
+
// Empty tables have no IndexedDB work. Repeatedly awaiting already
|
|
497
|
+
// resolved native async helpers can let a browser commit this transaction
|
|
498
|
+
// before the next populated table is written (Dexie.PrematureCommitError).
|
|
499
|
+
if (entries.length)
|
|
500
|
+
await this.writeRowEntries(normalizedScope, entity, entries, snapshot.cursor, sequence);
|
|
200
501
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
if (snapshot.cursor)
|
|
205
|
-
await this.database.meta.put({ scope: normalizedScope, key: "cursor", value: JSON.stringify(snapshot.cursor) });
|
|
502
|
+
await this.writeWindows(normalizedScope, Object.values(snapshot.liveQueries), sequence);
|
|
503
|
+
await this.saveCursor(normalizedScope, snapshot.cursor);
|
|
206
504
|
});
|
|
505
|
+
this.channel?.postMessage({ scope: normalizedScope });
|
|
207
506
|
}
|
|
208
507
|
async clear(scope = defaultReplicaScope) {
|
|
209
508
|
await this.initialize();
|
|
210
509
|
const normalizedScope = normalizeScope(scope);
|
|
211
510
|
await this.database.transaction("rw", this.database.entities, this.database.windows, this.database.meta, async () => {
|
|
511
|
+
const sequence = await this.nextSequence(normalizedScope);
|
|
212
512
|
await this.database.entities.where("scope").equals(normalizedScope).delete();
|
|
213
513
|
await this.database.windows.where("scope").equals(normalizedScope).delete();
|
|
214
|
-
await this.database.meta.where("scope").equals(normalizedScope).delete();
|
|
514
|
+
await this.database.meta.where("scope").equals(normalizedScope).and(record => record.key !== 'sequence' && record.key !== 'epoch').delete();
|
|
515
|
+
await this.database.meta.put({ scope: normalizedScope, key: 'resetSequence', value: String(sequence) });
|
|
215
516
|
});
|
|
517
|
+
this.channel?.postMessage({ scope: normalizedScope });
|
|
216
518
|
}
|
|
217
519
|
close() {
|
|
520
|
+
this.channel?.close();
|
|
521
|
+
this.peers.clear();
|
|
218
522
|
this.database.close();
|
|
219
523
|
}
|
|
220
524
|
}
|
|
@@ -224,14 +528,4 @@ export function indexedDBLocalReplica(name) {
|
|
|
224
528
|
function normalizeScope(scope) {
|
|
225
529
|
return typeof scope === "string" && scope.trim() ? scope : defaultReplicaScope;
|
|
226
530
|
}
|
|
227
|
-
function normalizeWindow(value) {
|
|
228
|
-
return {
|
|
229
|
-
...value,
|
|
230
|
-
kind: value.kind ?? "live",
|
|
231
|
-
key: value.key ?? "id",
|
|
232
|
-
ids: [...value.ids],
|
|
233
|
-
resultPath: value.resultPath ? [...value.resultPath] : undefined,
|
|
234
|
-
hashes: value.hashes ? { ...value.hashes } : undefined,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
531
|
//# sourceMappingURL=indexeddb-replica.js.map
|