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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,18 +450,30 @@ export class LocalReplica {
103
450
  if (!commandId)
104
451
  throw new Error("optimistic commandId is required");
105
452
  const clonedPatches = patches.map(cloneOptimisticPatch);
453
+ const previous = this.pendingCommands.get(commandId);
454
+ if (previous && sameReplicaValue(previous.patches, clonedPatches))
455
+ return;
456
+ if (previous)
457
+ this.markWindowsForPatches(previous.patches);
106
458
  this.pendingCommands.set(commandId, { commandId, patches: clonedPatches });
107
459
  this.markWindowsForPatches(clonedPatches);
108
460
  this.notify();
109
461
  }
110
462
  /** SDK-only atomic replacement after ordered local reducer replay. */
111
463
  replaceOptimistic(commands) {
464
+ // Rebase is also triggered by unrelated authoritative updates. An identical
465
+ // overlay must not dirty every selector or schedule another application render.
466
+ const previous = [...this.pendingCommands.values()];
467
+ if (previous.length === commands.length && commands.every((command, index) => command.commandId === previous[index].commandId &&
468
+ JSON.stringify(command.patches) === JSON.stringify(previous[index].patches)))
469
+ return;
112
470
  for (const command of this.pendingCommands.values())
113
471
  this.markWindowsForPatches(command.patches);
472
+ const previousCommands = new Map(this.pendingCommands);
114
473
  this.pendingCommands.clear();
115
474
  for (const command of commands) {
116
475
  const patches = command.patches.map(cloneOptimisticPatch);
117
- this.pendingCommands.set(command.commandId, { commandId: command.commandId, patches });
476
+ this.pendingCommands.set(command.commandId, { commandId: command.commandId, patches, committedRevision: previousCommands.get(command.commandId)?.committedRevision });
118
477
  this.markWindowsForPatches(patches);
119
478
  }
120
479
  this.notify();
@@ -152,13 +511,11 @@ export class LocalReplica {
152
511
  }
153
512
  applyTransaction(transaction, scope) {
154
513
  const requestedScope = scope === undefined ? undefined : normalizeReplicaScope(scope);
155
- const application = this.application.then(() => this.applyTransactionNow(transaction, requestedScope));
156
- this.application = application.catch(() => undefined);
514
+ const application = this.enqueueApplication(() => this.applyTransactionNow(transaction, requestedScope));
157
515
  return application;
158
516
  }
159
517
  materializeWindow(input) {
160
- const application = this.application.then(() => this.materializeWindowNow(input));
161
- this.application = application.catch(() => undefined);
518
+ const application = this.enqueueApplication(() => this.materializeWindowNow(input));
162
519
  return application;
163
520
  }
164
521
  /** Replace one server/cache window while retaining shared normalized entities. */
@@ -167,7 +524,7 @@ export class LocalReplica {
167
524
  }
168
525
  /** Apply a bounded window delta and persist it in the same transaction. */
169
526
  applyWindowDelta(input) {
170
- const application = this.application.then(async () => {
527
+ const application = this.enqueueApplication(async () => {
171
528
  const existing = this.liveQueries.get(input.signature);
172
529
  const deleted = new Set(input.deleted.map(String));
173
530
  const ids = (existing?.ids ?? []).filter((id) => !deleted.has(id));
@@ -177,19 +534,24 @@ export class LocalReplica {
177
534
  if (id && !ids.includes(id))
178
535
  ids.push(id);
179
536
  }
537
+ const upserts = new Map(input.upserts.map(row => [String(row[input.key]), row]));
180
538
  const rows = ids
181
- .map((id) => input.upserts.find((row) => String(row[input.key]) === id) ?? this.entities.get(input.entity)?.get(id))
182
- .filter((row) => row !== undefined)
183
- .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
+ }
184
547
  await this.materializeWindowNow({
185
548
  ...input,
186
549
  rows,
187
550
  completeness: input.completeness ?? existing?.completeness ?? "partial",
188
551
  source: input.source ?? existing?.source ?? "server",
189
552
  removedIDs: [...deleted],
190
- });
553
+ }, { upserts: input.upserts, deleted: [...deleted] });
191
554
  });
192
- this.application = application.catch(() => undefined);
193
555
  return application;
194
556
  }
195
557
  /**
@@ -200,21 +562,22 @@ export class LocalReplica {
200
562
  */
