@gonvex/client 0.5.2-staging.12 → 0.5.2-staging.14
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/dist/browser-upgrades.js +58 -12
- package/dist/browser-upgrades.js.map +1 -1
- package/dist/client-upgrades.d.ts +2 -2
- 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 +37 -3
- package/dist/index.js +625 -193
- 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 +38 -3
- package/dist/indexeddb-replica.js +381 -101
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +102 -4
- package/dist/local-replica.js +812 -86
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +11 -1
- package/dist/outbox.js +69 -10
- 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 +5 -5
package/dist/local-replica.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { memoryReadView, overlayReadView } from '@gonvex/local-runtime/read-view';
|
|
1
2
|
const defaultReplicaScope = "default";
|
|
2
3
|
export class LocalReplica {
|
|
3
4
|
storage;
|
|
5
|
+
retainedWindows = new Map();
|
|
6
|
+
retainedRows = new Map();
|
|
7
|
+
residentPartialTables = new Set();
|
|
8
|
+
rowSizes = new WeakMap();
|
|
9
|
+
visibleRowCopies = new WeakMap();
|
|
10
|
+
residencyBudget = { maxRows: 20_000, maxBytes: 16 * 1024 * 1024 };
|
|
4
11
|
cursorValue;
|
|
5
12
|
entities = new Map();
|
|
6
13
|
liveQueries = new Map();
|
|
@@ -10,12 +17,55 @@ export class LocalReplica {
|
|
|
10
17
|
pendingCommands = new Map();
|
|
11
18
|
listeners = new Set();
|
|
12
19
|
persistence = Promise.resolve();
|
|
20
|
+
storageSequence = 0;
|
|
13
21
|
application = Promise.resolve();
|
|
22
|
+
applicationRunning = false;
|
|
23
|
+
disposed = false;
|
|
24
|
+
applicationJobs = [];
|
|
25
|
+
enqueueApplication(run, urgent = false) {
|
|
26
|
+
if (this.disposed)
|
|
27
|
+
return Promise.reject(new Error("Local replica is closed"));
|
|
28
|
+
let resolve;
|
|
29
|
+
let reject;
|
|
30
|
+
const result = new Promise((done, failed) => { resolve = done; reject = failed; });
|
|
31
|
+
this.applicationJobs.push({ urgent, cancel: () => reject(new Error("Local replica is closed")), run: async () => { try {
|
|
32
|
+
resolve(await run());
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
reject(error);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
if (this.disposed)
|
|
39
|
+
this.releaseMemory();
|
|
40
|
+
} } });
|
|
41
|
+
this.application = Promise.all([this.application, result]).then(() => undefined, () => undefined);
|
|
42
|
+
if (!this.applicationRunning) {
|
|
43
|
+
this.applicationRunning = true;
|
|
44
|
+
queueMicrotask(() => { void this.drainApplicationJobs(); });
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
async drainApplicationJobs() {
|
|
49
|
+
while (this.applicationJobs.length) {
|
|
50
|
+
// Finish the active transaction atomically. Between transactions, a user
|
|
51
|
+
// read can precede queued hydration without reordering server writes.
|
|
52
|
+
const urgent = this.applicationJobs.findIndex(job => job.urgent);
|
|
53
|
+
const [job] = this.applicationJobs.splice(urgent < 0 ? 0 : urgent, 1);
|
|
54
|
+
await job.run();
|
|
55
|
+
}
|
|
56
|
+
this.applicationRunning = false;
|
|
57
|
+
}
|
|
14
58
|
hydration;
|
|
15
59
|
freshnessValue = "verifying";
|
|
16
60
|
versionValue = 0;
|
|
61
|
+
executionVersionValue = 0;
|
|
62
|
+
entityVersionClock = 0;
|
|
63
|
+
entityVersionFloor = 0;
|
|
64
|
+
entityVersions = new Map();
|
|
17
65
|
windowVersionClock = 0;
|
|
18
66
|
windowVersions = new Map();
|
|
67
|
+
rowVersions = new Map();
|
|
68
|
+
windowRowsVersions = new Map();
|
|
19
69
|
scopeValue = defaultReplicaScope;
|
|
20
70
|
// The default scope starts as an immediately usable empty in-memory store.
|
|
21
71
|
// Persistence is restored only by hydrate()/activateScope(), so direct
|
|
@@ -24,8 +74,38 @@ export class LocalReplica {
|
|
|
24
74
|
scopeActivation;
|
|
25
75
|
scopeActivationName;
|
|
26
76
|
scopeActivationGeneration = 0;
|
|
27
|
-
constructor(storage) {
|
|
77
|
+
constructor(storage, options = {}) {
|
|
28
78
|
this.storage = storage;
|
|
79
|
+
for (const [name, value] of Object.entries(options))
|
|
80
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0))
|
|
81
|
+
throw new Error(`Invalid replica residency budget: ${name}`);
|
|
82
|
+
this.residencyBudget.maxRows = options.maxResidentRows ?? this.residencyBudget.maxRows;
|
|
83
|
+
this.residencyBudget.maxBytes = options.maxResidentBytes ?? this.residencyBudget.maxBytes;
|
|
84
|
+
}
|
|
85
|
+
/** Release a discarded client's in-memory state without deleting durable data. */
|
|
86
|
+
dispose() {
|
|
87
|
+
this.disposed = true;
|
|
88
|
+
this.scopeLoaded = false;
|
|
89
|
+
this.scopeActivationGeneration++;
|
|
90
|
+
for (const job of this.applicationJobs.splice(0))
|
|
91
|
+
job.cancel();
|
|
92
|
+
this.releaseMemory();
|
|
93
|
+
}
|
|
94
|
+
releaseMemory() {
|
|
95
|
+
this.entities.clear();
|
|
96
|
+
this.liveQueries.clear();
|
|
97
|
+
this.pendingCommands.clear();
|
|
98
|
+
this.listeners.clear();
|
|
99
|
+
this.retainedWindows.clear();
|
|
100
|
+
this.retainedRows.clear();
|
|
101
|
+
this.residentPartialTables.clear();
|
|
102
|
+
this.windowOwned.clear();
|
|
103
|
+
this.replicaPlans.clear();
|
|
104
|
+
this.readSnapshots.clear();
|
|
105
|
+
this.entityVersions.clear();
|
|
106
|
+
this.rowVersions.clear();
|
|
107
|
+
this.windowVersions.clear();
|
|
108
|
+
this.windowRowsVersions.clear();
|
|
29
109
|
}
|
|
30
110
|
hydrate() {
|
|
31
111
|
if (this.hydration)
|
|
@@ -48,19 +128,28 @@ export class LocalReplica {
|
|
|
48
128
|
// synchronous auth change from exposing the prior identity's rows.
|
|
49
129
|
const wasLoaded = this.scopeLoaded;
|
|
50
130
|
this.scopeLoaded = false;
|
|
51
|
-
if (wasLoaded)
|
|
52
|
-
this.
|
|
131
|
+
if (wasLoaded) {
|
|
132
|
+
this.invalidateEntityVersions();
|
|
133
|
+
this.notify(true);
|
|
134
|
+
}
|
|
53
135
|
const generation = ++this.scopeActivationGeneration;
|
|
54
|
-
const activation = this.
|
|
136
|
+
const activation = this.enqueueApplication(async () => {
|
|
55
137
|
// A prior transaction may still be in the storage queue. Wait for it so
|
|
56
138
|
// a same-scope activation cannot load a snapshot from before its commit.
|
|
57
139
|
await this.persistence;
|
|
58
|
-
const snapshot =
|
|
140
|
+
const snapshot = this.storage?.loadWorkingSet
|
|
141
|
+
? await this.storage.loadWorkingSet(nextScope, this.residencyBudget)
|
|
142
|
+
: await this.storage?.load(nextScope);
|
|
59
143
|
if (generation !== this.scopeActivationGeneration)
|
|
60
144
|
return;
|
|
61
145
|
this.scopeValue = nextScope;
|
|
146
|
+
this.storageSequence = snapshot?.storageSequence ?? 0;
|
|
62
147
|
this.scopeLoaded = true;
|
|
63
148
|
this.entities = snapshot ? entitiesFromSnapshot(snapshot.entities) : new Map();
|
|
149
|
+
this.residentPartialTables.clear();
|
|
150
|
+
for (const entity of snapshot?.residentPartialTables ?? [])
|
|
151
|
+
this.residentPartialTables.add(entity);
|
|
152
|
+
this.invalidateEntityVersions();
|
|
64
153
|
this.liveQueries = snapshot
|
|
65
154
|
? new Map(Object.entries(snapshot.liveQueries).map(([key, value]) => [key, normalizeWindow(value)]))
|
|
66
155
|
: new Map();
|
|
@@ -72,22 +161,280 @@ export class LocalReplica {
|
|
|
72
161
|
// projected while the newly restored scope is becoming authoritative.
|
|
73
162
|
this.pendingCommands.clear();
|
|
74
163
|
this.freshnessValue = "verifying";
|
|
75
|
-
this.notify();
|
|
164
|
+
this.notify(true);
|
|
165
|
+
queueMicrotask(() => {
|
|
166
|
+
if (generation !== this.scopeActivationGeneration)
|
|
167
|
+
return;
|
|
168
|
+
for (const signature of this.retainedWindows.keys())
|
|
169
|
+
this.retainWindow(signature)();
|
|
170
|
+
for (const [entity, ids] of this.retainedRows)
|
|
171
|
+
this.retainRows(entity, [...ids.keys()])();
|
|
172
|
+
});
|
|
76
173
|
});
|
|
77
|
-
this.application = activation.catch(() => undefined);
|
|
78
174
|
this.scopeActivation = activation;
|
|
79
175
|
this.scopeActivationName = nextScope;
|
|
80
176
|
return activation;
|
|
81
177
|
}
|
|
178
|
+
/** Active views retain their rows. Cold collections stay durable on disk and
|
|
179
|
+
* reducer reads can use the indexed adapter without hydrating the table. */
|
|
180
|
+
retainWindow(signature) {
|
|
181
|
+
this.retainedWindows.set(signature, (this.retainedWindows.get(signature) ?? 0) + 1);
|
|
182
|
+
const scopeGeneration = this.scopeActivationGeneration;
|
|
183
|
+
const operation = this.enqueueApplication(async () => {
|
|
184
|
+
if (!this.scopeLoaded || !this.retainedWindows.has(signature) || scopeGeneration !== this.scopeActivationGeneration || !this.storage?.loadWindowRows)
|
|
185
|
+
return;
|
|
186
|
+
const existing = this.liveQueries.get(signature);
|
|
187
|
+
if (existing?.ids.every(id => this.entities.get(existing.entity)?.has(id)))
|
|
188
|
+
return;
|
|
189
|
+
const loaded = await this.storage.loadWindowRows(this.scopeValue, signature);
|
|
190
|
+
if (!loaded || !this.retainedWindows.has(signature) || scopeGeneration !== this.scopeActivationGeneration)
|
|
191
|
+
return;
|
|
192
|
+
const table = new Map(this.entities.get(loaded.window.entity));
|
|
193
|
+
for (const row of loaded.rows)
|
|
194
|
+
table.set(String(row[loaded.window.key]), row);
|
|
195
|
+
this.entities = new Map(this.entities).set(loaded.window.entity, table);
|
|
196
|
+
this.liveQueries.set(signature, normalizeWindow(loaded.window));
|
|
197
|
+
if (loaded.window.kind === 'replica' && loaded.window.completeness === 'complete' && !loaded.window.truncated && loaded.rows.length === loaded.window.ids.length)
|
|
198
|
+
this.residentPartialTables.delete(loaded.window.entity);
|
|
199
|
+
this.markEntityChanged(loaded.window.entity, loaded.window.ids);
|
|
200
|
+
this.markWindowChanged(signature);
|
|
201
|
+
this.notify(true);
|
|
202
|
+
}, true);
|
|
203
|
+
let released = false;
|
|
204
|
+
return () => {
|
|
205
|
+
if (released)
|
|
206
|
+
return;
|
|
207
|
+
released = true;
|
|
208
|
+
const count = this.retainedWindows.get(signature) ?? 0;
|
|
209
|
+
if (count > 1)
|
|
210
|
+
this.retainedWindows.set(signature, count - 1);
|
|
211
|
+
else {
|
|
212
|
+
this.retainedWindows.delete(signature);
|
|
213
|
+
this.readSnapshots.delete(signature);
|
|
214
|
+
this.trimResidentRows();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
residentCoverage(coverage) {
|
|
219
|
+
if (!this.residentPartialTables.size)
|
|
220
|
+
return coverage;
|
|
221
|
+
return Object.fromEntries(Object.entries(coverage).map(([table, value]) => [table, this.residentPartialTables.has(table) ? { ...value, complete: false, completeWhere: undefined } : value]));
|
|
222
|
+
}
|
|
223
|
+
retainRows(entity, ids) {
|
|
224
|
+
const scopeGeneration = this.scopeActivationGeneration;
|
|
225
|
+
let retained = this.retainedRows.get(entity);
|
|
226
|
+
if (!retained) {
|
|
227
|
+
retained = new Map();
|
|
228
|
+
this.retainedRows.set(entity, retained);
|
|
229
|
+
}
|
|
230
|
+
for (const id of ids)
|
|
231
|
+
retained.set(id, (retained.get(id) ?? 0) + 1);
|
|
232
|
+
const operation = this.enqueueApplication(async () => {
|
|
233
|
+
if (!this.scopeLoaded || !this.storage?.loadEntityRows || scopeGeneration !== this.scopeActivationGeneration)
|
|
234
|
+
return;
|
|
235
|
+
const missing = ids.filter(id => !this.entities.get(entity)?.has(id) && this.retainedRows.get(entity)?.has(id));
|
|
236
|
+
if (!missing.length)
|
|
237
|
+
return;
|
|
238
|
+
const rows = await this.storage.loadEntityRows(this.scopeValue, entity, missing);
|
|
239
|
+
if (scopeGeneration !== this.scopeActivationGeneration)
|
|
240
|
+
return;
|
|
241
|
+
const next = new Map(this.entities.get(entity));
|
|
242
|
+
for (const { id, row } of rows)
|
|
243
|
+
if (this.retainedRows.get(entity)?.has(id))
|
|
244
|
+
next.set(id, row);
|
|
245
|
+
this.entities = new Map(this.entities).set(entity, next);
|
|
246
|
+
this.markEntityChanged(entity, missing);
|
|
247
|
+
this.notify(true);
|
|
248
|
+
}, true);
|
|
249
|
+
let released = false;
|
|
250
|
+
return () => {
|
|
251
|
+
if (released)
|
|
252
|
+
return;
|
|
253
|
+
released = true;
|
|
254
|
+
const current = this.retainedRows.get(entity);
|
|
255
|
+
for (const id of ids) {
|
|
256
|
+
const count = current?.get(id) ?? 0;
|
|
257
|
+
if (count > 1)
|
|
258
|
+
current.set(id, count - 1);
|
|
259
|
+
else
|
|
260
|
+
current?.delete(id);
|
|
261
|
+
}
|
|
262
|
+
if (!current?.size)
|
|
263
|
+
this.retainedRows.delete(entity);
|
|
264
|
+
this.trimResidentRows();
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
trimResidentRows() {
|
|
268
|
+
// Without indexed persistence, eviction would lose the only readable copy.
|
|
269
|
+
if (!this.storage?.withReadView || !this.storage?.loadWindowRows)
|
|
270
|
+
return;
|
|
271
|
+
let bytes = 0, count = 0;
|
|
272
|
+
for (const rows of this.entities.values())
|
|
273
|
+
for (const row of rows.values()) {
|
|
274
|
+
let size = this.rowSizes.get(row);
|
|
275
|
+
if (size === undefined) {
|
|
276
|
+
size = JSON.stringify(row).length * 2 + 256;
|
|
277
|
+
this.rowSizes.set(row, size);
|
|
278
|
+
}
|
|
279
|
+
bytes += size;
|
|
280
|
+
count++;
|
|
281
|
+
}
|
|
282
|
+
if (bytes <= this.residencyBudget.maxBytes && count <= this.residencyBudget.maxRows)
|
|
283
|
+
return;
|
|
284
|
+
const pinned = new Map();
|
|
285
|
+
const pin = (entity, id) => { let ids = pinned.get(entity); if (!ids) {
|
|
286
|
+
ids = new Set();
|
|
287
|
+
pinned.set(entity, ids);
|
|
288
|
+
} ids.add(id); };
|
|
289
|
+
for (const signature of this.retainedWindows.keys()) {
|
|
290
|
+
const window = this.liveQueries.get(signature);
|
|
291
|
+
if (window)
|
|
292
|
+
for (const id of window.ids)
|
|
293
|
+
pin(window.entity, id);
|
|
294
|
+
}
|
|
295
|
+
for (const [entity, ids] of this.retainedRows)
|
|
296
|
+
for (const id of ids.keys())
|
|
297
|
+
pin(entity, id);
|
|
298
|
+
for (const command of this.pendingCommands.values())
|
|
299
|
+
for (const patch of command.patches)
|
|
300
|
+
pin(patch.entity ?? patch.collection, patch.rowId);
|
|
301
|
+
const next = new Map(this.entities);
|
|
302
|
+
let evicted = false;
|
|
303
|
+
for (const [entity, rows] of this.entities) {
|
|
304
|
+
let copied;
|
|
305
|
+
for (const [id, row] of rows) {
|
|
306
|
+
if (bytes <= this.residencyBudget.maxBytes && count <= this.residencyBudget.maxRows)
|
|
307
|
+
break;
|
|
308
|
+
if (pinned.get(entity)?.has(id))
|
|
309
|
+
continue;
|
|
310
|
+
copied ??= new Map(rows);
|
|
311
|
+
copied.delete(id);
|
|
312
|
+
count--;
|
|
313
|
+
bytes -= this.rowSizes.get(row);
|
|
314
|
+
this.rowVersions.delete(`${entity}\0${id}`);
|
|
315
|
+
this.residentPartialTables.add(entity);
|
|
316
|
+
evicted = true;
|
|
317
|
+
}
|
|
318
|
+
if (copied)
|
|
319
|
+
next.set(entity, copied);
|
|
320
|
+
}
|
|
321
|
+
if (evicted) {
|
|
322
|
+
this.entities = next;
|
|
323
|
+
this.readSnapshots.clear();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
82
326
|
cursor() {
|
|
83
327
|
return this.scopeLoaded && this.cursorValue ? { ...this.cursorValue } : undefined;
|
|
84
328
|
}
|
|
329
|
+
/** Consume the durable replica, including commits delivered by another tab.
|
|
330
|
+
* Predictions remain a separate overlay; peers never advance our socket's
|
|
331
|
+
* acknowledged cursor or publish a half-applied transaction. */
|
|
332
|
+
synchronizeStorage() {
|
|
333
|
+
if (!this.storage?.readChanges)
|
|
334
|
+
return Promise.resolve();
|
|
335
|
+
const scope = this.scopeValue;
|
|
336
|
+
const application = this.enqueueApplication(async () => {
|
|
337
|
+
if (!this.scopeLoaded || scope !== this.scopeValue || !this.storage?.readChanges)
|
|
338
|
+
return;
|
|
339
|
+
await this.persistence;
|
|
340
|
+
const entities = new Map(this.entities);
|
|
341
|
+
const windows = new Map(this.liveQueries);
|
|
342
|
+
if (!await this.mergeStoredChanges(entities, windows))
|
|
343
|
+
return;
|
|
344
|
+
this.entities = entities;
|
|
345
|
+
this.liveQueries = windows;
|
|
346
|
+
this.notify(true);
|
|
347
|
+
}, true);
|
|
348
|
+
return application;
|
|
349
|
+
}
|
|
350
|
+
async mergeStoredChanges(entities, windows) {
|
|
351
|
+
if (!this.storage?.readChanges)
|
|
352
|
+
return false;
|
|
353
|
+
const residentIds = Object.fromEntries([...entities].map(([table, rows]) => [table, [...rows.keys()]]));
|
|
354
|
+
let residentBytes = 0, residentCount = 0;
|
|
355
|
+
for (const rows of entities.values())
|
|
356
|
+
for (const row of rows.values()) {
|
|
357
|
+
let size = this.rowSizes.get(row);
|
|
358
|
+
if (size === undefined) {
|
|
359
|
+
size = JSON.stringify(row).length * 2 + 256;
|
|
360
|
+
this.rowSizes.set(row, size);
|
|
361
|
+
}
|
|
362
|
+
residentBytes += size;
|
|
363
|
+
residentCount++;
|
|
364
|
+
}
|
|
365
|
+
for (const command of this.pendingCommands.values())
|
|
366
|
+
for (const patch of command.patches) {
|
|
367
|
+
const entity = patch.entity ?? patch.collection;
|
|
368
|
+
(residentIds[entity] ??= []).push(patch.rowId);
|
|
369
|
+
}
|
|
370
|
+
const changes = await this.storage.readChanges(this.scopeValue, this.storageSequence, this.storage.loadWorkingSet ? {
|
|
371
|
+
rows: residentIds,
|
|
372
|
+
windows: [...this.retainedWindows.keys()],
|
|
373
|
+
availableRows: Math.max(0, this.residencyBudget.maxRows - residentCount),
|
|
374
|
+
availableBytes: Math.max(0, this.residencyBudget.maxBytes - residentBytes),
|
|
375
|
+
...this.residencyBudget,
|
|
376
|
+
} : undefined);
|
|
377
|
+
if (changes.sequence === this.storageSequence)
|
|
378
|
+
return false;
|
|
379
|
+
if (changes.reset) {
|
|
380
|
+
entities.clear();
|
|
381
|
+
windows.clear();
|
|
382
|
+
this.windowOwned.clear();
|
|
383
|
+
this.invalidateEntityVersions();
|
|
384
|
+
}
|
|
385
|
+
for (const [entity, values] of Object.entries(changes.entities)) {
|
|
386
|
+
const rows = new Map(entities.get(entity));
|
|
387
|
+
const changedIDs = new Set();
|
|
388
|
+
for (const [id, row] of Object.entries(values)) {
|
|
389
|
+
if (row === null) {
|
|
390
|
+
if (rows.delete(id))
|
|
391
|
+
changedIDs.add(id);
|
|
392
|
+
}
|
|
393
|
+
else if (!sameReplicaValue(rows.get(id), row)) {
|
|
394
|
+
rows.set(id, row);
|
|
395
|
+
changedIDs.add(id);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!changedIDs.size)
|
|
399
|
+
continue;
|
|
400
|
+
entities.set(entity, rows);
|
|
401
|
+
this.markEntityChanged(entity, changedIDs);
|
|
402
|
+
for (const window of windows.values()) {
|
|
403
|
+
if (window.entity === entity && window.ids.some(id => changedIDs.has(id)))
|
|
404
|
+
this.markWindowChanged(window.signature);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const [signature, window] of Object.entries(changes.windows)) {
|
|
408
|
+
const previous = windows.get(signature);
|
|
409
|
+
if (window === null) {
|
|
410
|
+
if (windows.delete(signature))
|
|
411
|
+
this.markWindowChanged(signature);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (sameReplicaValue(previous, window))
|
|
415
|
+
continue;
|
|
416
|
+
const membershipChanged = !sameWindowRowDefinition(previous, window);
|
|
417
|
+
windows.set(signature, normalizeWindow(window));
|
|
418
|
+
if (window.ids.some(id => !entities.get(window.entity)?.has(id)))
|
|
419
|
+
this.residentPartialTables.add(window.entity);
|
|
420
|
+
// Row changes have already invalidated their windows above. A storage
|
|
421
|
+
// watermark changes metadata, not membership or the visible row array.
|
|
422
|
+
this.markWindowChanged(signature, membershipChanged);
|
|
423
|
+
}
|
|
424
|
+
this.storageSequence = changes.sequence;
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
85
427
|
freshness() {
|
|
86
428
|
return this.freshnessValue;
|
|
87
429
|
}
|
|
88
430
|
version() { return this.versionValue; }
|
|
431
|
+
/** Changes to the authoritative execution snapshot, excluding UI metadata and predictions. */
|
|
432
|
+
executionVersion() { return this.executionVersionValue; }
|
|
433
|
+
entityVersion(entity, id) { return id === undefined ? this.entityVersions.get(entity) ?? this.entityVersionFloor : this.rowVersion(entity, id); }
|
|
434
|
+
rowVersion(entity, id) { return this.rowVersions.get(`${entity}\0${id}`) ?? this.entityVersionFloor; }
|
|
89
435
|
/** Monotonic version for one materialized window and its referenced rows. */
|
|
90
436
|
windowVersion(signature) { return this.windowVersions.get(signature) ?? 0; }
|
|
437
|
+
windowRowsVersion(signature) { return this.windowRowsVersions.get(signature) ?? 0; }
|
|
91
438
|
setFreshness(freshness) {
|
|
92
439
|
if (freshness === this.freshnessValue)
|
|
93
440
|
return;
|
|
@@ -103,18 +450,30 @@ export class LocalReplica {
|
|
|
103
450
|
if (!commandId)
|
|
104
451
|
throw new Error("optimistic commandId is required");
|
|
105
452
|
const clonedPatches = patches.map(cloneOptimisticPatch);
|
|
453
|
+
const previous = this.pendingCommands.get(commandId);
|
|
454
|
+
if (previous && sameReplicaValue(previous.patches, clonedPatches))
|
|
455
|
+
return;
|
|
456
|
+
if (previous)
|
|
457
|
+
this.markWindowsForPatches(previous.patches);
|
|
106
458
|
this.pendingCommands.set(commandId, { commandId, patches: clonedPatches });
|
|
107
459
|
this.markWindowsForPatches(clonedPatches);
|
|
108
460
|
this.notify();
|
|
109
461
|
}
|
|
110
462
|
/** SDK-only atomic replacement after ordered local reducer replay. */
|
|
111
463
|
replaceOptimistic(commands) {
|
|
464
|
+
// Rebase is also triggered by unrelated authoritative updates. An identical
|
|
465
|
+
// overlay must not dirty every selector or schedule another application render.
|
|
466
|
+
const previous = [...this.pendingCommands.values()];
|
|
467
|
+
if (previous.length === commands.length && commands.every((command, index) => command.commandId === previous[index].commandId &&
|
|
468
|
+
JSON.stringify(command.patches) === JSON.stringify(previous[index].patches)))
|
|
469
|
+
return;
|
|
112
470
|
for (const command of this.pendingCommands.values())
|
|
113
471
|
this.markWindowsForPatches(command.patches);
|
|
472
|
+
const previousCommands = new Map(this.pendingCommands);
|
|
114
473
|
this.pendingCommands.clear();
|
|
115
474
|
for (const command of commands) {
|
|
116
475
|
const patches = command.patches.map(cloneOptimisticPatch);
|
|
117
|
-
this.pendingCommands.set(command.commandId, { commandId: command.commandId, patches });
|
|
476
|
+
this.pendingCommands.set(command.commandId, { commandId: command.commandId, patches, committedRevision: previousCommands.get(command.commandId)?.committedRevision });
|
|
118
477
|
this.markWindowsForPatches(patches);
|
|
119
478
|
}
|
|
120
479
|
this.notify();
|
|
@@ -152,13 +511,11 @@ export class LocalReplica {
|
|
|
152
511
|
}
|
|
153
512
|
applyTransaction(transaction, scope) {
|
|
154
513
|
const requestedScope = scope === undefined ? undefined : normalizeReplicaScope(scope);
|
|
155
|
-
const application = this.
|
|
156
|
-
this.application = application.catch(() => undefined);
|
|
514
|
+
const application = this.enqueueApplication(() => this.applyTransactionNow(transaction, requestedScope));
|
|
157
515
|
return application;
|
|
158
516
|
}
|
|
159
517
|
materializeWindow(input) {
|
|
160
|
-
const application = this.
|
|
161
|
-
this.application = application.catch(() => undefined);
|
|
518
|
+
const application = this.enqueueApplication(() => this.materializeWindowNow(input));
|
|
162
519
|
return application;
|
|
163
520
|
}
|
|
164
521
|
/** Replace one server/cache window while retaining shared normalized entities. */
|
|
@@ -167,7 +524,7 @@ export class LocalReplica {
|
|
|
167
524
|
}
|
|
168
525
|
/** Apply a bounded window delta and persist it in the same transaction. */
|
|
169
526
|
applyWindowDelta(input) {
|
|
170
|
-
const application = this.
|
|
527
|
+
const application = this.enqueueApplication(async () => {
|
|
171
528
|
const existing = this.liveQueries.get(input.signature);
|
|
172
529
|
const deleted = new Set(input.deleted.map(String));
|
|
173
530
|
const ids = (existing?.ids ?? []).filter((id) => !deleted.has(id));
|
|
@@ -177,19 +534,24 @@ export class LocalReplica {
|
|
|
177
534
|
if (id && !ids.includes(id))
|
|
178
535
|
ids.push(id);
|
|
179
536
|
}
|
|
537
|
+
const upserts = new Map(input.upserts.map(row => [String(row[input.key]), row]));
|
|
180
538
|
const rows = ids
|
|
181
|
-
.map((id) =>
|
|
182
|
-
.filter((row) => row !== undefined)
|
|
183
|
-
|
|
539
|
+
.map((id) => upserts.has(id) ? { ...this.entities.get(input.entity)?.get(id), ...upserts.get(id) } : this.entities.get(input.entity)?.get(id))
|
|
540
|
+
.filter((row) => row !== undefined);
|
|
541
|
+
const plan = this.replicaPlans.get(input.signature)?.definition;
|
|
542
|
+
const orderBy = input.orderBy ?? existing?.orderBy ?? plan?.orderBy;
|
|
543
|
+
const orderDirection = input.orderDirection ?? existing?.orderDirection ?? plan?.orderDirection;
|
|
544
|
+
if ((input.kind ?? existing?.kind) === 'replica' && orderBy) {
|
|
545
|
+
rows.sort((a, b) => compareReplicaMembershipRows(a, b, input.key, orderBy, orderDirection));
|
|
546
|
+
}
|
|
184
547
|
await this.materializeWindowNow({
|
|
185
548
|
...input,
|
|
186
549
|
rows,
|
|
187
550
|
completeness: input.completeness ?? existing?.completeness ?? "partial",
|
|
188
551
|
source: input.source ?? existing?.source ?? "server",
|
|
189
552
|
removedIDs: [...deleted],
|
|
190
|
-
});
|
|
553
|
+
}, { upserts: input.upserts, deleted: [...deleted] });
|
|
191
554
|
});
|
|
192
|
-
this.application = application.catch(() => undefined);
|
|
193
555
|
return application;
|
|
194
556
|
}
|
|
195
557
|
/**
|
|
@@ -200,21 +562,22 @@ export class LocalReplica {
|
|
|
200
562
|
*/
|
|
201
563
|
advanceWatermark(revision, signatures = [], scope) {
|
|
202
564
|
const requestedScope = scope === undefined ? undefined : normalizeReplicaScope(scope);
|
|
203
|
-
const application = this.
|
|
565
|
+
const application = this.enqueueApplication(async () => {
|
|
204
566
|
if (requestedScope !== undefined && requestedScope !== this.scopeValue)
|
|
205
567
|
return;
|
|
206
568
|
if (!Number.isSafeInteger(revision) || revision < 0 || signatures.length === 0)
|
|
207
569
|
return;
|
|
208
570
|
const eligible = new Set(signatures);
|
|
209
|
-
const nextQueries = new Map(
|
|
571
|
+
const nextQueries = new Map(this.liveQueries);
|
|
210
572
|
const changedWindows = [];
|
|
211
573
|
for (const [signature, window] of nextQueries) {
|
|
212
574
|
if (!eligible.has(signature) || window.kind !== "replica" || !window.cursor)
|
|
213
575
|
continue;
|
|
214
576
|
if (window.cursor.revision >= revision)
|
|
215
577
|
continue;
|
|
216
|
-
|
|
217
|
-
|
|
578
|
+
const advanced = { ...window, cursor: { ...window.cursor, revision } };
|
|
579
|
+
nextQueries.set(signature, advanced);
|
|
580
|
+
changedWindows.push(advanced);
|
|
218
581
|
}
|
|
219
582
|
if (changedWindows.length === 0)
|
|
220
583
|
return;
|
|
@@ -228,7 +591,7 @@ export class LocalReplica {
|
|
|
228
591
|
else if (this.storage?.replaceSnapshot) {
|
|
229
592
|
// Compatibility for older/custom storage adapters. Current IndexedDB
|
|
230
593
|
// and SQLite adapters implement the metadata-only operation above.
|
|
231
|
-
const snapshot =
|
|
594
|
+
const snapshot = storageSnapshotFrom(nextCursor, this.entities, nextQueries);
|
|
232
595
|
await this.persist(() => this.storage.replaceSnapshot(snapshot, writeScope));
|
|
233
596
|
}
|
|
234
597
|
else if (this.storage?.replaceWindow) {
|
|
@@ -236,18 +599,17 @@ export class LocalReplica {
|
|
|
236
599
|
// durability for those adapters by reusing one computed snapshot;
|
|
237
600
|
// this path is intentionally serial and is not used by the bundled
|
|
238
601
|
// normalized adapters.
|
|
239
|
-
const snapshot =
|
|
602
|
+
const snapshot = storageSnapshotFrom(nextCursor, this.entities, nextQueries);
|
|
240
603
|
for (const window of changedWindows) {
|
|
241
604
|
await this.persist(() => this.storage.replaceWindow(window, snapshot, writeScope));
|
|
242
605
|
}
|
|
243
606
|
}
|
|
244
607
|
this.liveQueries = nextQueries;
|
|
245
608
|
for (const window of changedWindows)
|
|
246
|
-
this.markWindowChanged(window.signature);
|
|
609
|
+
this.markWindowChanged(window.signature, false);
|
|
247
610
|
this.cursorValue = nextCursor;
|
|
248
611
|
this.notify();
|
|
249
612
|
});
|
|
250
|
-
this.application = application.catch(() => undefined);
|
|
251
613
|
return application;
|
|
252
614
|
}
|
|
253
615
|
getWindow(signature) {
|
|
@@ -277,14 +639,15 @@ export class LocalReplica {
|
|
|
277
639
|
.map(cloneRow);
|
|
278
640
|
}
|
|
279
641
|
removeWindow(signature, scope) {
|
|
280
|
-
const application = this.
|
|
642
|
+
const application = this.enqueueApplication(async () => {
|
|
281
643
|
if (scope !== undefined && normalizeReplicaScope(scope) !== this.scopeValue)
|
|
282
644
|
return;
|
|
283
645
|
if (!this.liveQueries.has(signature))
|
|
284
646
|
return;
|
|
285
647
|
const nextQueries = new Map(this.liveQueries);
|
|
648
|
+
const removed = nextQueries.get(signature);
|
|
286
649
|
nextQueries.delete(signature);
|
|
287
|
-
const snapshot =
|
|
650
|
+
const snapshot = storageSnapshotFrom(this.cursorValue, this.entities, nextQueries);
|
|
288
651
|
if (this.storage?.removeWindow) {
|
|
289
652
|
await this.persist(() => this.storage.removeWindow(signature, snapshot, this.scopeValue));
|
|
290
653
|
}
|
|
@@ -294,59 +657,67 @@ export class LocalReplica {
|
|
|
294
657
|
this.liveQueries = nextQueries;
|
|
295
658
|
this.markWindowChanged(signature);
|
|
296
659
|
this.pruneOwnedEntitiesAfterRemoval(signature, nextQueries);
|
|
297
|
-
this.
|
|
660
|
+
this.markEntityChanged(removed.entity);
|
|
661
|
+
this.notify(true);
|
|
298
662
|
});
|
|
299
|
-
this.application = application.catch(() => undefined);
|
|
300
663
|
return application;
|
|
301
664
|
}
|
|
302
665
|
clear(scope) {
|
|
303
|
-
const application = this.
|
|
666
|
+
const application = this.enqueueApplication(async () => {
|
|
304
667
|
if (scope !== undefined && normalizeReplicaScope(scope) !== this.scopeValue)
|
|
305
668
|
return;
|
|
306
669
|
if (this.storage?.clear)
|
|
307
670
|
await this.persist(() => this.storage.clear(this.scopeValue));
|
|
308
671
|
this.cursorValue = undefined;
|
|
309
672
|
this.entities.clear();
|
|
673
|
+
this.invalidateEntityVersions();
|
|
310
674
|
for (const signature of this.liveQueries.keys())
|
|
311
675
|
this.markWindowChanged(signature);
|
|
312
676
|
this.liveQueries.clear();
|
|
313
677
|
this.windowOwned.clear();
|
|
314
678
|
this.pendingCommands.clear();
|
|
315
679
|
this.freshnessValue = "verifying";
|
|
316
|
-
this.notify();
|
|
680
|
+
this.notify(true);
|
|
317
681
|
});
|
|
318
|
-
this.application = application.catch(() => undefined);
|
|
319
682
|
return application;
|
|
320
683
|
}
|
|
321
|
-
async materializeWindowNow(input) {
|
|
684
|
+
async materializeWindowNow(input, delta) {
|
|
322
685
|
if (input.scope !== undefined && normalizeReplicaScope(input.scope) !== this.scopeValue)
|
|
323
686
|
return;
|
|
324
687
|
if (!input.signature.trim() || !input.entity.trim() || !input.key.trim()) {
|
|
325
688
|
throw new Error("replica materialization requires signature, entity, and key");
|
|
326
689
|
}
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
690
|
+
// This materialization changes one entity map and one membership. Other
|
|
691
|
+
// published maps/windows stay immutable and can be shared during persistence.
|
|
692
|
+
const nextEntities = new Map(this.entities);
|
|
693
|
+
const nextQueries = new Map(this.liveQueries);
|
|
694
|
+
const epochChanged = Boolean(input.cursor && this.cursorValue && input.cursor.epoch !== this.cursorValue.epoch);
|
|
695
|
+
if (epochChanged) {
|
|
333
696
|
nextEntities.clear();
|
|
334
697
|
nextQueries.clear();
|
|
335
698
|
}
|
|
336
|
-
const entityRows = nextEntities.get(input.entity)
|
|
699
|
+
const entityRows = new Map(nextEntities.get(input.entity));
|
|
337
700
|
nextEntities.set(input.entity, entityRows);
|
|
338
701
|
const ids = [];
|
|
702
|
+
const changedRowIDs = new Set();
|
|
703
|
+
const changedIDs = delta && !epochChanged ? new Set(delta.upserts.map(row => String(row[input.key]))) : undefined;
|
|
339
704
|
for (const row of input.rows) {
|
|
340
705
|
const rawID = row[input.key];
|
|
341
706
|
const id = typeof rawID === "string" || typeof rawID === "number" ? String(rawID) : "";
|
|
342
707
|
if (!id)
|
|
343
708
|
continue;
|
|
344
709
|
ids.push(id);
|
|
710
|
+
if (changedIDs && !changedIDs.has(id))
|
|
711
|
+
continue;
|
|
345
712
|
// Different Replica Collections may project different columns from the
|
|
346
713
|
// same table. They all hydrate one normalized entity, so a narrow
|
|
347
714
|
// projection must update the fields it owns without erasing fields
|
|
348
715
|
// supplied by another collection.
|
|
349
|
-
|
|
716
|
+
const priorRow = entityRows.get(id);
|
|
717
|
+
if (priorRow && Object.keys(row).every(key => Object.prototype.hasOwnProperty.call(priorRow, key) && sameReplicaValue(priorRow[key], row[key])))
|
|
718
|
+
continue;
|
|
719
|
+
entityRows.set(id, { ...(priorRow ?? {}), ...cloneRow(row) });
|
|
720
|
+
changedRowIDs.add(id);
|
|
350
721
|
}
|
|
351
722
|
const previous = nextQueries.get(input.signature);
|
|
352
723
|
const window = {
|
|
@@ -374,8 +745,8 @@ export class LocalReplica {
|
|
|
374
745
|
nextQueries.set(input.signature, window);
|
|
375
746
|
for (const id of input.removedIDs ?? []) {
|
|
376
747
|
const stillReferenced = [...nextQueries.values()].some((candidate) => candidate.ids.includes(id));
|
|
377
|
-
if (!stillReferenced)
|
|
378
|
-
|
|
748
|
+
if (!stillReferenced && nextEntities.get(input.entity)?.delete(id))
|
|
749
|
+
changedRowIDs.add(id);
|
|
379
750
|
}
|
|
380
751
|
this.trackWindowOwnership(window, input.rows, previous);
|
|
381
752
|
// A collection cursor proves only that collection's materialized rows.
|
|
@@ -384,22 +755,41 @@ export class LocalReplica {
|
|
|
384
755
|
// collection. The only revision proven by snapshots alone is the minimum
|
|
385
756
|
// cursor shared by every materialized Replica window in the epoch.
|
|
386
757
|
const nextCursor = replicaTransactionFloor(this.cursorValue, nextQueries);
|
|
387
|
-
const snapshot =
|
|
758
|
+
const snapshot = storageSnapshotFrom(nextCursor, nextEntities, nextQueries);
|
|
759
|
+
const membershipChanged = !sameWindowRowDefinition(previous, window);
|
|
760
|
+
const rowsChanged = membershipChanged || changedRowIDs.size > 0;
|
|
388
761
|
const writeScope = this.scopeValue;
|
|
389
|
-
if (this.storage?.
|
|
390
|
-
|
|
762
|
+
if (!epochChanged && this.storage?.applyWindowDelta && (delta || !rowsChanged)) {
|
|
763
|
+
const writeDelta = delta ? { upserts: delta.upserts.filter(row => changedRowIDs.has(String(row[input.key]))), deleted: delta.deleted } : { upserts: [], deleted: [] };
|
|
764
|
+
await this.persist(() => this.storage.applyWindowDelta(window, writeDelta, snapshot, writeScope));
|
|
765
|
+
}
|
|
766
|
+
else if (this.storage?.replaceWindow) {
|
|
767
|
+
await this.persist(() => this.storage.replaceWindow(window, snapshot, writeScope, input.rows));
|
|
391
768
|
}
|
|
392
769
|
else if (this.storage?.replaceSnapshot) {
|
|
393
770
|
await this.persist(() => this.storage.replaceSnapshot(snapshot, writeScope));
|
|
394
771
|
}
|
|
772
|
+
if (epochChanged)
|
|
773
|
+
this.invalidateEntityVersions();
|
|
774
|
+
if (this.storage?.readChanges)
|
|
775
|
+
await this.mergeStoredChanges(nextEntities, nextQueries);
|
|
395
776
|
this.entities = nextEntities;
|
|
396
777
|
this.liveQueries = nextQueries;
|
|
397
|
-
|
|
778
|
+
if (window.kind === 'replica' && window.completeness === 'complete' && !window.truncated && window.ids.every(id => nextEntities.get(window.entity)?.has(id)))
|
|
779
|
+
this.residentPartialTables.delete(window.entity);
|
|
780
|
+
if (changedRowIDs.size) {
|
|
781
|
+
this.markEntityChanged(input.entity, changedRowIDs);
|
|
782
|
+
for (const candidate of this.liveQueries.values()) {
|
|
783
|
+
if (candidate.signature !== input.signature && candidate.entity === input.entity && candidate.ids.some(id => changedRowIDs.has(id)))
|
|
784
|
+
this.markWindowChanged(candidate.signature);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
this.markWindowChanged(input.signature, rowsChanged);
|
|
398
788
|
this.pruneOwnedEntities(input.signature);
|
|
399
789
|
this.cursorValue = nextCursor;
|
|
400
790
|
if (input.source === "server")
|
|
401
791
|
this.freshnessValue = "current";
|
|
402
|
-
this.notify();
|
|
792
|
+
this.notify(true);
|
|
403
793
|
}
|
|
404
794
|
async applyTransactionNow(transaction, scope) {
|
|
405
795
|
if (scope !== undefined && scope !== this.scopeValue)
|
|
@@ -408,47 +798,60 @@ export class LocalReplica {
|
|
|
408
798
|
if (this.cursorValue?.epoch === transaction.cursor.epoch && transaction.cursor.revision <= this.cursorValue.revision) {
|
|
409
799
|
return;
|
|
410
800
|
}
|
|
411
|
-
const nextEntities =
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
const nextQueries = new Map(
|
|
801
|
+
const nextEntities = new Map(this.entities);
|
|
802
|
+
const copiedEntities = new Set();
|
|
803
|
+
// Copy only changed memberships; untouched immutable windows stay shared.
|
|
804
|
+
const nextQueries = new Map(this.liveQueries);
|
|
415
805
|
const changedWindows = new Set();
|
|
416
|
-
|
|
806
|
+
const epochChanged = Boolean(this.cursorValue && this.cursorValue.epoch !== transaction.cursor.epoch);
|
|
807
|
+
if (epochChanged) {
|
|
417
808
|
for (const signature of nextQueries.keys())
|
|
418
809
|
changedWindows.add(signature);
|
|
419
810
|
nextEntities.clear();
|
|
420
811
|
nextQueries.clear();
|
|
421
|
-
this.windowOwned.clear();
|
|
422
812
|
}
|
|
423
813
|
for (const change of transaction.changes) {
|
|
424
814
|
for (const [signature, window] of nextQueries) {
|
|
425
815
|
if (window.entity === change.entity && window.ids.includes(change.id))
|
|
426
816
|
changedWindows.add(signature);
|
|
427
817
|
}
|
|
428
|
-
|
|
429
|
-
|
|
818
|
+
if (!copiedEntities.has(change.entity)) {
|
|
819
|
+
nextEntities.set(change.entity, new Map(nextEntities.get(change.entity)));
|
|
820
|
+
copiedEntities.add(change.entity);
|
|
821
|
+
}
|
|
822
|
+
const rows = nextEntities.get(change.entity);
|
|
430
823
|
if (change.operation === "delete") {
|
|
431
824
|
rows.delete(change.id);
|
|
432
825
|
for (const window of nextQueries.values()) {
|
|
433
826
|
if (window.entity === change.entity && window.ids.includes(change.id)) {
|
|
434
|
-
window.
|
|
827
|
+
nextQueries.set(window.signature, { ...window, ids: window.ids.filter((id) => id !== change.id) });
|
|
435
828
|
}
|
|
436
829
|
}
|
|
437
830
|
}
|
|
831
|
+
// Change-feed rows are projections, just like collection snapshots.
|
|
832
|
+
// A narrow subscription must not erase fields supplied by another one.
|
|
438
833
|
else if (change.newValue)
|
|
439
|
-
rows.set(change.id, cloneRow(change.newValue));
|
|
834
|
+
rows.set(change.id, { ...rows.get(change.id), ...cloneRow(change.newValue) });
|
|
440
835
|
}
|
|
441
836
|
for (const membership of transaction.memberships ?? []) {
|
|
442
837
|
nextQueries.set(membership.signature, normalizeWindow(membership));
|
|
443
838
|
changedWindows.add(membership.signature);
|
|
444
839
|
}
|
|
445
|
-
const snapshot =
|
|
840
|
+
const snapshot = storageSnapshotFrom(transaction.cursor, nextEntities, nextQueries);
|
|
446
841
|
const writeScope = this.scopeValue;
|
|
447
842
|
await this.persist(() => this.storage?.applyTransaction(transaction, snapshot, writeScope));
|
|
843
|
+
if (this.storage?.readChanges)
|
|
844
|
+
await this.mergeStoredChanges(nextEntities, nextQueries);
|
|
845
|
+
if (epochChanged) {
|
|
846
|
+
this.invalidateEntityVersions();
|
|
847
|
+
this.windowOwned.clear();
|
|
848
|
+
}
|
|
448
849
|
// Publish the whole committed transaction in one state swap and notify UI
|
|
449
850
|
// exactly once. No subscriber can observe a partial entity/query update.
|
|
450
851
|
this.entities = nextEntities;
|
|
451
852
|
this.liveQueries = nextQueries;
|
|
853
|
+
for (const change of transaction.changes)
|
|
854
|
+
this.markEntityChanged(change.entity, [change.id]);
|
|
452
855
|
for (const signature of changedWindows)
|
|
453
856
|
this.markWindowChanged(signature);
|
|
454
857
|
this.cursorValue = { ...transaction.cursor };
|
|
@@ -459,7 +862,7 @@ export class LocalReplica {
|
|
|
459
862
|
// created row can disappear from a complete Replica Collection between
|
|
460
863
|
// the transaction and its membership delta.
|
|
461
864
|
this.reconcileCommands(false);
|
|
462
|
-
this.notify();
|
|
865
|
+
this.notify(true);
|
|
463
866
|
}
|
|
464
867
|
entity(entity, id) {
|
|
465
868
|
if (!this.scopeLoaded)
|
|
@@ -484,33 +887,269 @@ export class LocalReplica {
|
|
|
484
887
|
}
|
|
485
888
|
/** Resolve several IDs from one atomic Local Replica version. */
|
|
486
889
|
entityBatch(entity, ids) {
|
|
487
|
-
|
|
890
|
+
if (!this.scopeLoaded)
|
|
891
|
+
return ids.map(() => undefined);
|
|
892
|
+
const base = this.entities.get(entity);
|
|
893
|
+
if (this.pendingCommands.size === 0)
|
|
894
|
+
return ids.map(id => {
|
|
895
|
+
const row = base?.get(id);
|
|
896
|
+
return row === undefined ? undefined : cloneRow(row);
|
|
897
|
+
});
|
|
898
|
+
const rows = new Map();
|
|
899
|
+
for (const id of ids)
|
|
900
|
+
rows.set(id, base?.get(id));
|
|
901
|
+
if (rows.size)
|
|
902
|
+
for (const command of this.pendingCommands.values()) {
|
|
903
|
+
for (const patch of command.patches) {
|
|
904
|
+
if ((patch.entity ?? patch.collection) !== entity || !rows.has(patch.rowId))
|
|
905
|
+
continue;
|
|
906
|
+
if (patch.op === 'delete')
|
|
907
|
+
rows.set(patch.rowId, undefined);
|
|
908
|
+
else if (patch.op === 'insert' || patch.op === 'upsert')
|
|
909
|
+
rows.set(patch.rowId, patch.fields);
|
|
910
|
+
else if (patch.op === 'patch')
|
|
911
|
+
rows.set(patch.rowId, { ...(rows.get(patch.rowId) ?? {}), ...patch.fields });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// Preserve caller order and return independent values for duplicate IDs.
|
|
915
|
+
return ids.map(id => {
|
|
916
|
+
const row = rows.get(id);
|
|
917
|
+
return row === undefined ? undefined : cloneRow(row);
|
|
918
|
+
});
|
|
488
919
|
}
|
|
489
920
|
/** All cached rows for one normalized entity, including optimistic overlays. */
|
|
490
921
|
entityRows(entity) {
|
|
491
922
|
if (!this.scopeLoaded)
|
|
492
923
|
return [];
|
|
493
|
-
|
|
924
|
+
if (this.pendingCommands.size === 0) {
|
|
925
|
+
return Array.from(this.entities.get(entity)?.values() ?? [], row => cloneRow(row));
|
|
926
|
+
}
|
|
927
|
+
// Apply the journal once for the table, not once per row. Keep deleted
|
|
928
|
+
// entries as tombstones until projection so delete/reinsert retains the
|
|
929
|
+
// established ID order. Base rows and journal fields stay untouched.
|
|
930
|
+
const rows = new Map(this.entities.get(entity));
|
|
494
931
|
for (const command of this.pendingCommands.values()) {
|
|
495
932
|
for (const patch of command.patches) {
|
|
496
|
-
if ((patch.entity ?? patch.collection)
|
|
933
|
+
if ((patch.entity ?? patch.collection) !== entity)
|
|
934
|
+
continue;
|
|
935
|
+
if (patch.op === "delete")
|
|
936
|
+
rows.set(patch.rowId, undefined);
|
|
937
|
+
else if (patch.op === "insert" || patch.op === "upsert")
|
|
938
|
+
rows.set(patch.rowId, patch.fields);
|
|
939
|
+
else if (patch.op === "patch")
|
|
940
|
+
rows.set(patch.rowId, { ...(rows.get(patch.rowId) ?? {}), ...patch.fields });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const result = [];
|
|
944
|
+
// Clone only the final values, including nested patch fields. Callers must
|
|
945
|
+
// not be able to mutate either authoritative rows or the pending journal.
|
|
946
|
+
for (const row of rows.values())
|
|
947
|
+
if (row !== undefined)
|
|
948
|
+
result.push(cloneRow(row));
|
|
949
|
+
return result;
|
|
950
|
+
}
|
|
951
|
+
/** Serialize against confirmed publications, then capture just the pending journal.
|
|
952
|
+
* IndexedDB keeps the base transaction consistent without copying any tables.
|
|
953
|
+
*/
|
|
954
|
+
pendingReducerPatches() {
|
|
955
|
+
return [...this.pendingCommands.values()].flatMap(command => command.patches.map(patch => patch.op === 'delete'
|
|
956
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
|
|
957
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: structuredClone(patch.fields) }));
|
|
958
|
+
}
|
|
959
|
+
withReadView(coverage, pending, run, residentOnly = false) {
|
|
960
|
+
if (residentOnly) {
|
|
961
|
+
if (!this.scopeLoaded)
|
|
962
|
+
return Promise.reject(new Error('Local replica scope is not ready'));
|
|
963
|
+
const known = coverage();
|
|
964
|
+
const patches = typeof pending !== 'boolean' ? [...pending] : pending ? [...this.pendingCommands.values()].flatMap(command => command.patches.map(patch => patch.op === 'delete'
|
|
965
|
+
? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
|
|
966
|
+
: { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields })) : [];
|
|
967
|
+
// A short speculative read never waits for disk. Its publication is
|
|
968
|
+
// checked against the captured execution version, and durable admission
|
|
969
|
+
// re-executes against the coordinated journal before sending anything.
|
|
970
|
+
return run(overlayReadView(memoryReadView(this.entities, this.residentCoverage(known)), known, patches));
|
|
971
|
+
}
|
|
972
|
+
const operation = this.enqueueApplication(async () => {
|
|
973
|
+
if (!this.scopeLoaded)
|
|
974
|
+
throw new Error('Local replica scope is not ready');
|
|
975
|
+
await this.persistence;
|
|
976
|
+
const known = coverage();
|
|
977
|
+
const patches = typeof pending !== 'boolean' ? [...pending] : pending ? [...this.pendingCommands.values()].flatMap(command => command.patches.map(patch => {
|
|
978
|
+
const entity = patch.entity ?? patch.collection;
|
|
979
|
+
return patch.op === 'delete' ? { entity, rowId: patch.rowId, op: 'delete' } : { entity, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields };
|
|
980
|
+
})) : [];
|
|
981
|
+
const apply = (base) => run(overlayReadView(base, known, patches));
|
|
982
|
+
// The visible working set is already resident. Execute against that
|
|
983
|
+
// consistent view first instead of sending hot row reads back to disk.
|
|
984
|
+
// A missing row/field retries the whole prediction in one disk read
|
|
985
|
+
// transaction, never mixes rows from two different snapshots.
|
|
986
|
+
try {
|
|
987
|
+
return await apply(memoryReadView(this.entities, this.residentCoverage(known)));
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError' || !this.storage?.withReadView)
|
|
991
|
+
throw error;
|
|
992
|
+
return this.storage.withReadView(this.scopeValue, known, apply);
|
|
993
|
+
}
|
|
994
|
+
}, true);
|
|
995
|
+
return operation;
|
|
996
|
+
}
|
|
997
|
+
/** SDK execution snapshot: capture immutable row references now, copy a table only when used. */
|
|
998
|
+
captureExecutionRows(includePending) {
|
|
999
|
+
const captured = new Map([...this.entities].map(([entity, rows]) => [entity, [...rows.entries()]]));
|
|
1000
|
+
const patches = includePending ? [...this.pendingCommands.values()].flatMap(command => command.patches) : [];
|
|
1001
|
+
const loaded = this.scopeLoaded;
|
|
1002
|
+
const materialized = new Map();
|
|
1003
|
+
return entity => {
|
|
1004
|
+
const prior = materialized.get(entity);
|
|
1005
|
+
if (prior)
|
|
1006
|
+
return prior;
|
|
1007
|
+
const rows = new Map(loaded ? captured.get(entity) ?? [] : []);
|
|
1008
|
+
const ids = new Set(rows.keys());
|
|
1009
|
+
if (loaded)
|
|
1010
|
+
for (const patch of patches) {
|
|
1011
|
+
if ((patch.entity ?? patch.collection) !== entity)
|
|
1012
|
+
continue;
|
|
497
1013
|
ids.add(patch.rowId);
|
|
1014
|
+
if (patch.op === "delete")
|
|
1015
|
+
rows.delete(patch.rowId);
|
|
1016
|
+
else if (patch.op === "patch")
|
|
1017
|
+
rows.set(patch.rowId, { ...rows.get(patch.rowId), ...patch.fields });
|
|
1018
|
+
else
|
|
1019
|
+
rows.set(patch.rowId, patch.fields);
|
|
1020
|
+
}
|
|
1021
|
+
const result = [...ids].flatMap(id => { const row = rows.get(id); return row ? [cloneRow(row)] : []; });
|
|
1022
|
+
materialized.set(entity, result);
|
|
1023
|
+
return result;
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
/** Complete registered slices prove narrower reads without another subscription. */
|
|
1027
|
+
captureReadCoverage(base) {
|
|
1028
|
+
const coverage = { ...base };
|
|
1029
|
+
for (const [signature, { definition, args }] of this.replicaPlans) {
|
|
1030
|
+
const known = coverage[definition.table];
|
|
1031
|
+
if (!known || !this.windowIsComplete(signature))
|
|
1032
|
+
continue;
|
|
1033
|
+
const terms = [];
|
|
1034
|
+
let valid = true;
|
|
1035
|
+
for (const [argument, column] of Object.entries(definition.equalFilters ?? {})) {
|
|
1036
|
+
const value = args[argument];
|
|
1037
|
+
if (value === undefined || value !== null && !['string', 'number', 'boolean'].includes(typeof value)) {
|
|
1038
|
+
valid = false;
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
terms.push({ column, op: 'eq', value: value });
|
|
498
1042
|
}
|
|
1043
|
+
if (!valid)
|
|
1044
|
+
continue;
|
|
1045
|
+
for (const column of definition.excludeWhenSet ?? [])
|
|
1046
|
+
terms.push({ column, op: 'isNull' });
|
|
1047
|
+
coverage[definition.table] = {
|
|
1048
|
+
...known,
|
|
1049
|
+
complete: known.complete || terms.length === 0,
|
|
1050
|
+
completeWhere: terms.length ? [...(known.completeWhere ?? []), { and: terms }] : known.completeWhere,
|
|
1051
|
+
};
|
|
499
1052
|
}
|
|
500
|
-
return
|
|
501
|
-
|
|
502
|
-
|
|
1053
|
+
return coverage;
|
|
1054
|
+
}
|
|
1055
|
+
/** Read execution eligibility without copying a window's ordered IDs and hashes. */
|
|
1056
|
+
windowIsComplete(signature) {
|
|
1057
|
+
const window = this.liveQueries.get(signature);
|
|
1058
|
+
return this.scopeLoaded && window?.completeness === "complete" && window.truncated !== true;
|
|
1059
|
+
}
|
|
1060
|
+
/** SDK watch snapshots: unchanged rows are not cloned again on every notification. */
|
|
1061
|
+
watchRows(signature, cache) {
|
|
1062
|
+
const window = this.scopeLoaded ? this.liveQueries.get(signature) : undefined;
|
|
1063
|
+
if (!window) {
|
|
1064
|
+
cache.clear();
|
|
1065
|
+
return [];
|
|
1066
|
+
}
|
|
1067
|
+
// A pending write to a different row must not disable shared immutable
|
|
1068
|
+
// snapshots for every newly mounted view in the application.
|
|
1069
|
+
let pendingRows;
|
|
1070
|
+
for (const command of this.pendingCommands.values()) {
|
|
1071
|
+
for (const patch of command.patches) {
|
|
1072
|
+
if ((patch.entity ?? patch.collection) === window.entity)
|
|
1073
|
+
(pendingRows ??= new Set()).add(patch.rowId);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const read = (id) => {
|
|
1077
|
+
const version = this.rowVersion(window.entity, id);
|
|
1078
|
+
const prior = cache.get(id);
|
|
1079
|
+
if (prior?.version === version)
|
|
1080
|
+
return prior.row;
|
|
1081
|
+
const base = !pendingRows?.has(id) ? this.entities.get(window.entity)?.get(id) : undefined;
|
|
1082
|
+
let next;
|
|
1083
|
+
if (base) {
|
|
1084
|
+
let copy = this.visibleRowCopies.get(base);
|
|
1085
|
+
if (!copy) {
|
|
1086
|
+
copy = cloneRow(base);
|
|
1087
|
+
this.visibleRowCopies.set(base, copy);
|
|
1088
|
+
}
|
|
1089
|
+
next = copy;
|
|
1090
|
+
}
|
|
1091
|
+
else
|
|
1092
|
+
next = this.entity(window.entity, id);
|
|
1093
|
+
// A matching server echo or removal of its prediction changes protocol
|
|
1094
|
+
// versions without changing the row the component sees.
|
|
1095
|
+
const row = prior && sameReplicaValue(prior.row, next) ? prior.row : next;
|
|
1096
|
+
cache.set(id, { version, row });
|
|
1097
|
+
return row;
|
|
1098
|
+
};
|
|
1099
|
+
const ids = this.effectiveMembership(window, read);
|
|
1100
|
+
const retained = new Set(ids);
|
|
1101
|
+
for (const id of cache.keys())
|
|
1102
|
+
if (!retained.has(id))
|
|
1103
|
+
cache.delete(id);
|
|
1104
|
+
return ids.map(read).filter((row) => row !== undefined);
|
|
503
1105
|
}
|
|
504
1106
|
/** Exact only when an authoritative, non-truncated Replica Collection covers the entity. */
|
|
505
1107
|
entityCompleteness(entity) {
|
|
506
|
-
if (!this.scopeLoaded)
|
|
1108
|
+
if (!this.scopeLoaded || this.residentPartialTables.has(entity))
|
|
507
1109
|
return "partial";
|
|
508
1110
|
return [...this.liveQueries.values()].some((window) => (window.kind === "replica"
|
|
509
1111
|
&& window.entity === entity
|
|
510
1112
|
&& window.completeness === "complete"
|
|
511
1113
|
&& window.truncated !== true)) ? "complete" : "partial";
|
|
512
1114
|
}
|
|
513
|
-
|
|
1115
|
+
// Bounded derived snapshots, containing only rows in recently read windows.
|
|
1116
|
+
readSnapshots = new Map();
|
|
1117
|
+
liveQuerySnapshot(signature) {
|
|
1118
|
+
let snapshot = this.readSnapshots.get(signature);
|
|
1119
|
+
if (!snapshot) {
|
|
1120
|
+
if (this.readSnapshots.size >= 64) {
|
|
1121
|
+
const cold = [...this.readSnapshots.keys()].find(key => !this.retainedWindows.has(key));
|
|
1122
|
+
if (cold !== undefined)
|
|
1123
|
+
this.readSnapshots.delete(cold);
|
|
1124
|
+
}
|
|
1125
|
+
snapshot = { version: -1, rowsVersion: -1, rows: [], cache: new Map() };
|
|
1126
|
+
}
|
|
1127
|
+
// Mounted windows cannot be evicted. Reordering them on every store read
|
|
1128
|
+
// only allocates Map tombstones; LRU order matters for cold windows alone.
|
|
1129
|
+
if (!this.retainedWindows.has(signature))
|
|
1130
|
+
this.readSnapshots.delete(signature);
|
|
1131
|
+
this.readSnapshots.set(signature, snapshot);
|
|
1132
|
+
if (snapshot.version !== this.windowVersion(signature) || snapshot.freshness !== this.freshnessValue) {
|
|
1133
|
+
const rowsVersion = this.windowRowsVersion(signature);
|
|
1134
|
+
if (snapshot.rowsVersion !== rowsVersion) {
|
|
1135
|
+
const rows = this.watchRows(signature, snapshot.cache);
|
|
1136
|
+
if (rows.length !== snapshot.rows.length || rows.some((row, index) => row !== snapshot.rows[index]))
|
|
1137
|
+
snapshot.rows = rows;
|
|
1138
|
+
snapshot.rowsVersion = rowsVersion;
|
|
1139
|
+
}
|
|
1140
|
+
const next = this.liveQuery(signature, snapshot.rows);
|
|
1141
|
+
const previous = snapshot.result;
|
|
1142
|
+
if (!previous || previous.rows !== next.rows || !sameReplicaValue({ ...previous, rows: undefined }, { ...next, rows: undefined }))
|
|
1143
|
+
snapshot.result = next;
|
|
1144
|
+
snapshot.version = this.windowVersion(signature);
|
|
1145
|
+
snapshot.freshness = this.freshnessValue;
|
|
1146
|
+
}
|
|
1147
|
+
return snapshot.result;
|
|
1148
|
+
}
|
|
1149
|
+
liveQuery(signature, cachedRows) {
|
|
1150
|
+
return this.windowResult(signature, cachedRows);
|
|
1151
|
+
}
|
|
1152
|
+
windowResult(signature, cachedRows) {
|
|
514
1153
|
if (!this.scopeLoaded) {
|
|
515
1154
|
return { rows: [], ids: [], source: "cache", completeness: "partial", freshness: this.freshnessValue };
|
|
516
1155
|
}
|
|
@@ -518,8 +1157,8 @@ export class LocalReplica {
|
|
|
518
1157
|
if (!membership) {
|
|
519
1158
|
return { rows: [], ids: [], source: "cache", completeness: "partial", freshness: this.freshnessValue };
|
|
520
1159
|
}
|
|
521
|
-
const ids = this.effectiveMembership(membership);
|
|
522
|
-
const rows = ids
|
|
1160
|
+
const ids = cachedRows ? cachedRows.map(row => String(row[membership.key])) : this.effectiveMembership(membership);
|
|
1161
|
+
const rows = cachedRows ?? ids
|
|
523
1162
|
.map((id) => this.entity(membership.entity, id))
|
|
524
1163
|
.filter((row) => row !== undefined);
|
|
525
1164
|
const metadata = windowResultMetadata(membership.resultSkeleton, membership.resultPath);
|
|
@@ -528,13 +1167,13 @@ export class LocalReplica {
|
|
|
528
1167
|
ids,
|
|
529
1168
|
...metadata,
|
|
530
1169
|
source: this.freshnessValue === "current" ? membership.source : "cache",
|
|
531
|
-
completeness: membership.completeness,
|
|
1170
|
+
completeness: membership.ids.every(id => this.entities.get(membership.entity)?.has(id)) ? membership.completeness : 'partial',
|
|
532
1171
|
freshness: this.freshnessValue,
|
|
533
1172
|
};
|
|
534
1173
|
}
|
|
535
1174
|
/** Rows and protocol-owned completeness for one Replica Collection window. */
|
|
536
|
-
collectionState(signature) {
|
|
537
|
-
const result = this.
|
|
1175
|
+
collectionState(signature, cachedRows) {
|
|
1176
|
+
const result = this.windowResult(signature, cachedRows);
|
|
538
1177
|
const window = this.scopeLoaded ? this.liveQueries.get(signature) : undefined;
|
|
539
1178
|
return {
|
|
540
1179
|
...result,
|
|
@@ -568,24 +1207,45 @@ export class LocalReplica {
|
|
|
568
1207
|
this.persistence = attempt.catch(() => undefined);
|
|
569
1208
|
return attempt;
|
|
570
1209
|
}
|
|
571
|
-
notify() {
|
|
1210
|
+
notify(executionChanged = false) {
|
|
1211
|
+
if (this.disposed)
|
|
1212
|
+
return;
|
|
1213
|
+
if (executionChanged)
|
|
1214
|
+
this.trimResidentRows();
|
|
1215
|
+
if (executionChanged)
|
|
1216
|
+
this.executionVersionValue += 1;
|
|
572
1217
|
this.versionValue += 1;
|
|
573
1218
|
for (const listener of [...this.listeners])
|
|
574
1219
|
listener();
|
|
575
1220
|
}
|
|
576
|
-
|
|
1221
|
+
markEntityChanged(entity, ids) {
|
|
1222
|
+
const version = ++this.entityVersionClock;
|
|
1223
|
+
this.entityVersions.set(entity, version);
|
|
1224
|
+
for (const id of ids ?? this.entities.get(entity)?.keys() ?? [])
|
|
1225
|
+
this.rowVersions.set(`${entity}\0${id}`, version);
|
|
1226
|
+
}
|
|
1227
|
+
invalidateEntityVersions() {
|
|
1228
|
+
this.entityVersionFloor = ++this.entityVersionClock;
|
|
1229
|
+
this.entityVersions.clear();
|
|
1230
|
+
this.rowVersions.clear();
|
|
1231
|
+
this.readSnapshots.clear();
|
|
1232
|
+
}
|
|
1233
|
+
markWindowChanged(signature, rowsChanged = true) {
|
|
577
1234
|
this.windowVersionClock += 1;
|
|
578
1235
|
this.windowVersions.set(signature, this.windowVersionClock);
|
|
1236
|
+
if (rowsChanged)
|
|
1237
|
+
this.windowRowsVersions.set(signature, this.windowVersionClock);
|
|
579
1238
|
}
|
|
580
1239
|
markWindowsForPatches(patches) {
|
|
581
1240
|
const affected = new Set();
|
|
582
1241
|
for (const patch of patches) {
|
|
583
1242
|
const entity = patch.entity ?? patch.collection;
|
|
1243
|
+
if (entity)
|
|
1244
|
+
this.markEntityChanged(entity, [patch.rowId]);
|
|
584
1245
|
for (const [signature, window] of this.liveQueries) {
|
|
585
1246
|
if (window.entity !== entity)
|
|
586
1247
|
continue;
|
|
587
|
-
|
|
588
|
-
if (window.ids.includes(patch.rowId) || currentIDs.includes(patch.rowId)) {
|
|
1248
|
+
if (window.ids.includes(patch.rowId)) {
|
|
589
1249
|
affected.add(signature);
|
|
590
1250
|
continue;
|
|
591
1251
|
}
|
|
@@ -605,7 +1265,7 @@ export class LocalReplica {
|
|
|
605
1265
|
* the committed server membership, so rejection and reconciliation are
|
|
606
1266
|
* reversible and never write optimistic IDs to storage.
|
|
607
1267
|
*/
|
|
608
|
-
effectiveMembership(window) {
|
|
1268
|
+
effectiveMembership(window, read = id => this.entity(window.entity, id)) {
|
|
609
1269
|
const plan = this.replicaPlans.get(window.signature);
|
|
610
1270
|
const patches = [...this.pendingCommands.values()]
|
|
611
1271
|
.flatMap((command) => command.patches)
|
|
@@ -621,7 +1281,7 @@ export class LocalReplica {
|
|
|
621
1281
|
const ids = new Set(window.ids);
|
|
622
1282
|
const touched = new Set(patches.map((patch) => patch.rowId));
|
|
623
1283
|
for (const id of touched) {
|
|
624
|
-
const row =
|
|
1284
|
+
const row = read(id);
|
|
625
1285
|
if (row && replicaRowMatchesPlan(row, plan.definition, plan.args))
|
|
626
1286
|
ids.add(id);
|
|
627
1287
|
else
|
|
@@ -637,7 +1297,9 @@ export class LocalReplica {
|
|
|
637
1297
|
const orderBy = plan.definition.orderBy;
|
|
638
1298
|
if (!orderBy)
|
|
639
1299
|
return [...retained, ...additions.sort(compareReplicaKeys)];
|
|
640
|
-
|
|
1300
|
+
// The comparator may run O(n log n) times. Resolve each immutable row once.
|
|
1301
|
+
const rows = new Map(effectiveIDs.map(id => [id, read(id)]));
|
|
1302
|
+
return effectiveIDs.sort((left, right) => compareReplicaMembershipRows(rows.get(left), rows.get(right), window.key, orderBy, plan.definition.orderDirection));
|
|
641
1303
|
}
|
|
642
1304
|
trackWindowOwnership(window, rows, previous) {
|
|
643
1305
|
const owned = this.windowOwned.get(window.signature) ?? new Map();
|
|
@@ -663,9 +1325,14 @@ export class LocalReplica {
|
|
|
663
1325
|
if (!owned)
|
|
664
1326
|
return;
|
|
665
1327
|
const sourceWindow = this.liveQueries.get(signature);
|
|
1328
|
+
const referenced = new Set();
|
|
1329
|
+
for (const window of this.liveQueries.values()) {
|
|
1330
|
+
if (window.entity === sourceWindow?.entity)
|
|
1331
|
+
for (const id of window.ids)
|
|
1332
|
+
referenced.add(id);
|
|
1333
|
+
}
|
|
666
1334
|
for (const [id] of owned) {
|
|
667
|
-
|
|
668
|
-
if (!stillReferenced && sourceWindow) {
|
|
1335
|
+
if (!referenced.has(id) && sourceWindow) {
|
|
669
1336
|
this.entities.get(sourceWindow.entity)?.delete(id);
|
|
670
1337
|
}
|
|
671
1338
|
}
|
|
@@ -687,6 +1354,13 @@ export class LocalReplica {
|
|
|
687
1354
|
this.windowOwned.delete(signature);
|
|
688
1355
|
}
|
|
689
1356
|
}
|
|
1357
|
+
function sameWindowRowDefinition(previous, next) {
|
|
1358
|
+
return previous !== undefined && previous.entity === next.entity && previous.key === next.key
|
|
1359
|
+
&& previous.kind === next.kind && previous.completeness === next.completeness
|
|
1360
|
+
&& previous.truncated === next.truncated && previous.orderBy === next.orderBy
|
|
1361
|
+
&& previous.orderDirection === next.orderDirection && previous.ids.length === next.ids.length
|
|
1362
|
+
&& previous.ids.every((id, index) => id === next.ids[index]);
|
|
1363
|
+
}
|
|
690
1364
|
function replicaRowMatchesPlan(row, definition, args) {
|
|
691
1365
|
// The module manifest encodes equalFilters as argument name -> row column
|
|
692
1366
|
// (for example `{ id: "_id" }`). Keep this aligned with artifact parsing.
|
|
@@ -783,6 +1457,19 @@ function cloneOptimisticPatch(patch) {
|
|
|
783
1457
|
return { ...patch, fields: structuredClone(patch.fields) };
|
|
784
1458
|
return { ...patch, fields: structuredClone(patch.fields) };
|
|
785
1459
|
}
|
|
1460
|
+
function sameReplicaValue(left, right) {
|
|
1461
|
+
if (left === right)
|
|
1462
|
+
return true;
|
|
1463
|
+
if (!left || !right || typeof left !== "object" || typeof right !== "object")
|
|
1464
|
+
return false;
|
|
1465
|
+
if (Array.isArray(left))
|
|
1466
|
+
return Array.isArray(right) && left.length === right.length && left.every((value, index) => sameReplicaValue(value, right[index]));
|
|
1467
|
+
if (Array.isArray(right))
|
|
1468
|
+
return false;
|
|
1469
|
+
const a = left, b = right;
|
|
1470
|
+
const keys = Object.keys(a);
|
|
1471
|
+
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameReplicaValue(a[key], b[key]));
|
|
1472
|
+
}
|
|
786
1473
|
function cloneRow(row) { return structuredClone(row); }
|
|
787
1474
|
function windowResultMetadata(result, resultPath) {
|
|
788
1475
|
if (!result || typeof result !== "object" || Array.isArray(result) || !resultPath?.length)
|
|
@@ -853,9 +1540,6 @@ function hydratedTransactionFloor(persisted, windows) {
|
|
|
853
1540
|
// unsafe floor across a browser restart.
|
|
854
1541
|
return { epoch, revision: Math.min(persisted.revision, provenRevision) };
|
|
855
1542
|
}
|
|
856
|
-
function cloneEntities(source) {
|
|
857
|
-
return new Map([...source].map(([entity, rows]) => [entity, new Map([...rows].map(([id, row]) => [id, cloneRow(row)]))]));
|
|
858
|
-
}
|
|
859
1543
|
function entitiesFromSnapshot(source) {
|
|
860
1544
|
return new Map(Object.entries(source).map(([entity, rows]) => [entity, new Map(Object.entries(rows).map(([id, row]) => [id, cloneRow(row)]))]));
|
|
861
1545
|
}
|
|
@@ -874,4 +1558,46 @@ function normalizeReplicaScope(scope) {
|
|
|
874
1558
|
return defaultReplicaScope;
|
|
875
1559
|
return scope;
|
|
876
1560
|
}
|
|
1561
|
+
/** Build snapshot accessors only if an adapter needs them. Metadata-only writes
|
|
1562
|
+
* never enumerate tables/windows; delta adapters clone values on first access. */
|
|
1563
|
+
function storageSnapshotFrom(cursor, source, windows) {
|
|
1564
|
+
// Capture membership now even if an adapter reads its snapshot after the
|
|
1565
|
+
// persistence promise resolves and reconciliation replaces map entries.
|
|
1566
|
+
const capturedSource = new Map(source);
|
|
1567
|
+
const capturedWindows = new Map(windows);
|
|
1568
|
+
const snapshot = { cursor: cursor ? { ...cursor } : undefined };
|
|
1569
|
+
Object.defineProperty(snapshot, "entities", { enumerable: true, configurable: true, get() {
|
|
1570
|
+
const entities = {};
|
|
1571
|
+
for (const [entity, rows] of capturedSource) {
|
|
1572
|
+
// Committed maps and rows are immutable. Retain that version without
|
|
1573
|
+
// allocating entries for unrelated tables; clone values on first access.
|
|
1574
|
+
const captured = rows;
|
|
1575
|
+
Object.defineProperty(entities, entity, { enumerable: true, configurable: true, get() {
|
|
1576
|
+
const value = {};
|
|
1577
|
+
for (const [id, row] of captured)
|
|
1578
|
+
Object.defineProperty(value, id, { enumerable: true, configurable: true, get() {
|
|
1579
|
+
const copy = cloneRow(row);
|
|
1580
|
+
Object.defineProperty(value, id, { enumerable: true, configurable: true, writable: true, value: copy });
|
|
1581
|
+
return copy;
|
|
1582
|
+
} });
|
|
1583
|
+
Object.defineProperty(entities, entity, { enumerable: true, configurable: true, writable: true, value });
|
|
1584
|
+
return value;
|
|
1585
|
+
} });
|
|
1586
|
+
}
|
|
1587
|
+
Object.defineProperty(snapshot, "entities", { enumerable: true, configurable: true, writable: true, value: entities });
|
|
1588
|
+
return entities;
|
|
1589
|
+
} });
|
|
1590
|
+
Object.defineProperty(snapshot, "liveQueries", { enumerable: true, configurable: true, get() {
|
|
1591
|
+
const liveQueries = {};
|
|
1592
|
+
for (const [key, window] of capturedWindows)
|
|
1593
|
+
Object.defineProperty(liveQueries, key, { enumerable: true, configurable: true, get() {
|
|
1594
|
+
const copy = cloneWindow(window);
|
|
1595
|
+
Object.defineProperty(liveQueries, key, { enumerable: true, configurable: true, writable: true, value: copy });
|
|
1596
|
+
return copy;
|
|
1597
|
+
} });
|
|
1598
|
+
Object.defineProperty(snapshot, "liveQueries", { enumerable: true, configurable: true, writable: true, value: liveQueries });
|
|
1599
|
+
return liveQueries;
|
|
1600
|
+
} });
|
|
1601
|
+
return snapshot;
|
|
1602
|
+
}
|
|
877
1603
|
//# sourceMappingURL=local-replica.js.map
|