@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
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,10 +450,34 @@ 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
|
}
|
|
462
|
+
/** SDK-only atomic replacement after ordered local reducer replay. */
|
|
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;
|
|
470
|
+
for (const command of this.pendingCommands.values())
|
|
471
|
+
this.markWindowsForPatches(command.patches);
|
|
472
|
+
const previousCommands = new Map(this.pendingCommands);
|
|
473
|
+
this.pendingCommands.clear();
|
|
474
|
+
for (const command of commands) {
|
|
475
|
+
const patches = command.patches.map(cloneOptimisticPatch);
|
|
476
|
+
this.pendingCommands.set(command.commandId, { commandId: command.commandId, patches, committedRevision: previousCommands.get(command.commandId)?.committedRevision });
|
|
477
|
+
this.markWindowsForPatches(patches);
|
|
478
|
+
}
|
|
479
|
+
this.notify();
|
|
480
|
+
}
|
|
110
481
|
/** Register generated Replica metadata without persisting it with the window. */
|
|
111
482
|
registerReplicaCollection(signature, definition, args = {}) {
|
|
112
483
|
this.replicaPlans.set(signature, {
|
|
@@ -140,13 +511,11 @@ export class LocalReplica {
|
|
|
140
511
|
}
|
|
141
512
|
applyTransaction(transaction, scope) {
|
|
142
513
|
const requestedScope = scope === undefined ? undefined : normalizeReplicaScope(scope);
|
|
143
|
-
const application = this.
|
|
144
|
-
this.application = application.catch(() => undefined);
|
|
514
|
+
const application = this.enqueueApplication(() => this.applyTransactionNow(transaction, requestedScope));
|
|
145
515
|
return application;
|
|
146
516
|
}
|
|
147
517
|
materializeWindow(input) {
|
|
148
|
-
const application = this.
|
|
149
|
-
this.application = application.catch(() => undefined);
|
|
518
|
+
const application = this.enqueueApplication(() => this.materializeWindowNow(input));
|
|
150
519
|
return application;
|
|
151
520
|
}
|
|
152
521
|
/** Replace one server/cache window while retaining shared normalized entities. */
|
|
@@ -155,7 +524,7 @@ export class LocalReplica {
|
|
|
155
524
|
}
|
|
156
525
|
/** Apply a bounded window delta and persist it in the same transaction. */
|
|
157
526
|
applyWindowDelta(input) {
|
|
158
|
-
const application = this.
|
|
527
|
+
const application = this.enqueueApplication(async () => {
|
|
159
528
|
const existing = this.liveQueries.get(input.signature);
|
|
160
529
|
const deleted = new Set(input.deleted.map(String));
|
|
161
530
|
const ids = (existing?.ids ?? []).filter((id) => !deleted.has(id));
|
|
@@ -165,19 +534,24 @@ export class LocalReplica {
|
|
|
165
534
|
if (id && !ids.includes(id))
|
|
166
535
|
ids.push(id);
|
|
167
536
|
}
|
|
537
|
+
const upserts = new Map(input.upserts.map(row => [String(row[input.key]), row]));
|
|
168
538
|
const rows = ids
|
|
169
|
-
.map((id) =>
|
|
170
|
-
.filter((row) => row !== undefined)
|
|
171
|
-
|
|
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
|
+
}
|
|
172
547
|
await this.materializeWindowNow({
|
|
173
548
|
...input,
|
|
174
549
|
rows,
|
|
175
550
|
completeness: input.completeness ?? existing?.completeness ?? "partial",
|
|
176
551
|
source: input.source ?? existing?.source ?? "server",
|
|
177
552
|
removedIDs: [...deleted],
|
|
178
|
-
});
|
|
553
|
+
}, { upserts: input.upserts, deleted: [...deleted] });
|
|
179
554
|
});
|
|
180
|
-
this.application = application.catch(() => undefined);
|
|
181
555
|
return application;
|
|
182
556
|
}
|
|
183
557
|
/**
|
|
@@ -188,21 +562,22 @@ export class LocalReplica {
|
|
|
188
562
|
*/
|
|
189
563
|
advanceWatermark(revision, signatures = [], scope) {
|
|
190
564
|
const requestedScope = scope === undefined ? undefined : normalizeReplicaScope(scope);
|
|
191
|
-
const application = this.
|
|
565
|
+
const application = this.enqueueApplication(async () => {
|
|
192
566
|
if (requestedScope !== undefined && requestedScope !== this.scopeValue)
|
|
193
567
|
return;
|
|
194
568
|
if (!Number.isSafeInteger(revision) || revision < 0 || signatures.length === 0)
|
|
195
569
|
return;
|
|
196
570
|
const eligible = new Set(signatures);
|
|
197
|
-
const nextQueries = new Map(
|
|
571
|
+
const nextQueries = new Map(this.liveQueries);
|
|
198
572
|
const changedWindows = [];
|
|
199
573
|
for (const [signature, window] of nextQueries) {
|
|
200
574
|
if (!eligible.has(signature) || window.kind !== "replica" || !window.cursor)
|
|
201
575
|
continue;
|
|
202
576
|
if (window.cursor.revision >= revision)
|
|
203
577
|
continue;
|
|
204
|
-
|
|
205
|
-
|
|
578
|
+
const advanced = { ...window, cursor: { ...window.cursor, revision } };
|
|
579
|
+
nextQueries.set(signature, advanced);
|
|
580
|
+
changedWindows.push(advanced);
|
|
206
581
|
}
|
|
207
582
|
if (changedWindows.length === 0)
|
|
208
583
|
return;
|
|
@@ -216,7 +591,7 @@ export class LocalReplica {
|
|
|
216
591
|
else if (this.storage?.replaceSnapshot) {
|
|
217
592
|
// Compatibility for older/custom storage adapters. Current IndexedDB
|
|
218
593
|
// and SQLite adapters implement the metadata-only operation above.
|
|
219
|
-
const snapshot =
|
|
594
|
+
const snapshot = storageSnapshotFrom(nextCursor, this.entities, nextQueries);
|
|
220
595
|
await this.persist(() => this.storage.replaceSnapshot(snapshot, writeScope));
|
|
221
596
|
}
|
|
222
597
|
else if (this.storage?.replaceWindow) {
|
|
@@ -224,18 +599,17 @@ export class LocalReplica {
|
|
|
224
599
|
// durability for those adapters by reusing one computed snapshot;
|
|
225
600
|
// this path is intentionally serial and is not used by the bundled
|
|
226
601
|
// normalized adapters.
|
|
227
|
-
const snapshot =
|
|
602
|
+
const snapshot = storageSnapshotFrom(nextCursor, this.entities, nextQueries);
|
|
228
603
|
for (const window of changedWindows) {
|
|
229
604
|
await this.persist(() => this.storage.replaceWindow(window, snapshot, writeScope));
|
|
230
605
|
}
|
|
231
606
|
}
|
|
232
607
|
this.liveQueries = nextQueries;
|
|
233
608
|
for (const window of changedWindows)
|
|
234
|
-
this.markWindowChanged(window.signature);
|
|
609
|
+
this.markWindowChanged(window.signature, false);
|
|
235
610
|
this.cursorValue = nextCursor;
|
|
236
611
|
this.notify();
|
|
237
612
|
});
|
|
238
|
-
this.application = application.catch(() => undefined);
|
|
239
613
|
return application;
|
|
240
614
|
}
|
|
241
615
|
getWindow(signature) {
|
|
@@ -265,14 +639,15 @@ export class LocalReplica {
|
|
|
265
639
|
.map(cloneRow);
|
|
266
640
|
}
|
|
267
641
|
removeWindow(signature, scope) {
|
|
268
|
-
const application = this.
|
|
642
|
+
const application = this.enqueueApplication(async () => {
|
|
269
643
|
if (scope !== undefined && normalizeReplicaScope(scope) !== this.scopeValue)
|
|
270
644
|
return;
|
|
271
645
|
if (!this.liveQueries.has(signature))
|
|
272
646
|
return;
|
|
273
647
|
const nextQueries = new Map(this.liveQueries);
|
|
648
|
+
const removed = nextQueries.get(signature);
|
|
274
649
|
nextQueries.delete(signature);
|
|
275
|
-
const snapshot =
|
|
650
|
+
const snapshot = storageSnapshotFrom(this.cursorValue, this.entities, nextQueries);
|
|
276
651
|
if (this.storage?.removeWindow) {
|
|
277
652
|
await this.persist(() => this.storage.removeWindow(signature, snapshot, this.scopeValue));
|
|
278
653
|
}
|
|
@@ -282,59 +657,67 @@ export class LocalReplica {
|
|
|
282
657
|
this.liveQueries = nextQueries;
|
|
283
658
|
this.markWindowChanged(signature);
|
|
284
659
|
this.pruneOwnedEntitiesAfterRemoval(signature, nextQueries);
|
|
285
|
-
this.
|
|
660
|
+
this.markEntityChanged(removed.entity);
|
|
661
|
+
this.notify(true);
|
|
286
662
|
});
|
|
287
|
-
this.application = application.catch(() => undefined);
|
|
288
663
|
return application;
|
|
289
664
|
}
|
|
290
665
|
clear(scope) {
|
|
291
|
-
const application = this.
|
|
666
|
+
const application = this.enqueueApplication(async () => {
|
|
292
667
|
if (scope !== undefined && normalizeReplicaScope(scope) !== this.scopeValue)
|
|
293
668
|
return;
|
|
294
669
|
if (this.storage?.clear)
|
|
295
670
|
await this.persist(() => this.storage.clear(this.scopeValue));
|
|
296
671
|
this.cursorValue = undefined;
|
|
297
672
|
this.entities.clear();
|
|
673
|
+
this.invalidateEntityVersions();
|
|
298
674
|
for (const signature of this.liveQueries.keys())
|
|
299
675
|
this.markWindowChanged(signature);
|
|
300
676
|
this.liveQueries.clear();
|
|
301
677
|
this.windowOwned.clear();
|
|
302
678
|
this.pendingCommands.clear();
|
|
303
679
|
this.freshnessValue = "verifying";
|
|
304
|
-
this.notify();
|
|
680
|
+
this.notify(true);
|
|
305
681
|
});
|
|
306
|
-
this.application = application.catch(() => undefined);
|
|
307
682
|
return application;
|
|
308
683
|
}
|
|
309
|
-
async materializeWindowNow(input) {
|
|
684
|
+
async materializeWindowNow(input, delta) {
|
|
310
685
|
if (input.scope !== undefined && normalizeReplicaScope(input.scope) !== this.scopeValue)
|
|
311
686
|
return;
|
|
312
687
|
if (!input.signature.trim() || !input.entity.trim() || !input.key.trim()) {
|
|
313
688
|
throw new Error("replica materialization requires signature, entity, and key");
|
|
314
689
|
}
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
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) {
|
|
321
696
|
nextEntities.clear();
|
|
322
697
|
nextQueries.clear();
|
|
323
698
|
}
|
|
324
|
-
const entityRows = nextEntities.get(input.entity)
|
|
699
|
+
const entityRows = new Map(nextEntities.get(input.entity));
|
|
325
700
|
nextEntities.set(input.entity, entityRows);
|
|
326
701
|
const ids = [];
|
|
702
|
+
const changedRowIDs = new Set();
|
|
703
|
+
const changedIDs = delta && !epochChanged ? new Set(delta.upserts.map(row => String(row[input.key]))) : undefined;
|
|
327
704
|
for (const row of input.rows) {
|
|
328
705
|
const rawID = row[input.key];
|
|
329
706
|
const id = typeof rawID === "string" || typeof rawID === "number" ? String(rawID) : "";
|
|
330
707
|
if (!id)
|
|
331
708
|
continue;
|
|
332
709
|
ids.push(id);
|
|
710
|
+
if (changedIDs && !changedIDs.has(id))
|
|
711
|
+
continue;
|
|
333
712
|
// Different Replica Collections may project different columns from the
|
|
334
713
|
// same table. They all hydrate one normalized entity, so a narrow
|
|
335
714
|
// projection must update the fields it owns without erasing fields
|
|
336
715
|
// supplied by another collection.
|
|
337
|
-
|
|
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);
|
|
338
721
|
}
|
|
339
722
|
const previous = nextQueries.get(input.signature);
|
|
340
723
|
const window = {
|
|
@@ -362,8 +745,8 @@ export class LocalReplica {
|
|
|
362
745
|
nextQueries.set(input.signature, window);
|
|
363
746
|
for (const id of input.removedIDs ?? []) {
|
|
364
747
|
const stillReferenced = [...nextQueries.values()].some((candidate) => candidate.ids.includes(id));
|
|
365
|
-
if (!stillReferenced)
|
|
366
|
-
|
|
748
|
+
if (!stillReferenced && nextEntities.get(input.entity)?.delete(id))
|
|
749
|
+
changedRowIDs.add(id);
|
|
367
750
|
}
|
|
368
751
|
this.trackWindowOwnership(window, input.rows, previous);
|
|
369
752
|
// A collection cursor proves only that collection's materialized rows.
|
|
@@ -372,22 +755,41 @@ export class LocalReplica {
|
|
|
372
755
|
// collection. The only revision proven by snapshots alone is the minimum
|
|
373
756
|
// cursor shared by every materialized Replica window in the epoch.
|
|
374
757
|
const nextCursor = replicaTransactionFloor(this.cursorValue, nextQueries);
|
|
375
|
-
const snapshot =
|
|
758
|
+
const snapshot = storageSnapshotFrom(nextCursor, nextEntities, nextQueries);
|
|
759
|
+
const membershipChanged = !sameWindowRowDefinition(previous, window);
|
|
760
|
+
const rowsChanged = membershipChanged || changedRowIDs.size > 0;
|
|
376
761
|
const writeScope = this.scopeValue;
|
|
377
|
-
if (this.storage?.
|
|
378
|
-
|
|
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));
|
|
379
768
|
}
|
|
380
769
|
else if (this.storage?.replaceSnapshot) {
|
|
381
770
|
await this.persist(() => this.storage.replaceSnapshot(snapshot, writeScope));
|
|
382
771
|
}
|
|
772
|
+
if (epochChanged)
|
|
773
|
+
this.invalidateEntityVersions();
|
|
774
|
+
if (this.storage?.readChanges)
|
|
775
|
+
await this.mergeStoredChanges(nextEntities, nextQueries);
|
|
383
776
|
this.entities = nextEntities;
|
|
384
777
|
this.liveQueries = nextQueries;
|
|
385
|
-
|
|
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);
|
|
386
788
|
this.pruneOwnedEntities(input.signature);
|
|
387
789
|
this.cursorValue = nextCursor;
|
|
388
790
|
if (input.source === "server")
|
|
389
791
|
this.freshnessValue = "current";
|
|
390
|
-
this.notify();
|
|
792
|
+
this.notify(true);
|
|
391
793
|
}
|
|
392
794
|
async applyTransactionNow(transaction, scope) {
|
|
393
795
|
if (scope !== undefined && scope !== this.scopeValue)
|
|
@@ -396,47 +798,60 @@ export class LocalReplica {
|
|
|
396
798
|
if (this.cursorValue?.epoch === transaction.cursor.epoch && transaction.cursor.revision <= this.cursorValue.revision) {
|
|
397
799
|
return;
|
|
398
800
|
}
|
|
399
|
-
const nextEntities =
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
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);
|
|
403
805
|
const changedWindows = new Set();
|
|
404
|
-
|
|
806
|
+
const epochChanged = Boolean(this.cursorValue && this.cursorValue.epoch !== transaction.cursor.epoch);
|
|
807
|
+
if (epochChanged) {
|
|
405
808
|
for (const signature of nextQueries.keys())
|
|
406
809
|
changedWindows.add(signature);
|
|
407
810
|
nextEntities.clear();
|
|
408
811
|
nextQueries.clear();
|
|
409
|
-
this.windowOwned.clear();
|
|
410
812
|
}
|
|
411
813
|
for (const change of transaction.changes) {
|
|
412
814
|
for (const [signature, window] of nextQueries) {
|
|
413
815
|
if (window.entity === change.entity && window.ids.includes(change.id))
|
|
414
816
|
changedWindows.add(signature);
|
|
415
817
|
}
|
|
416
|
-
|
|
417
|
-
|
|
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);
|
|
418
823
|
if (change.operation === "delete") {
|
|
419
824
|
rows.delete(change.id);
|
|
420
825
|
for (const window of nextQueries.values()) {
|
|
421
826
|
if (window.entity === change.entity && window.ids.includes(change.id)) {
|
|
422
|
-
window.
|
|
827
|
+
nextQueries.set(window.signature, { ...window, ids: window.ids.filter((id) => id !== change.id) });
|
|
423
828
|
}
|
|
424
829
|
}
|
|
425
830
|
}
|
|
831
|
+
// Change-feed rows are projections, just like collection snapshots.
|
|
832
|
+
// A narrow subscription must not erase fields supplied by another one.
|
|
426
833
|
else if (change.newValue)
|
|
427
|
-
rows.set(change.id, cloneRow(change.newValue));
|
|
834
|
+
rows.set(change.id, { ...rows.get(change.id), ...cloneRow(change.newValue) });
|
|
428
835
|
}
|
|
429
836
|
for (const membership of transaction.memberships ?? []) {
|
|
430
837
|
nextQueries.set(membership.signature, normalizeWindow(membership));
|
|
431
838
|
changedWindows.add(membership.signature);
|
|
432
839
|
}
|
|
433
|
-
const snapshot =
|
|
840
|
+
const snapshot = storageSnapshotFrom(transaction.cursor, nextEntities, nextQueries);
|
|
434
841
|
const writeScope = this.scopeValue;
|
|
435
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
|
+
}
|
|
436
849
|
// Publish the whole committed transaction in one state swap and notify UI
|
|
437
850
|
// exactly once. No subscriber can observe a partial entity/query update.
|
|
438
851
|
this.entities = nextEntities;
|
|
439
852
|
this.liveQueries = nextQueries;
|
|
853
|
+
for (const change of transaction.changes)
|
|
854
|
+
this.markEntityChanged(change.entity, [change.id]);
|
|
440
855
|
for (const signature of changedWindows)
|
|
441
856
|
this.markWindowChanged(signature);
|
|
442
857
|
this.cursorValue = { ...transaction.cursor };
|
|
@@ -447,7 +862,7 @@ export class LocalReplica {
|
|
|
447
862
|
// created row can disappear from a complete Replica Collection between
|
|
448
863
|
// the transaction and its membership delta.
|
|
449
864
|
this.reconcileCommands(false);
|
|
450
|
-
this.notify();
|
|
865
|
+
this.notify(true);
|
|
451
866
|
}
|
|
452
867
|
entity(entity, id) {
|
|
453
868
|
if (!this.scopeLoaded)
|
|
@@ -472,33 +887,274 @@ export class LocalReplica {
|
|
|
472
887
|
}
|
|
473
888
|
/** Resolve several IDs from one atomic Local Replica version. */
|
|
474
889
|
entityBatch(entity, ids) {
|
|
475
|
-
|
|
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
|
+
});
|
|
476
919
|
}
|
|
477
920
|
/** All cached rows for one normalized entity, including optimistic overlays. */
|
|
478
921
|
entityRows(entity) {
|
|
479
922
|
if (!this.scopeLoaded)
|
|
480
923
|
return [];
|
|
481
|
-
|
|
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));
|
|
482
931
|
for (const command of this.pendingCommands.values()) {
|
|
483
932
|
for (const patch of command.patches) {
|
|
484
|
-
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;
|
|
485
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 });
|
|
486
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
|
+
};
|
|
487
1052
|
}
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
|
|
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
|
+
/** A watermark reads only cursor/integrity presence, never copied IDs or hashes. */
|
|
1061
|
+
windowCanAdvance(signature, revision) {
|
|
1062
|
+
const window = this.liveQueries.get(signature);
|
|
1063
|
+
return Boolean(window?.cursor && window.cursor.revision < revision && window.hashes);
|
|
1064
|
+
}
|
|
1065
|
+
/** SDK watch snapshots: unchanged rows are not cloned again on every notification. */
|
|
1066
|
+
watchRows(signature, cache) {
|
|
1067
|
+
const window = this.scopeLoaded ? this.liveQueries.get(signature) : undefined;
|
|
1068
|
+
if (!window) {
|
|
1069
|
+
cache.clear();
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
// A pending write to a different row must not disable shared immutable
|
|
1073
|
+
// snapshots for every newly mounted view in the application.
|
|
1074
|
+
let pendingRows;
|
|
1075
|
+
for (const command of this.pendingCommands.values()) {
|
|
1076
|
+
for (const patch of command.patches) {
|
|
1077
|
+
if ((patch.entity ?? patch.collection) === window.entity)
|
|
1078
|
+
(pendingRows ??= new Set()).add(patch.rowId);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const read = (id) => {
|
|
1082
|
+
const version = this.rowVersion(window.entity, id);
|
|
1083
|
+
const prior = cache.get(id);
|
|
1084
|
+
if (prior?.version === version)
|
|
1085
|
+
return prior.row;
|
|
1086
|
+
const base = !pendingRows?.has(id) ? this.entities.get(window.entity)?.get(id) : undefined;
|
|
1087
|
+
let next;
|
|
1088
|
+
if (base) {
|
|
1089
|
+
let copy = this.visibleRowCopies.get(base);
|
|
1090
|
+
if (!copy) {
|
|
1091
|
+
copy = cloneRow(base);
|
|
1092
|
+
this.visibleRowCopies.set(base, copy);
|
|
1093
|
+
}
|
|
1094
|
+
next = copy;
|
|
1095
|
+
}
|
|
1096
|
+
else
|
|
1097
|
+
next = this.entity(window.entity, id);
|
|
1098
|
+
// A matching server echo or removal of its prediction changes protocol
|
|
1099
|
+
// versions without changing the row the component sees.
|
|
1100
|
+
const row = prior && sameReplicaValue(prior.row, next) ? prior.row : next;
|
|
1101
|
+
cache.set(id, { version, row });
|
|
1102
|
+
return row;
|
|
1103
|
+
};
|
|
1104
|
+
const ids = this.effectiveMembership(window, read);
|
|
1105
|
+
const retained = new Set(ids);
|
|
1106
|
+
for (const id of cache.keys())
|
|
1107
|
+
if (!retained.has(id))
|
|
1108
|
+
cache.delete(id);
|
|
1109
|
+
return ids.map(read).filter((row) => row !== undefined);
|
|
491
1110
|
}
|
|
492
1111
|
/** Exact only when an authoritative, non-truncated Replica Collection covers the entity. */
|
|
493
1112
|
entityCompleteness(entity) {
|
|
494
|
-
if (!this.scopeLoaded)
|
|
1113
|
+
if (!this.scopeLoaded || this.residentPartialTables.has(entity))
|
|
495
1114
|
return "partial";
|
|
496
1115
|
return [...this.liveQueries.values()].some((window) => (window.kind === "replica"
|
|
497
1116
|
&& window.entity === entity
|
|
498
1117
|
&& window.completeness === "complete"
|
|
499
1118
|
&& window.truncated !== true)) ? "complete" : "partial";
|
|
500
1119
|
}
|
|
501
|
-
|
|
1120
|
+
// Bounded derived snapshots, containing only rows in recently read windows.
|
|
1121
|
+
readSnapshots = new Map();
|
|
1122
|
+
liveQuerySnapshot(signature) {
|
|
1123
|
+
let snapshot = this.readSnapshots.get(signature);
|
|
1124
|
+
if (!snapshot) {
|
|
1125
|
+
if (this.readSnapshots.size >= 64) {
|
|
1126
|
+
const cold = [...this.readSnapshots.keys()].find(key => !this.retainedWindows.has(key));
|
|
1127
|
+
if (cold !== undefined)
|
|
1128
|
+
this.readSnapshots.delete(cold);
|
|
1129
|
+
}
|
|
1130
|
+
snapshot = { version: -1, rowsVersion: -1, rows: [], cache: new Map() };
|
|
1131
|
+
}
|
|
1132
|
+
// Mounted windows cannot be evicted. Reordering them on every store read
|
|
1133
|
+
// only allocates Map tombstones; LRU order matters for cold windows alone.
|
|
1134
|
+
if (!this.retainedWindows.has(signature))
|
|
1135
|
+
this.readSnapshots.delete(signature);
|
|
1136
|
+
this.readSnapshots.set(signature, snapshot);
|
|
1137
|
+
if (snapshot.version !== this.windowVersion(signature) || snapshot.freshness !== this.freshnessValue) {
|
|
1138
|
+
const rowsVersion = this.windowRowsVersion(signature);
|
|
1139
|
+
if (snapshot.rowsVersion !== rowsVersion) {
|
|
1140
|
+
const rows = this.watchRows(signature, snapshot.cache);
|
|
1141
|
+
if (rows.length !== snapshot.rows.length || rows.some((row, index) => row !== snapshot.rows[index]))
|
|
1142
|
+
snapshot.rows = rows;
|
|
1143
|
+
snapshot.rowsVersion = rowsVersion;
|
|
1144
|
+
}
|
|
1145
|
+
const next = this.liveQuery(signature, snapshot.rows);
|
|
1146
|
+
const previous = snapshot.result;
|
|
1147
|
+
if (!previous || previous.rows !== next.rows || !sameReplicaValue({ ...previous, rows: undefined }, { ...next, rows: undefined }))
|
|
1148
|
+
snapshot.result = next;
|
|
1149
|
+
snapshot.version = this.windowVersion(signature);
|
|
1150
|
+
snapshot.freshness = this.freshnessValue;
|
|
1151
|
+
}
|
|
1152
|
+
return snapshot.result;
|
|
1153
|
+
}
|
|
1154
|
+
liveQuery(signature, cachedRows) {
|
|
1155
|
+
return this.windowResult(signature, cachedRows);
|
|
1156
|
+
}
|
|
1157
|
+
windowResult(signature, cachedRows) {
|
|
502
1158
|
if (!this.scopeLoaded) {
|
|
503
1159
|
return { rows: [], ids: [], source: "cache", completeness: "partial", freshness: this.freshnessValue };
|
|
504
1160
|
}
|
|
@@ -506,8 +1162,8 @@ export class LocalReplica {
|
|
|
506
1162
|
if (!membership) {
|
|
507
1163
|
return { rows: [], ids: [], source: "cache", completeness: "partial", freshness: this.freshnessValue };
|
|
508
1164
|
}
|
|
509
|
-
const ids = this.effectiveMembership(membership);
|
|
510
|
-
const rows = ids
|
|
1165
|
+
const ids = cachedRows ? cachedRows.map(row => String(row[membership.key])) : this.effectiveMembership(membership);
|
|
1166
|
+
const rows = cachedRows ?? ids
|
|
511
1167
|
.map((id) => this.entity(membership.entity, id))
|
|
512
1168
|
.filter((row) => row !== undefined);
|
|
513
1169
|
const metadata = windowResultMetadata(membership.resultSkeleton, membership.resultPath);
|
|
@@ -516,13 +1172,13 @@ export class LocalReplica {
|
|
|
516
1172
|
ids,
|
|
517
1173
|
...metadata,
|
|
518
1174
|
source: this.freshnessValue === "current" ? membership.source : "cache",
|
|
519
|
-
completeness: membership.completeness,
|
|
1175
|
+
completeness: membership.ids.every(id => this.entities.get(membership.entity)?.has(id)) ? membership.completeness : 'partial',
|
|
520
1176
|
freshness: this.freshnessValue,
|
|
521
1177
|
};
|
|
522
1178
|
}
|
|
523
1179
|
/** Rows and protocol-owned completeness for one Replica Collection window. */
|
|
524
|
-
collectionState(signature) {
|
|
525
|
-
const result = this.
|
|
1180
|
+
collectionState(signature, cachedRows) {
|
|
1181
|
+
const result = this.windowResult(signature, cachedRows);
|
|
526
1182
|
const window = this.scopeLoaded ? this.liveQueries.get(signature) : undefined;
|
|
527
1183
|
return {
|
|
528
1184
|
...result,
|
|
@@ -556,24 +1212,45 @@ export class LocalReplica {
|
|
|
556
1212
|
this.persistence = attempt.catch(() => undefined);
|
|
557
1213
|
return attempt;
|
|
558
1214
|
}
|
|
559
|
-
notify() {
|
|
1215
|
+
notify(executionChanged = false) {
|
|
1216
|
+
if (this.disposed)
|
|
1217
|
+
return;
|
|
1218
|
+
if (executionChanged)
|
|
1219
|
+
this.trimResidentRows();
|
|
1220
|
+
if (executionChanged)
|
|
1221
|
+
this.executionVersionValue += 1;
|
|
560
1222
|
this.versionValue += 1;
|
|
561
1223
|
for (const listener of [...this.listeners])
|
|
562
1224
|
listener();
|
|
563
1225
|
}
|
|
564
|
-
|
|
1226
|
+
markEntityChanged(entity, ids) {
|
|
1227
|
+
const version = ++this.entityVersionClock;
|
|
1228
|
+
this.entityVersions.set(entity, version);
|
|
1229
|
+
for (const id of ids ?? this.entities.get(entity)?.keys() ?? [])
|
|
1230
|
+
this.rowVersions.set(`${entity}\0${id}`, version);
|
|
1231
|
+
}
|
|
1232
|
+
invalidateEntityVersions() {
|
|
1233
|
+
this.entityVersionFloor = ++this.entityVersionClock;
|
|
1234
|
+
this.entityVersions.clear();
|
|
1235
|
+
this.rowVersions.clear();
|
|
1236
|
+
this.readSnapshots.clear();
|
|
1237
|
+
}
|
|
1238
|
+
markWindowChanged(signature, rowsChanged = true) {
|
|
565
1239
|
this.windowVersionClock += 1;
|
|
566
1240
|
this.windowVersions.set(signature, this.windowVersionClock);
|
|
1241
|
+
if (rowsChanged)
|
|
1242
|
+
this.windowRowsVersions.set(signature, this.windowVersionClock);
|
|
567
1243
|
}
|
|
568
1244
|
markWindowsForPatches(patches) {
|
|
569
1245
|
const affected = new Set();
|
|
570
1246
|
for (const patch of patches) {
|
|
571
1247
|
const entity = patch.entity ?? patch.collection;
|
|
1248
|
+
if (entity)
|
|
1249
|
+
this.markEntityChanged(entity, [patch.rowId]);
|
|
572
1250
|
for (const [signature, window] of this.liveQueries) {
|
|
573
1251
|
if (window.entity !== entity)
|
|
574
1252
|
continue;
|
|
575
|
-
|
|
576
|
-
if (window.ids.includes(patch.rowId) || currentIDs.includes(patch.rowId)) {
|
|
1253
|
+
if (window.ids.includes(patch.rowId)) {
|
|
577
1254
|
affected.add(signature);
|
|
578
1255
|
continue;
|
|
579
1256
|
}
|
|
@@ -593,7 +1270,7 @@ export class LocalReplica {
|
|
|
593
1270
|
* the committed server membership, so rejection and reconciliation are
|
|
594
1271
|
* reversible and never write optimistic IDs to storage.
|
|
595
1272
|
*/
|
|
596
|
-
effectiveMembership(window) {
|
|
1273
|
+
effectiveMembership(window, read = id => this.entity(window.entity, id)) {
|
|
597
1274
|
const plan = this.replicaPlans.get(window.signature);
|
|
598
1275
|
const patches = [...this.pendingCommands.values()]
|
|
599
1276
|
.flatMap((command) => command.patches)
|
|
@@ -609,7 +1286,7 @@ export class LocalReplica {
|
|
|
609
1286
|
const ids = new Set(window.ids);
|
|
610
1287
|
const touched = new Set(patches.map((patch) => patch.rowId));
|
|
611
1288
|
for (const id of touched) {
|
|
612
|
-
const row =
|
|
1289
|
+
const row = read(id);
|
|
613
1290
|
if (row && replicaRowMatchesPlan(row, plan.definition, plan.args))
|
|
614
1291
|
ids.add(id);
|
|
615
1292
|
else
|
|
@@ -625,7 +1302,9 @@ export class LocalReplica {
|
|
|
625
1302
|
const orderBy = plan.definition.orderBy;
|
|
626
1303
|
if (!orderBy)
|
|
627
1304
|
return [...retained, ...additions.sort(compareReplicaKeys)];
|
|
628
|
-
|
|
1305
|
+
// The comparator may run O(n log n) times. Resolve each immutable row once.
|
|
1306
|
+
const rows = new Map(effectiveIDs.map(id => [id, read(id)]));
|
|
1307
|
+
return effectiveIDs.sort((left, right) => compareReplicaMembershipRows(rows.get(left), rows.get(right), window.key, orderBy, plan.definition.orderDirection));
|
|
629
1308
|
}
|
|
630
1309
|
trackWindowOwnership(window, rows, previous) {
|
|
631
1310
|
const owned = this.windowOwned.get(window.signature) ?? new Map();
|
|
@@ -651,9 +1330,14 @@ export class LocalReplica {
|
|
|
651
1330
|
if (!owned)
|
|
652
1331
|
return;
|
|
653
1332
|
const sourceWindow = this.liveQueries.get(signature);
|
|
1333
|
+
const referenced = new Set();
|
|
1334
|
+
for (const window of this.liveQueries.values()) {
|
|
1335
|
+
if (window.entity === sourceWindow?.entity)
|
|
1336
|
+
for (const id of window.ids)
|
|
1337
|
+
referenced.add(id);
|
|
1338
|
+
}
|
|
654
1339
|
for (const [id] of owned) {
|
|
655
|
-
|
|
656
|
-
if (!stillReferenced && sourceWindow) {
|
|
1340
|
+
if (!referenced.has(id) && sourceWindow) {
|
|
657
1341
|
this.entities.get(sourceWindow.entity)?.delete(id);
|
|
658
1342
|
}
|
|
659
1343
|
}
|
|
@@ -675,6 +1359,13 @@ export class LocalReplica {
|
|
|
675
1359
|
this.windowOwned.delete(signature);
|
|
676
1360
|
}
|
|
677
1361
|
}
|
|
1362
|
+
function sameWindowRowDefinition(previous, next) {
|
|
1363
|
+
return previous !== undefined && previous.entity === next.entity && previous.key === next.key
|
|
1364
|
+
&& previous.kind === next.kind && previous.completeness === next.completeness
|
|
1365
|
+
&& previous.truncated === next.truncated && previous.orderBy === next.orderBy
|
|
1366
|
+
&& previous.orderDirection === next.orderDirection && previous.ids.length === next.ids.length
|
|
1367
|
+
&& previous.ids.every((id, index) => id === next.ids[index]);
|
|
1368
|
+
}
|
|
678
1369
|
function replicaRowMatchesPlan(row, definition, args) {
|
|
679
1370
|
// The module manifest encodes equalFilters as argument name -> row column
|
|
680
1371
|
// (for example `{ id: "_id" }`). Keep this aligned with artifact parsing.
|
|
@@ -710,6 +1401,14 @@ function replicaOrderValue(value) {
|
|
|
710
1401
|
return typeof value === "string" || typeof value === "number" ? value : null;
|
|
711
1402
|
}
|
|
712
1403
|
export class MemoryLocalReplicaStorage {
|
|
1404
|
+
sessions = new Map();
|
|
1405
|
+
async loadSession(scope) { const value = this.sessions.get(scope); return value ? structuredClone(value) : undefined; }
|
|
1406
|
+
async saveSession(scope, value) {
|
|
1407
|
+
if (value)
|
|
1408
|
+
this.sessions.set(scope, structuredClone(value));
|
|
1409
|
+
else
|
|
1410
|
+
this.sessions.delete(scope);
|
|
1411
|
+
}
|
|
713
1412
|
values = new Map();
|
|
714
1413
|
async load(scope = defaultReplicaScope) {
|
|
715
1414
|
const value = this.values.get(normalizeReplicaScope(scope));
|
|
@@ -763,6 +1462,19 @@ function cloneOptimisticPatch(patch) {
|
|
|
763
1462
|
return { ...patch, fields: structuredClone(patch.fields) };
|
|
764
1463
|
return { ...patch, fields: structuredClone(patch.fields) };
|
|
765
1464
|
}
|
|
1465
|
+
function sameReplicaValue(left, right) {
|
|
1466
|
+
if (left === right)
|
|
1467
|
+
return true;
|
|
1468
|
+
if (!left || !right || typeof left !== "object" || typeof right !== "object")
|
|
1469
|
+
return false;
|
|
1470
|
+
if (Array.isArray(left))
|
|
1471
|
+
return Array.isArray(right) && left.length === right.length && left.every((value, index) => sameReplicaValue(value, right[index]));
|
|
1472
|
+
if (Array.isArray(right))
|
|
1473
|
+
return false;
|
|
1474
|
+
const a = left, b = right;
|
|
1475
|
+
const keys = Object.keys(a);
|
|
1476
|
+
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameReplicaValue(a[key], b[key]));
|
|
1477
|
+
}
|
|
766
1478
|
function cloneRow(row) { return structuredClone(row); }
|
|
767
1479
|
function windowResultMetadata(result, resultPath) {
|
|
768
1480
|
if (!result || typeof result !== "object" || Array.isArray(result) || !resultPath?.length)
|
|
@@ -833,9 +1545,6 @@ function hydratedTransactionFloor(persisted, windows) {
|
|
|
833
1545
|
// unsafe floor across a browser restart.
|
|
834
1546
|
return { epoch, revision: Math.min(persisted.revision, provenRevision) };
|
|
835
1547
|
}
|
|
836
|
-
function cloneEntities(source) {
|
|
837
|
-
return new Map([...source].map(([entity, rows]) => [entity, new Map([...rows].map(([id, row]) => [id, cloneRow(row)]))]));
|
|
838
|
-
}
|
|
839
1548
|
function entitiesFromSnapshot(source) {
|
|
840
1549
|
return new Map(Object.entries(source).map(([entity, rows]) => [entity, new Map(Object.entries(rows).map(([id, row]) => [id, cloneRow(row)]))]));
|
|
841
1550
|
}
|
|
@@ -854,4 +1563,46 @@ function normalizeReplicaScope(scope) {
|
|
|
854
1563
|
return defaultReplicaScope;
|
|
855
1564
|
return scope;
|
|
856
1565
|
}
|
|
1566
|
+
/** Build snapshot accessors only if an adapter needs them. Metadata-only writes
|
|
1567
|
+
* never enumerate tables/windows; delta adapters clone values on first access. */
|
|
1568
|
+
function storageSnapshotFrom(cursor, source, windows) {
|
|
1569
|
+
// Capture membership now even if an adapter reads its snapshot after the
|
|
1570
|
+
// persistence promise resolves and reconciliation replaces map entries.
|
|
1571
|
+
const capturedSource = new Map(source);
|
|
1572
|
+
const capturedWindows = new Map(windows);
|
|
1573
|
+
const snapshot = { cursor: cursor ? { ...cursor } : undefined };
|
|
1574
|
+
Object.defineProperty(snapshot, "entities", { enumerable: true, configurable: true, get() {
|
|
1575
|
+
const entities = {};
|
|
1576
|
+
for (const [entity, rows] of capturedSource) {
|
|
1577
|
+
// Committed maps and rows are immutable. Retain that version without
|
|
1578
|
+
// allocating entries for unrelated tables; clone values on first access.
|
|
1579
|
+
const captured = rows;
|
|
1580
|
+
Object.defineProperty(entities, entity, { enumerable: true, configurable: true, get() {
|
|
1581
|
+
const value = {};
|
|
1582
|
+
for (const [id, row] of captured)
|
|
1583
|
+
Object.defineProperty(value, id, { enumerable: true, configurable: true, get() {
|
|
1584
|
+
const copy = cloneRow(row);
|
|
1585
|
+
Object.defineProperty(value, id, { enumerable: true, configurable: true, writable: true, value: copy });
|
|
1586
|
+
return copy;
|
|
1587
|
+
} });
|
|
1588
|
+
Object.defineProperty(entities, entity, { enumerable: true, configurable: true, writable: true, value });
|
|
1589
|
+
return value;
|
|
1590
|
+
} });
|
|
1591
|
+
}
|
|
1592
|
+
Object.defineProperty(snapshot, "entities", { enumerable: true, configurable: true, writable: true, value: entities });
|
|
1593
|
+
return entities;
|
|
1594
|
+
} });
|
|
1595
|
+
Object.defineProperty(snapshot, "liveQueries", { enumerable: true, configurable: true, get() {
|
|
1596
|
+
const liveQueries = {};
|
|
1597
|
+
for (const [key, window] of capturedWindows)
|
|
1598
|
+
Object.defineProperty(liveQueries, key, { enumerable: true, configurable: true, get() {
|
|
1599
|
+
const copy = cloneWindow(window);
|
|
1600
|
+
Object.defineProperty(liveQueries, key, { enumerable: true, configurable: true, writable: true, value: copy });
|
|
1601
|
+
return copy;
|
|
1602
|
+
} });
|
|
1603
|
+
Object.defineProperty(snapshot, "liveQueries", { enumerable: true, configurable: true, writable: true, value: liveQueries });
|
|
1604
|
+
return liveQueries;
|
|
1605
|
+
} });
|
|
1606
|
+
return snapshot;
|
|
1607
|
+
}
|
|
857
1608
|
//# sourceMappingURL=local-replica.js.map
|