201
563
  advanceWatermark(revision, signatures = [], scope) {
202
564
  const requestedScope = scope === undefined ? undefined : normalizeReplicaScope(scope);
203
- const application = this.application.then(async () => {
565
+ const application = this.enqueueApplication(async () => {
204
566
  if (requestedScope !== undefined && requestedScope !== this.scopeValue)
205
567
  return;
206
568
  if (!Number.isSafeInteger(revision) || revision < 0 || signatures.length === 0)
207
569
  return;
208
570
  const eligible = new Set(signatures);
209
- const nextQueries = new Map([...this.liveQueries.entries()].map(([signature, window]) => [signature, cloneWindow(window)]));
571
+ const nextQueries = new Map(this.liveQueries);
210
572
  const changedWindows = [];
211
573
  for (const [signature, window] of nextQueries) {
212
574
  if (!eligible.has(signature) || window.kind !== "replica" || !window.cursor)
213
575
  continue;
214
576
  if (window.cursor.revision >= revision)
215
577
  continue;
216
- window.cursor = { ...window.cursor, revision };
217
- changedWindows.push(window);
578
+ const advanced = { ...window, cursor: { ...window.cursor, revision } };
579
+ nextQueries.set(signature, advanced);
580
+ changedWindows.push(advanced);
218
581
  }
219
582
  if (changedWindows.length === 0)
220
583
  return;
@@ -228,7 +591,7 @@ export class LocalReplica {
228
591
  else if (this.storage?.replaceSnapshot) {
229
592
  // Compatibility for older/custom storage adapters. Current IndexedDB
230
593
  // and SQLite adapters implement the metadata-only operation above.
231
- const snapshot = snapshotFrom(nextCursor, this.entities, nextQueries);
594
+ const snapshot = storageSnapshotFrom(nextCursor, this.entities, nextQueries);
232
595
  await this.persist(() => this.storage.replaceSnapshot(snapshot, writeScope));
233
596
  }
234
597
  else if (this.storage?.replaceWindow) {
@@ -236,18 +599,17 @@ export class LocalReplica {
236
599
  // durability for those adapters by reusing one computed snapshot;
237
600
  // this path is intentionally serial and is not used by the bundled
238
601
  // normalized adapters.
239
- const snapshot = snapshotFrom(nextCursor, this.entities, nextQueries);
602
+ const snapshot = storageSnapshotFrom(nextCursor, this.entities, nextQueries);
240
603
  for (const window of changedWindows) {
241
604
  await this.persist(() => this.storage.replaceWindow(window, snapshot, writeScope));
242
605
  }
243
606
  }
244
607
  this.liveQueries = nextQueries;
245
608
  for (const window of changedWindows)
246
- this.markWindowChanged(window.signature);
609
+ this.markWindowChanged(window.signature, false);
247
610
  this.cursorValue = nextCursor;
248
611
  this.notify();
249
612
  });
250
- this.application = application.catch(() => undefined);
251
613
  return application;
252
614
  }
253
615
  getWindow(signature) {
@@ -277,14 +639,15 @@ export class LocalReplica {
277
639
  .map(cloneRow);
278
640
  }
279
641
  removeWindow(signature, scope) {
280
- const application = this.application.then(async () => {
642
+ const application = this.enqueueApplication(async () => {
281
643
  if (scope !== undefined && normalizeReplicaScope(scope) !== this.scopeValue)
282
644
  return;
283
645
  if (!this.liveQueries.has(signature))
284
646
  return;
285
647
  const nextQueries = new Map(this.liveQueries);
648
+ const removed = nextQueries.get(signature);
286
649
  nextQueries.delete(signature);
287
- const snapshot = snapshotFrom(this.cursorValue, this.entities, nextQueries);
650
+ const snapshot = storageSnapshotFrom(this.cursorValue, this.entities, nextQueries);
288
651
  if (this.storage?.removeWindow) {
289
652
  await this.persist(() => this.storage.removeWindow(signature, snapshot, this.scopeValue));
290
653
  }
@@ -294,59 +657,67 @@ export class LocalReplica {
294
657
  this.liveQueries = nextQueries;
295
658
  this.markWindowChanged(signature);
296
659
  this.pruneOwnedEntitiesAfterRemoval(signature, nextQueries);
297
- this.notify();
660
+ this.markEntityChanged(removed.entity);
661
+ this.notify(true);
298
662
  });
299
- this.application = application.catch(() => undefined);
300
663
  return application;
301
664
  }
302
665
  clear(scope) {
303
- const application = this.application.then(async () => {
666
+ const application = this.enqueueApplication(async () => {
304
667
  if (scope !== undefined && normalizeReplicaScope(scope) !== this.scopeValue)
305
668
  return;
306
669
  if (this.storage?.clear)
307
670
  await this.persist(() => this.storage.clear(this.scopeValue));
308
671
  this.cursorValue = undefined;
309
672
  this.entities.clear();
673
+ this.invalidateEntityVersions();
310
674
  for (const signature of this.liveQueries.keys())
311
675
  this.markWindowChanged(signature);
312
676
  this.liveQueries.clear();
313
677
  this.windowOwned.clear();
314
678
  this.pendingCommands.clear();
315
679
  this.freshnessValue = "verifying";
316
- this.notify();
680
+ this.notify(true);
317
681
  });
318
- this.application = application.catch(() => undefined);
319
682
  return application;
320
683
  }
321
- async materializeWindowNow(input) {
684
+ async materializeWindowNow(input, delta) {
322
685
  if (input.scope !== undefined && normalizeReplicaScope(input.scope) !== this.scopeValue)
323
686
  return;
324
687
  if (!input.signature.trim() || !input.entity.trim() || !input.key.trim()) {
325
688
  throw new Error("replica materialization requires signature, entity, and key");
326
689
  }
327
- const nextEntities = cloneEntities(this.entities);
328
- // Clone memberships before changing them. Persistence may be asynchronous,
329
- // and readers must continue to observe the prior complete version until the
330
- // transaction commits locally and the single state swap below runs.
331
- const nextQueries = new Map([...this.liveQueries.entries()].map(([signature, window]) => [signature, cloneWindow(window)]));
332
- 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) {
333
696
  nextEntities.clear();
334
697
  nextQueries.clear();
335
698
  }
336
- const entityRows = nextEntities.get(input.entity) ?? new Map();
699
+ const entityRows = new Map(nextEntities.get(input.entity));
337
700
  nextEntities.set(input.entity, entityRows);
338
701
  const ids = [];
702
+ const changedRowIDs = new Set();
703
+ const changedIDs = delta && !epochChanged ? new Set(delta.upserts.map(row => String(row[input.key]))) : undefined;
339
704
  for (const row of input.rows) {
340
705
  const rawID = row[input.key];
341
706
  const id = typeof rawID === "string" || typeof rawID === "number" ? String(rawID) : "";
342
707
  if (!id)
343
708
  continue;
344
709
  ids.push(id);
710
+ if (changedIDs && !changedIDs.has(id))
711
+ continue;
345
712
  // Different Replica Collections may project different columns from the
346
713
  // same table. They all hydrate one normalized entity, so a narrow
347
714
  // projection must update the fields it owns without erasing fields
348
715
  // supplied by another collection.
349
- 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);
350
721
  }
351
722
  const previous = nextQueries.get(input.signature);
352
723
  const window = {
@@ -374,8 +745,8 @@ export class LocalReplica {
374
745
  nextQueries.set(input.signature, window);
375
746
  for (const id of input.removedIDs ?? []) {
376
747
  const stillReferenced = [...nextQueries.values()].some((candidate) => candidate.ids.includes(id));
377
- if (!stillReferenced)
378
- nextEntities.get(input.entity)?.delete(id);
748
+ if (!stillReferenced && nextEntities.get(input.entity)?.delete(id))
749
+ changedRowIDs.add(id);
379
750
  }
380
751
  this.trackWindowOwnership(window, input.rows, previous);
381
752
  // A collection cursor proves only that collection's materialized rows.
@@ -384,22 +755,41 @@ export class LocalReplica {
384
755
  // collection. The only revision proven by snapshots alone is the minimum
385
756
  // cursor shared by every materialized Replica window in the epoch.
386
757
  const nextCursor = replicaTransactionFloor(this.cursorValue, nextQueries);
387
- const snapshot = snapshotFrom(nextCursor, nextEntities, nextQueries);
758
+ const snapshot = storageSnapshotFrom(nextCursor, nextEntities, nextQueries);
759
+ const membershipChanged = !sameWindowRowDefinition(previous, window);
760
+ const rowsChanged = membershipChanged || changedRowIDs.size > 0;
388
761
  const writeScope = this.scopeValue;
389
- if (this.storage?.replaceWindow) {
390
- 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));
391
768
  }
392
769
  else if (this.storage?.replaceSnapshot) {
393
770
  await this.persist(() => this.storage.replaceSnapshot(snapshot, writeScope));
394
771
  }
772
+ if (epochChanged)
773
+ this.invalidateEntityVersions();
774
+ if (this.storage?.readChanges)
775
+ await this.mergeStoredChanges(nextEntities, nextQueries);
395
776
  this.entities = nextEntities;
396
777
  this.liveQueries = nextQueries;
397
- 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);
398
788
  this.pruneOwnedEntities(input.signature);
399
789
  this.cursorValue = nextCursor;
400
790
  if (input.source === "server")
401
791
  this.freshnessValue = "current";
402
- this.notify();
792
+ this.notify(true);
403
793
  }
404
794
  async applyTransactionNow(transaction, scope) {
405
795
  if (scope !== undefined && scope !== this.scopeValue)
@@ -408,47 +798,60 @@ export class LocalReplica {
408
798
  if (this.cursorValue?.epoch === transaction.cursor.epoch && transaction.cursor.revision <= this.cursorValue.revision) {
409
799
  return;
410
800
  }
411
- const nextEntities = cloneEntities(this.entities);
412
- // A transaction is invisible until its storage write succeeds. Deep-clone
413
- // memberships because row deletion edits their ordered ID arrays.
414
- 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);
415
805
  const changedWindows = new Set();
416
- if (this.cursorValue && this.cursorValue.epoch !== transaction.cursor.epoch) {
806
+ const epochChanged = Boolean(this.cursorValue && this.cursorValue.epoch !== transaction.cursor.epoch);
807
+ if (epochChanged) {
417
808
  for (const signature of nextQueries.keys())
418
809
  changedWindows.add(signature);
419
810
  nextEntities.clear();
420
811
  nextQueries.clear();
421
- this.windowOwned.clear();
422
812
  }
423
813
  for (const change of transaction.changes) {
424
814
  for (const [signature, window] of nextQueries) {
425
815
  if (window.entity === change.entity && window.ids.includes(change.id))
426
816
  changedWindows.add(signature);
427
817
  }
428
- const rows = nextEntities.get(change.entity) ?? new Map();
429
- 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);
430
823
  if (change.operation === "delete") {
431
824
  rows.delete(change.id);
432
825
  for (const window of nextQueries.values()) {
433
826
  if (window.entity === change.entity && window.ids.includes(change.id)) {
434
- window.ids = window.ids.filter((id) => id !== change.id);
827
+ nextQueries.set(window.signature, { ...window, ids: window.ids.filter((id) => id !== change.id) });
435
828
  }
436
829
  }
437
830
  }
831
+ // Change-feed rows are projections, just like collection snapshots.
832
+ // A narrow subscription must not erase fields supplied by another one.
438
833
  else if (change.newValue)
439
- rows.set(change.id, cloneRow(change.newValue));
834
+ rows.set(change.id, { ...rows.get(change.id), ...cloneRow(change.newValue) });
440
835
  }
441
836
  for (const membership of transaction.memberships ?? []) {
442
837
  nextQueries.set(membership.signature, normalizeWindow(membership));
443
838
  changedWindows.add(membership.signature);
444
839
  }
445
- const snapshot = snapshotFrom(transaction.cursor, nextEntities, nextQueries);
840
+ const snapshot = storageSnapshotFrom(transaction.cursor, nextEntities, nextQueries);
446
841
  const writeScope = this.scopeValue;
447
842
  await this.persist(() => this.storage?.applyTransaction(transaction, snapshot, writeScope));
843
+ if (this.storage?.readChanges)
844
+ await this.mergeStoredChanges(nextEntities, nextQueries);
845
+ if (epochChanged) {
846
+ this.invalidateEntityVersions();
847
+ this.windowOwned.clear();
848
+ }
448
849
  // Publish the whole committed transaction in one state swap and notify UI
449
850
  // exactly once. No subscriber can observe a partial entity/query update.
450
851
  this.entities = nextEntities;
451
852
  this.liveQueries = nextQueries;
853
+ for (const change of transaction.changes)
854
+ this.markEntityChanged(change.entity, [change.id]);
452
855
  for (const signature of changedWindows)
453
856
  this.markWindowChanged(signature);
454
857
  this.cursorValue = { ...transaction.cursor };
@@ -459,7 +862,7 @@ export class LocalReplica {
459
862
  // created row can disappear from a complete Replica Collection between
460
863
  // the transaction and its membership delta.
461
864
  this.reconcileCommands(false);
462
- this.notify();
865
+ this.notify(true);
463
866
  }
464
867
  entity(entity, id) {
465
868
  if (!this.scopeLoaded)
@@ -484,33 +887,269 @@ export class LocalReplica {
484
887
  }
485
888
  /** Resolve several IDs from one atomic Local Replica version. */
486
889
  entityBatch(entity, ids) {
487
- 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
+ });
488
919
  }
489
920
  /** All cached rows for one normalized entity, including optimistic overlays. */
490
921
  entityRows(entity) {
491
922
  if (!this.scopeLoaded)
492
923
  return [];
493
- 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));
494
931
  for (const command of this.pendingCommands.values()) {
495
932
  for (const patch of command.patches) {
496
- 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;
497
1013
  ids.add(patch.rowId);
1014
+ if (patch.op === "delete")
1015
+ rows.delete(patch.rowId);
1016
+ else if (patch.op === "patch")
1017
+ rows.set(patch.rowId, { ...rows.get(patch.rowId), ...patch.fields });
1018
+ else
1019
+ rows.set(patch.rowId, patch.fields);
1020
+ }
1021
+ const result = [...ids].flatMap(id => { const row = rows.get(id); return row ? [cloneRow(row)] : []; });
1022
+ materialized.set(entity, result);
1023
+ return result;
1024
+ };
1025
+ }
1026
+ /** Complete registered slices prove narrower reads without another subscription. */
1027
+ captureReadCoverage(base) {
1028
+ const coverage = { ...base };
1029
+ for (const [signature, { definition, args }] of this.replicaPlans) {
1030
+ const known = coverage[definition.table];
1031
+ if (!known || !this.windowIsComplete(signature))
1032
+ continue;
1033
+ const terms = [];
1034
+ let valid = true;
1035
+ for (const [argument, column] of Object.entries(definition.equalFilters ?? {})) {
1036
+ const value = args[argument];
1037
+ if (value === undefined || value !== null && !['string', 'number', 'boolean'].includes(typeof value)) {
1038
+ valid = false;
1039
+ break;
1040
+ }
1041
+ terms.push({ column, op: 'eq', value: value });
498
1042
  }
1043
+ if (!valid)
1044
+ continue;
1045
+ for (const column of definition.excludeWhenSet ?? [])
1046
+ terms.push({ column, op: 'isNull' });
1047
+ coverage[definition.table] = {
1048
+ ...known,
1049
+ complete: known.complete || terms.length === 0,
1050
+ completeWhere: terms.length ? [...(known.completeWhere ?? []), { and: terms }] : known.completeWhere,
1051
+ };
499
1052
  }
500
- return [...ids]
501
- .map((id) => this.entity(entity, id))
502
- .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
+ /** SDK watch snapshots: unchanged rows are not cloned again on every notification. */
1061
+ watchRows(signature, cache) {
1062
+ const window = this.scopeLoaded ? this.liveQueries.get(signature) : undefined;
1063
+ if (!window) {
1064
+ cache.clear();
1065
+ return [];
1066
+ }
1067
+ // A pending write to a different row must not disable shared immutable
1068
+ // snapshots for every newly mounted view in the application.
1069
+ let pendingRows;
1070
+ for (const command of this.pendingCommands.values()) {
1071
+ for (const patch of command.patches) {
1072
+ if ((patch.entity ?? patch.collection) === window.entity)
1073
+ (pendingRows ??= new Set()).add(patch.rowId);
1074
+ }
1075
+ }
1076
+ const read = (id) => {
1077
+ const version = this.rowVersion(window.entity, id);
1078
+ const prior = cache.get(id);
1079
+ if (prior?.version === version)
1080
+ return prior.row;
1081
+ const base = !pendingRows?.has(id) ? this.entities.get(window.entity)?.get(id) : undefined;
1082
+ let next;
1083
+ if (base) {
1084
+ let copy = this.visibleRowCopies.get(base);
1085
+ if (!copy) {
1086
+ copy = cloneRow(base);
1087
+ this.visibleRowCopies.set(base, copy);
1088
+ }
1089
+ next = copy;
1090
+ }
1091
+ else
1092
+ next = this.entity(window.entity, id);
1093
+ // A matching server echo or removal of its prediction changes protocol
1094
+ // versions without changing the row the component sees.
1095
+ const row = prior && sameReplicaValue(prior.row, next) ? prior.row : next;
1096
+ cache.set(id, { version, row });
1097
+ return row;
1098
+ };
1099
+ const ids = this.effectiveMembership(window, read);
1100
+ const retained = new Set(ids);
1101
+ for (const id of cache.keys())
1102
+ if (!retained.has(id))
1103
+ cache.delete(id);
1104
+ return ids.map(read).filter((row) => row !== undefined);
503
1105
  }
504
1106
  /** Exact only when an authoritative, non-truncated Replica Collection covers the entity. */
505
1107
  entityCompleteness(entity) {
506
- if (!this.scopeLoaded)
1108
+ if (!this.scopeLoaded || this.residentPartialTables.has(entity))
507
1109
  return "partial";
508
1110
  return [...this.liveQueries.values()].some((window) => (window.kind === "replica"
509
1111
  && window.entity === entity
510
1112
  && window.completeness === "complete"
511
1113
  && window.truncated !== true)) ? "complete" : "partial";
512
1114
  }
513
- liveQuery(signature) {
1115
+ // Bounded derived snapshots, containing only rows in recently read windows.
1116
+ readSnapshots = new Map();
1117
+ liveQuerySnapshot(signature) {
1118
+ let snapshot = this.readSnapshots.get(signature);
1119
+ if (!snapshot) {
1120
+ if (this.readSnapshots.size >= 64) {
1121
+ const cold = [...this.readSnapshots.keys()].find(key => !this.retainedWindows.has(key));
1122
+ if (cold !== undefined)
1123
+ this.readSnapshots.delete(cold);
1124
+ }
1125
+ snapshot = { version: -1, rowsVersion: -1, rows: [], cache: new Map() };
1126
+ }
1127
+ // Mounted windows cannot be evicted. Reordering them on every store read
1128
+ // only allocates Map tombstones; LRU order matters for cold windows alone.
1129
+ if (!this.retainedWindows.has(signature))
1130
+ this.readSnapshots.delete(signature);
1131
+ this.readSnapshots.set(signature, snapshot);
1132
+ if (snapshot.version !== this.windowVersion(signature) || snapshot.freshness !== this.freshnessValue) {
1133
+ const rowsVersion = this.windowRowsVersion(signature);
1134
+ if (snapshot.rowsVersion !== rowsVersion) {
1135
+ const rows = this.watchRows(signature, snapshot.cache);
1136
+ if (rows.length !== snapshot.rows.length || rows.some((row, index) => row !== snapshot.rows[index]))
1137
+ snapshot.rows = rows;
1138
+ snapshot.rowsVersion = rowsVersion;
1139
+ }
1140
+ const next = this.liveQuery(signature, snapshot.rows);
1141
+ const previous = snapshot.result;
1142
+ if (!previous || previous.rows !== next.rows || !sameReplicaValue({ ...previous, rows: undefined }, { ...next, rows: undefined }))
1143
+ snapshot.result = next;
1144
+ snapshot.version = this.windowVersion(signature);
1145
+ snapshot.freshness = this.freshnessValue;
1146
+ }
1147
+ return snapshot.result;
1148
+ }
1149
+ liveQuery(signature, cachedRows) {
1150
+ return this.windowResult(signature, cachedRows);
1151
+ }
1152
+ windowResult(signature, cachedRows) {
514
1153
  if (!this.scopeLoaded) {
515
1154
  return { rows: [], ids: [], source: "cache", completeness: "partial", freshness: this.freshnessValue };
516
1155
  }
@@ -518,8 +1157,8 @@ export class LocalReplica {
518
1157
  if (!membership) {
519
1158
  return { rows: [], ids: [], source: "cache", completeness: "partial", freshness: this.freshnessValue };
520
1159
  }
521
- const ids = this.effectiveMembership(membership);
522
- const rows = ids
1160
+ const ids = cachedRows ? cachedRows.map(row => String(row[membership.key])) : this.effectiveMembership(membership);
1161
+ const rows = cachedRows ?? ids
523
1162
  .map((id) => this.entity(membership.entity, id))
524
1163
  .filter((row) => row !== undefined);
525
1164
  const metadata = windowResultMetadata(membership.resultSkeleton, membership.resultPath);
@@ -528,13 +1167,13 @@ export class LocalReplica {
528
1167
  ids,
529
1168
  ...metadata,
530
1169
  source: this.freshnessValue === "current" ? membership.source : "cache",
531
- completeness: membership.completeness,
1170
+ completeness: membership.ids.every(id => this.entities.get(membership.entity)?.has(id)) ? membership.completeness : 'partial',
532
1171
  freshness: this.freshnessValue,
533
1172
  };
534
1173
  }
535
1174
  /** Rows and protocol-owned completeness for one Replica Collection window. */
536
- collectionState(signature) {
537
- const result = this.liveQuery(signature);
1175
+ collectionState(signature, cachedRows) {
1176
+ const result = this.windowResult(signature, cachedRows);
538
1177
  const window = this.scopeLoaded ? this.liveQueries.get(signature) : undefined;
539
1178
  return {
540
1179
  ...result,
@@ -568,24 +1207,45 @@ export class LocalReplica {
568
1207
  this.persistence = attempt.catch(() => undefined);
569
1208
  return attempt;
570
1209
  }
571
- notify() {
1210
+ notify(executionChanged = false) {
1211
+ if (this.disposed)
1212
+ return;
1213
+ if (executionChanged)
1214
+ this.trimResidentRows();
1215
+ if (executionChanged)
1216
+ this.executionVersionValue += 1;
572
1217
  this.versionValue += 1;
573
1218
  for (const listener of [...this.listeners])
574
1219
  listener();
575
1220
  }
576
- markWindowChanged(signature) {
1221
+ markEntityChanged(entity, ids) {
1222
+ const version = ++this.entityVersionClock;
1223
+ this.entityVersions.set(entity, version);
1224
+ for (const id of ids ?? this.entities.get(entity)?.keys() ?? [])
1225
+ this.rowVersions.set(`${entity}\0${id}`, version);
1226
+ }
1227
+ invalidateEntityVersions() {
1228
+ this.entityVersionFloor = ++this.entityVersionClock;
1229
+ this.entityVersions.clear();
1230
+ this.rowVersions.clear();
1231
+ this.readSnapshots.clear();
1232
+ }
1233
+ markWindowChanged(signature, rowsChanged = true) {
577
1234
  this.windowVersionClock += 1;
578
1235
  this.windowVersions.set(signature, this.windowVersionClock);
1236
+ if (rowsChanged)
1237
+ this.windowRowsVersions.set(signature, this.windowVersionClock);
579
1238
  }
580
1239
  markWindowsForPatches(patches) {
581
1240
  const affected = new Set();
582
1241
  for (const patch of patches) {
583
1242
  const entity = patch.entity ?? patch.collection;
1243
+ if (entity)
1244
+ this.markEntityChanged(entity, [patch.rowId]);
584
1245
  for (const [signature, window] of this.liveQueries) {
585
1246
  if (window.entity !== entity)
586
1247
  continue;
587
- const currentIDs = this.effectiveMembership(window);
588
- if (window.ids.includes(patch.rowId) || currentIDs.includes(patch.rowId)) {
1248
+ if (window.ids.includes(patch.rowId)) {
589
1249
  affected.add(signature);
590
1250
  continue;
591
1251
  }
@@ -605,7 +1265,7 @@ export class LocalReplica {
605
1265
  * the committed server membership, so rejection and reconciliation are
606
1266
  * reversible and never write optimistic IDs to storage.
607
1267
  */
608
- effectiveMembership(window) {
1268
+ effectiveMembership(window, read = id => this.entity(window.entity, id)) {
609
1269
  const plan = this.replicaPlans.get(window.signature);
610
1270
  const patches = [...this.pendingCommands.values()]
611
1271
  .flatMap((command) => command.patches)
@@ -621,7 +1281,7 @@ export class LocalReplica {
621
1281
  const ids = new Set(window.ids);
622
1282
  const touched = new Set(patches.map((patch) => patch.rowId));
623
1283
  for (const id of touched) {
624
- const row = this.entity(window.entity, id);
1284
+ const row = read(id);
625
1285
  if (row && replicaRowMatchesPlan(row, plan.definition, plan.args))
626
1286
  ids.add(id);
627
1287
  else
@@ -637,7 +1297,9 @@ export class LocalReplica {
637
1297
  const orderBy = plan.definition.orderBy;
638
1298
  if (!orderBy)
639
1299
  return [...retained, ...additions.sort(compareReplicaKeys)];
640
- return effectiveIDs.sort((left, right) => compareReplicaMembershipRows(this.entity(window.entity, left), this.entity(window.entity, right), window.key, orderBy, plan.definition.orderDirection));
1300
+ // The comparator may run O(n log n) times. Resolve each immutable row once.
1301
+ const rows = new Map(effectiveIDs.map(id => [id, read(id)]));
1302
+ return effectiveIDs.sort((left, right) => compareReplicaMembershipRows(rows.get(left), rows.get(right), window.key, orderBy, plan.definition.orderDirection));
641
1303
  }
642
1304
  trackWindowOwnership(window, rows, previous) {
643
1305
  const owned = this.windowOwned.get(window.signature) ?? new Map();
@@ -663,9 +1325,14 @@ export class LocalReplica {
663
1325
  if (!owned)
664
1326
  return;
665
1327
  const sourceWindow = this.liveQueries.get(signature);
1328
+ const referenced = new Set();
1329
+ for (const window of this.liveQueries.values()) {
1330
+ if (window.entity === sourceWindow?.entity)
1331
+ for (const id of window.ids)
1332
+ referenced.add(id);
1333
+ }
666
1334
  for (const [id] of owned) {
667
- const stillReferenced = [...this.liveQueries.values()].some((window) => (window.entity === sourceWindow?.entity && window.ids.includes(id)));
668
- if (!stillReferenced && sourceWindow) {
1335
+ if (!referenced.has(id) && sourceWindow) {
669
1336
  this.entities.get(sourceWindow.entity)?.delete(id);
670
1337
  }
671
1338
  }
@@ -687,6 +1354,13 @@ export class LocalReplica {
687
1354
  this.windowOwned.delete(signature);
688
1355
  }
689
1356
  }
1357
+ function sameWindowRowDefinition(previous, next) {
1358
+ return previous !== undefined && previous.entity === next.entity && previous.key === next.key
1359
+ && previous.kind === next.kind && previous.completeness === next.completeness
1360
+ && previous.truncated === next.truncated && previous.orderBy === next.orderBy
1361
+ && previous.orderDirection === next.orderDirection && previous.ids.length === next.ids.length
1362
+ && previous.ids.every((id, index) => id === next.ids[index]);
1363
+ }
690
1364
  function replicaRowMatchesPlan(row, definition, args) {
691
1365
  // The module manifest encodes equalFilters as argument name -> row column
692
1366
  // (for example `{ id: "_id" }`). Keep this aligned with artifact parsing.
@@ -783,6 +1457,19 @@ function cloneOptimisticPatch(patch) {
783
1457
  return { ...patch, fields: structuredClone(patch.fields) };
784
1458
  return { ...patch, fields: structuredClone(patch.fields) };
785
1459
  }
1460
+ function sameReplicaValue(left, right) {
1461
+ if (left === right)
1462
+ return true;
1463
+ if (!left || !right || typeof left !== "object" || typeof right !== "object")
1464
+ return false;
1465
+ if (Array.isArray(left))
1466
+ return Array.isArray(right) && left.length === right.length && left.every((value, index) => sameReplicaValue(value, right[index]));
1467
+ if (Array.isArray(right))
1468
+ return false;
1469
+ const a = left, b = right;
1470
+ const keys = Object.keys(a);
1471
+ return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameReplicaValue(a[key], b[key]));
1472
+ }
786
1473
  function cloneRow(row) { return structuredClone(row); }
787
1474
  function windowResultMetadata(result, resultPath) {
788
1475
  if (!result || typeof result !== "object" || Array.isArray(result) || !resultPath?.length)
@@ -853,9 +1540,6 @@ function hydratedTransactionFloor(persisted, windows) {
853
1540
  // unsafe floor across a browser restart.
854
1541
  return { epoch, revision: Math.min(persisted.revision, provenRevision) };
855
1542
  }
856
- function cloneEntities(source) {
857
- return new Map([...source].map(([entity, rows]) => [entity, new Map([...rows].map(([id, row]) => [id, cloneRow(row)]))]));
858
- }
859
1543
  function entitiesFromSnapshot(source) {
860
1544
  return new Map(Object.entries(source).map(([entity, rows]) => [entity, new Map(Object.entries(rows).map(([id, row]) => [id, cloneRow(row)]))]));
861
1545
  }
@@ -874,4 +1558,46 @@ function normalizeReplicaScope(scope) {
874
1558
  return defaultReplicaScope;
875
1559
  return scope;
876
1560
  }
1561
+ /** Build snapshot accessors only if an adapter needs them. Metadata-only writes
1562
+ * never enumerate tables/windows; delta adapters clone values on first access. */
1563
+ function storageSnapshotFrom(cursor, source, windows) {
1564
+ // Capture membership now even if an adapter reads its snapshot after the
1565
+ // persistence promise resolves and reconciliation replaces map entries.
1566
+ const capturedSource = new Map(source);
1567
+ const capturedWindows = new Map(windows);
1568
+ const snapshot = { cursor: cursor ? { ...cursor } : undefined };
1569
+ Object.defineProperty(snapshot, "entities", { enumerable: true, configurable: true, get() {
1570
+ const entities = {};
1571
+ for (const [entity, rows] of capturedSource) {
1572
+ // Committed maps and rows are immutable. Retain that version without
1573
+ // allocating entries for unrelated tables; clone values on first access.
1574
+ const captured = rows;
1575
+ Object.defineProperty(entities, entity, { enumerable: true, configurable: true, get() {
1576
+ const value = {};
1577
+ for (const [id, row] of captured)
1578
+ Object.defineProperty(value, id, { enumerable: true, configurable: true, get() {
1579
+ const copy = cloneRow(row);
1580
+ Object.defineProperty(value, id, { enumerable: true, configurable: true, writable: true, value: copy });
1581
+ return copy;
1582
+ } });
1583
+ Object.defineProperty(entities, entity, { enumerable: true, configurable: true, writable: true, value });
1584
+ return value;
1585
+ } });
1586
+ }
1587
+ Object.defineProperty(snapshot, "entities", { enumerable: true, configurable: true, writable: true, value: entities });
1588
+ return entities;
1589
+ } });
1590
+ Object.defineProperty(snapshot, "liveQueries", { enumerable: true, configurable: true, get() {
1591
+ const liveQueries = {};
1592
+ for (const [key, window] of capturedWindows)
1593
+ Object.defineProperty(liveQueries, key, { enumerable: true, configurable: true, get() {
1594
+ const copy = cloneWindow(window);
1595
+ Object.defineProperty(liveQueries, key, { enumerable: true, configurable: true, writable: true, value: copy });
1596
+ return copy;
1597
+ } });
1598
+ Object.defineProperty(snapshot, "liveQueries", { enumerable: true, configurable: true, writable: true, value: liveQueries });
1599
+ return liveQueries;
1600
+ } });
1601
+ return snapshot;
1602
+ }
877
1603
  //# sourceMappingURL=local-replica.js.map