@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.
Files changed (38) hide show
  1. package/README.md +68 -2
  2. package/dist/browser-upgrades.d.ts +19 -0
  3. package/dist/browser-upgrades.js +157 -0
  4. package/dist/browser-upgrades.js.map +1 -0
  5. package/dist/client-upgrades.d.ts +26 -0
  6. package/dist/client-upgrades.js +41 -0
  7. package/dist/client-upgrades.js.map +1 -0
  8. package/dist/control.d.ts +2 -0
  9. package/dist/control.js +1 -0
  10. package/dist/control.js.map +1 -1
  11. package/dist/index.d.ts +348 -9
  12. package/dist/index.js +1576 -192
  13. package/dist/index.js.map +1 -1
  14. package/dist/indexeddb-read-view.d.ts +24 -0
  15. package/dist/indexeddb-read-view.js +164 -0
  16. package/dist/indexeddb-read-view.js.map +1 -0
  17. package/dist/indexeddb-replica.d.ts +41 -3
  18. package/dist/indexeddb-replica.js +405 -111
  19. package/dist/indexeddb-replica.js.map +1 -1
  20. package/dist/local-replica.d.ts +127 -4
  21. package/dist/local-replica.js +836 -85
  22. package/dist/local-replica.js.map +1 -1
  23. package/dist/outbox.d.ts +118 -10
  24. package/dist/outbox.js +391 -89
  25. package/dist/outbox.js.map +1 -1
  26. package/dist/paged-read-view.d.ts +13 -0
  27. package/dist/paged-read-view.js +60 -0
  28. package/dist/paged-read-view.js.map +1 -0
  29. package/dist/query-expression.d.ts +39 -2
  30. package/dist/query-expression.js +106 -5
  31. package/dist/query-expression.js.map +1 -1
  32. package/dist/replica-integrity.d.ts +2 -0
  33. package/dist/replica-integrity.js +10 -0
  34. package/dist/replica-integrity.js.map +1 -1
  35. package/dist/replica-record.d.ts +14 -0
  36. package/dist/replica-record.js +46 -0
  37. package/dist/replica-record.js.map +1 -0
  38. package/package.json +4 -2
@@ -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.notify();
131
+ if (wasLoaded) {
132
+ this.invalidateEntityVersions();
133
+ this.notify(true);
134
+ }
53
135
  const generation = ++this.scopeActivationGeneration;
54
- const activation = this.application.then(async () => {
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 = await this.storage?.load(nextScope);
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.application.then(() => this.applyTransactionNow(transaction, requestedScope));
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.application.then(() => this.materializeWindowNow(input));
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.application.then(async () => {
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) => input.upserts.find((row) => String(row[input.key]) === id) ?? this.entities.get(input.entity)?.get(id))
170
- .filter((row) => row !== undefined)
171
- .map(cloneRow);
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.application.then(async () => {
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([...this.liveQueries.entries()].map(([signature, window]) => [signature, cloneWindow(window)]));
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
- window.cursor = { ...window.cursor, revision };
205
- changedWindows.push(window);
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 = snapshotFrom(nextCursor, this.entities, nextQueries);
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 = snapshotFrom(nextCursor, this.entities, nextQueries);
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.application.then(async () => {
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 = snapshotFrom(this.cursorValue, this.entities, nextQueries);
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.notify();
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.application.then(async () => {
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
- const nextEntities = cloneEntities(this.entities);
316
- // Clone memberships before changing them. Persistence may be asynchronous,
317
- // and readers must continue to observe the prior complete version until the
318
- // transaction commits locally and the single state swap below runs.
319
- const nextQueries = new Map([...this.liveQueries.entries()].map(([signature, window]) => [signature, cloneWindow(window)]));
320
- if (input.cursor && this.cursorValue && input.cursor.epoch !== this.cursorValue.epoch) {
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) ?? new Map();
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
- entityRows.set(id, { ...(entityRows.get(id) ?? {}), ...cloneRow(row) });
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
- nextEntities.get(input.entity)?.delete(id);
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 = snapshotFrom(nextCursor, nextEntities, nextQueries);
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?.replaceWindow) {
378
- await this.persist(() => this.storage.replaceWindow(window, snapshot, writeScope));
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
- this.markWindowChanged(input.signature);
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 = cloneEntities(this.entities);
400
- // A transaction is invisible until its storage write succeeds. Deep-clone
401
- // memberships because row deletion edits their ordered ID arrays.
402
- const nextQueries = new Map([...this.liveQueries.entries()].map(([signature, window]) => [signature, cloneWindow(window)]));
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
- if (this.cursorValue && this.cursorValue.epoch !== transaction.cursor.epoch) {
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
- const rows = nextEntities.get(change.entity) ?? new Map();
417
- nextEntities.set(change.entity, rows);
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.ids = window.ids.filter((id) => id !== change.id);
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 = snapshotFrom(transaction.cursor, nextEntities, nextQueries);
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
- return ids.map((id) => this.entity(entity, id));
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
- const ids = new Set(this.entities.get(entity)?.keys() ?? []);
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) === entity)
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 [...ids]
489
- .map((id) => this.entity(entity, id))
490
- .filter((row) => row !== undefined);
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
- liveQuery(signature) {
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.liveQuery(signature);
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
- markWindowChanged(signature) {
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
- const currentIDs = this.effectiveMembership(window);
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 = this.entity(window.entity, id);
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
- return effectiveIDs.sort((left, right) => compareReplicaMembershipRows(this.entity(window.entity, left), this.entity(window.entity, right), window.key, orderBy, plan.definition.orderDirection));
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
- const stillReferenced = [...this.liveQueries.values()].some((window) => (window.entity === sourceWindow?.entity && window.ids.includes(id)));
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