@freecorps/ember-offline 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js CHANGED
@@ -6,10 +6,61 @@
6
6
  // row is deleted. So the replica is always the union of the last server results of the offline queries — never a
7
7
  // row the server did not return, never a row no stored result vouches for. Other queries only get their last value
8
8
  // cached (`source: "cache"`).
9
+ //
10
+ // Values by reference, changes as row diffs. A replicated document is stored once, as a row; the record keeps a
11
+ // skeleton of its value where those documents are references `{ _id, $row: 1 }`. A new server value whose
12
+ // skeleton is a doc array is persisted as a row diff against the previous skeleton (appended to the record's log,
13
+ // usually a few ids) plus the rows that changed; the record is rewritten whole (compaction) when the log would
14
+ // outgrow it. So a patch touching k rows of an n-row result writes O(k), not O(n).
15
+ //
16
+ // Reactive local results. `watchQueryLocally` keeps the read set of each local run (`runLocalQuery`) and re-runs
17
+ // the query when replicated rows change inside it (this store's writes, another tab's through the leader, or a
18
+ // backend's external writer), and re-sends a cached value when the record of its args changes.
9
19
  import { decodeJson, encodeJson, getFunctionName, stableStringify, } from "@freecorps/ember-client";
10
- import { documentsIn, localQueryRegistry, localTableOfId, runLocalQuery, } from "@freecorps/ember-server";
11
- import { recordKey } from "./backend.js";
20
+ import { documentsIn, localQueryRegistry, localReadSetMatches, localTableOfId, runLocalQuery, } from "@freecorps/ember-server";
21
+ import { applyRowDiff, isPlainObject, rowDiff } from "@freecorps/ember-values";
22
+ import { NeedData, recordKey } from "./backend.js";
12
23
  const DAY = 86_400_000;
24
+ /** Log entries per record before it is rewritten whole. */
25
+ const MAX_LOG = 64;
26
+ const ROW = "$row";
27
+ const isRef = (x) => isPlainObject(x) && x[ROW] === 1 && typeof x._id === "string" && Object.keys(x).length === 2;
28
+ /** `value` with the documents of `docs` (by identity) replaced by row references. */
29
+ function toSkeleton(value, docs) {
30
+ if (Array.isArray(value))
31
+ return value.map((x) => toSkeleton(x, docs));
32
+ if (!isPlainObject(value))
33
+ return value;
34
+ if (docs.has(value))
35
+ return { _id: value._id, [ROW]: 1 };
36
+ const out = {};
37
+ for (const [k, x] of Object.entries(value))
38
+ out[k] = toSkeleton(x, docs);
39
+ return out;
40
+ }
41
+ function refsIn(skel, out = []) {
42
+ if (Array.isArray(skel))
43
+ for (const x of skel)
44
+ refsIn(x, out);
45
+ else if (isRef(skel))
46
+ out.push(skel._id);
47
+ else if (isPlainObject(skel))
48
+ for (const x of Object.values(skel))
49
+ refsIn(x, out);
50
+ return out;
51
+ }
52
+ function resolveRefs(skel, row) {
53
+ if (Array.isArray(skel))
54
+ return skel.map((x) => resolveRefs(x, row));
55
+ if (isRef(skel))
56
+ return row(skel._id);
57
+ if (!isPlainObject(skel))
58
+ return skel;
59
+ const out = {};
60
+ for (const [k, x] of Object.entries(skel))
61
+ out[k] = resolveRefs(x, row);
62
+ return out;
63
+ }
13
64
  export class OfflineStore {
14
65
  backend;
15
66
  options;
@@ -20,11 +71,15 @@ export class OfflineStore {
20
71
  records = new Map();
21
72
  /** `${shard}\0${id}` → number of records owning the row. */
22
73
  refs = new Map();
23
- /** record key → id → stable JSON of the doc (lazy): unchanged rows are not rewritten. */
24
- sigs = new Map();
74
+ /** `${shard}\0${id}` → the row as last written / read (unknown after a restart until needed). */
75
+ rowDoc = new Map();
76
+ rowSig = new Map();
77
+ watches = new Set();
78
+ rerunQueued = false;
25
79
  meta = {};
26
80
  chain = Promise.resolve();
27
81
  opened = null;
82
+ stopExternal = null;
28
83
  now;
29
84
  constructor(backend, options) {
30
85
  this.backend = backend;
@@ -37,8 +92,8 @@ export class OfflineStore {
37
92
  this.persistPending = {
38
93
  getClientId: () => this.enqueue(async () => this.meta.clientId ?? null),
39
94
  setClientId: (id) => this.enqueue(async () => {
40
- this.meta.clientId = id;
41
95
  await this.backend.setMeta("clientId", id);
96
+ this.meta.clientId = id;
42
97
  }),
43
98
  load: () => this.enqueue(async () => await this.backend.loadPending()),
44
99
  put: (r) => this.enqueue(async () => void (await this.backend.putPending(r))),
@@ -64,23 +119,81 @@ export class OfflineStore {
64
119
  open() {
65
120
  if (!this.opened) {
66
121
  this.opened = (async () => {
67
- const { meta, queries } = await this.backend.open();
122
+ const { meta, queries, log = [] } = await this.backend.open();
68
123
  this.meta = { ...meta };
124
+ const logs = new Map();
125
+ for (const e of log)
126
+ logs.set(e.key, [...(logs.get(e.key) ?? []), e]);
127
+ const broken = [];
69
128
  for (const r of queries) {
70
- this.records.set(r.key, r);
71
- for (const id of r.ids)
72
- this.ref(r.shard, id, +1);
129
+ const rec = this.replay(r, (logs.get(r.key) ?? []).sort((a, b) => a.seq - b.seq));
130
+ this.records.set(rec.key, rec);
131
+ for (const id of rec.ids)
132
+ this.ref(rec.shard, id, +1);
133
+ if (rec.skel === BROKEN)
134
+ broken.push(rec);
73
135
  }
74
136
  // Retention: what the server has not refreshed for too long goes, with the rows only it owned.
75
137
  const cutoff = this.now() - (this.options.retentionMs ?? 30 * DAY);
76
- const stale = [...this.records.values()].filter((r) => r.updatedAt < cutoff);
138
+ const stale = [...this.records.values()].filter((r) => r.updatedAt < cutoff || r.skel === BROKEN);
77
139
  if (stale.length)
78
140
  await this.dropRecords(stale);
141
+ this.stopExternal = this.backend.onExternalChange?.((c) => this.onExternal(c)) ?? null;
79
142
  })();
80
143
  this.chain = this.opened.catch(() => undefined);
81
144
  }
82
145
  return this.opened;
83
146
  }
147
+ /** A stored record with its log applied. A log that does not apply marks the record broken (dropped on open). */
148
+ replay(r, entries) {
149
+ let skel = r.value;
150
+ let { version, updatedAt } = r;
151
+ let epoch = r.epoch;
152
+ const ids = new Set(r.ids);
153
+ let seq = 0;
154
+ let logBytes = 0;
155
+ try {
156
+ for (const e of entries) {
157
+ skel = applyRowDiff(skel, e.diff);
158
+ version = e.version;
159
+ epoch = e.epoch;
160
+ updatedAt = e.updatedAt;
161
+ for (const id of e.add)
162
+ ids.add(id);
163
+ for (const id of e.remove)
164
+ ids.delete(id);
165
+ seq = e.seq;
166
+ logBytes += encodeJson(e.diff).length;
167
+ }
168
+ }
169
+ catch {
170
+ // Own every row any entry added, so dropping the record releases them all.
171
+ for (const e of entries)
172
+ for (const id of e.add)
173
+ ids.add(id);
174
+ skel = BROKEN;
175
+ }
176
+ const refs = r.refs === true;
177
+ return {
178
+ key: r.key,
179
+ shard: r.shard,
180
+ fn: r.fn,
181
+ args: r.args,
182
+ argsJson: r.argsJson,
183
+ version,
184
+ epoch,
185
+ updatedAt,
186
+ ids: [...ids],
187
+ skel,
188
+ refs,
189
+ materialized: !refs,
190
+ value: refs ? undefined : skel,
191
+ seq,
192
+ logCount: entries.length,
193
+ logBytes,
194
+ baseBytes: r.size ?? 0,
195
+ };
196
+ }
84
197
  ref(shard, id, d) {
85
198
  const k = `${shard}\u0000${id}`;
86
199
  const n = (this.refs.get(k) ?? 0) + d;
@@ -102,57 +215,155 @@ export class OfflineStore {
102
215
  applyPatch(u) {
103
216
  return this.enqueue(() => this.apply(u));
104
217
  }
105
- sigsOf(r) {
106
- let m = this.sigs.get(r.key);
107
- if (!m) {
108
- m = new Map(documentsIn(this.options.schema.manifest, r.value).map(({ doc }) => [doc._id, stableStringify(doc)]));
109
- this.sigs.set(r.key, m);
218
+ /** Reads rows by id from the backend (async readers: fetch and retry until nothing is missing). */
219
+ async readRows(shard, ids) {
220
+ const m = this.options.schema.manifest;
221
+ const reader = this.backend.reader(shard);
222
+ for (let attempt = 0;; attempt++) {
223
+ const out = new Map();
224
+ for (const id of ids) {
225
+ const table = localTableOfId(m, id);
226
+ if (!table) {
227
+ out.set(id, null);
228
+ continue;
229
+ }
230
+ try {
231
+ out.set(id, reader.get(table, id));
232
+ }
233
+ catch (e) {
234
+ if (!(e instanceof NeedData))
235
+ throw e;
236
+ }
237
+ }
238
+ if (reader.misses && reader.fetch && attempt < 100) {
239
+ await reader.fetch();
240
+ continue;
241
+ }
242
+ // Rows still missing after the retries are reported as missing (null), never left out of the map.
243
+ for (const id of ids)
244
+ if (!out.has(id))
245
+ out.set(id, null);
246
+ return out;
247
+ }
248
+ }
249
+ /** The full value of a record (resolving its row references from the replica). */
250
+ async valueOf(rec) {
251
+ if (rec.materialized)
252
+ return rec.value;
253
+ const missing = [...new Set(refsIn(rec.skel))].filter((id) => !this.rowDoc.has(`${rec.shard}\u0000${id}`));
254
+ if (missing.length) {
255
+ for (const [id, doc] of await this.readRows(rec.shard, missing)) {
256
+ if (!doc)
257
+ throw new Error(`offline store: replicated row ${id} of ${rec.fn} is missing`);
258
+ this.know(rec.shard, doc);
259
+ }
110
260
  }
111
- return m;
261
+ rec.value = resolveRefs(rec.skel, (id) => this.rowDoc.get(`${rec.shard}\u0000${id}`));
262
+ rec.materialized = true;
263
+ return rec.value;
264
+ }
265
+ know(shard, doc, sig) {
266
+ const k = `${shard}\u0000${doc._id}`;
267
+ this.rowDoc.set(k, doc);
268
+ this.rowSig.set(k, sig ?? stableStringify(doc));
112
269
  }
113
270
  async apply(u) {
114
271
  const m = this.options.schema.manifest;
115
272
  const key = recordKey(u.shard, u.fn, u.argsJson);
116
273
  const prev = this.records.get(key);
117
- const docs = this.replicated.has(u.fn) ? documentsIn(m, u.value) : [];
118
- const oldSigs = prev ? this.sigsOf(prev) : new Map();
119
- const newSigs = new Map();
120
- const c = { put: [], upsert: [], delete: [] };
274
+ // Older than what is stored (another tab persisted a newer value first, or a call resent after a leader
275
+ // handover): dropped, so the replica never goes back. Versions only compare within one shard epoch; a new
276
+ // epoch (a restore reuses versions) or an unknown one is taken as it comes.
277
+ if (prev && prev.epoch !== undefined && prev.epoch === u.epoch && u.version < prev.version)
278
+ return;
279
+ // The previous rows' content tells which rows are unchanged (not rewritten) and how changed rows moved.
280
+ if (prev && !prev.materialized)
281
+ await this.valueOf(prev).catch(() => undefined);
282
+ const found = this.replicated.has(u.fn) ? documentsIn(m, u.value) : [];
283
+ const prevIds = new Set(prev?.ids ?? []);
284
+ const newIds = new Set();
285
+ const c = { upsert: [], delete: [] };
286
+ const changes = [];
287
+ const written = [];
288
+ const removed = [];
121
289
  // Refcount changes are applied as computed and undone if the commit fails, so memory never runs ahead of storage.
122
290
  const deltas = [];
123
291
  const ref = (id, d) => {
124
292
  deltas.push([id, d]);
125
293
  return this.ref(u.shard, id, d);
126
294
  };
127
- for (const { table, doc } of docs) {
295
+ const before = (k) => (this.rowDoc.has(k) ? this.rowDoc.get(k) : this.refs.has(k) ? undefined : null);
296
+ for (const { table, doc } of found) {
297
+ const k = `${u.shard}\u0000${doc._id}`;
128
298
  const sig = stableStringify(doc);
129
- newSigs.set(doc._id, sig);
130
- if (oldSigs.get(doc._id) !== sig)
299
+ newIds.add(doc._id);
300
+ if (this.rowSig.get(k) !== sig) {
131
301
  c.upsert.push({ table, doc });
132
- if (!oldSigs.has(doc._id))
302
+ changes.push({ table, id: doc._id, before: before(k), after: doc });
303
+ written.push({ doc, sig });
304
+ }
305
+ if (!prevIds.has(doc._id))
133
306
  ref(doc._id, +1);
134
307
  }
135
- for (const id of prev?.ids ?? []) {
136
- if (newSigs.has(id))
308
+ for (const id of prevIds) {
309
+ if (newIds.has(id))
137
310
  continue;
311
+ const k = `${u.shard}\u0000${id}`;
312
+ const was = before(k);
138
313
  if (ref(id, -1) === 0) {
139
314
  const table = localTableOfId(m, id);
140
- if (table)
315
+ if (table) {
141
316
  c.delete.push({ table, id });
317
+ changes.push({ table, id, before: was, after: null });
318
+ removed.push(k);
319
+ }
142
320
  }
143
321
  }
144
- const record = {
145
- key,
146
- shard: u.shard,
147
- fn: u.fn,
148
- args: u.args,
149
- argsJson: u.argsJson,
150
- value: u.value,
151
- version: u.version,
152
- updatedAt: this.now(),
153
- ids: [...newSigs.keys()],
154
- };
155
- c.put.push(record);
322
+ const docSet = new Set(found.map((f) => f.doc));
323
+ const diffs = this.options.persistDiffs !== false;
324
+ const skel = docSet.size && diffs ? toSkeleton(u.value, docSet) : u.value;
325
+ const now = this.now();
326
+ let entry = null;
327
+ let entryBytes = 0;
328
+ if (prev && prev.skel !== BROKEN && diffs && prev.logCount < MAX_LOG && prev.baseBytes > 0) {
329
+ const diff = rowDiff(prev.skel, skel);
330
+ if (diff) {
331
+ entryBytes = encodeJson(diff).length;
332
+ if (prev.logBytes + entryBytes <= prev.baseBytes) {
333
+ entry = {
334
+ key,
335
+ seq: prev.seq + 1,
336
+ diff,
337
+ version: u.version,
338
+ ...(u.epoch !== undefined ? { epoch: u.epoch } : {}),
339
+ updatedAt: now,
340
+ add: [...newIds].filter((id) => !prevIds.has(id)),
341
+ remove: [...prevIds].filter((id) => !newIds.has(id)),
342
+ };
343
+ }
344
+ }
345
+ }
346
+ let baseBytes = prev?.baseBytes ?? 0;
347
+ if (entry)
348
+ c.append = [entry];
349
+ else {
350
+ const record = {
351
+ key,
352
+ shard: u.shard,
353
+ fn: u.fn,
354
+ args: u.args,
355
+ argsJson: u.argsJson,
356
+ value: skel,
357
+ version: u.version,
358
+ ...(u.epoch !== undefined ? { epoch: u.epoch } : {}),
359
+ updatedAt: now,
360
+ ids: [...newIds],
361
+ size: 0,
362
+ ...(docSet.size && diffs ? { refs: true } : {}),
363
+ };
364
+ record.size = baseBytes = encodeJson(skel).length;
365
+ c.put = [record];
366
+ }
156
367
  try {
157
368
  await this.backend.commit(u.shard, c);
158
369
  }
@@ -161,8 +372,32 @@ export class OfflineStore {
161
372
  this.ref(u.shard, id, -d);
162
373
  throw e;
163
374
  }
164
- this.records.set(key, record);
165
- this.sigs.set(key, newSigs);
375
+ for (const w of written)
376
+ this.know(u.shard, w.doc, w.sig);
377
+ for (const k of removed) {
378
+ this.rowDoc.delete(k);
379
+ this.rowSig.delete(k);
380
+ }
381
+ this.records.set(key, {
382
+ key,
383
+ shard: u.shard,
384
+ fn: u.fn,
385
+ args: u.args,
386
+ argsJson: u.argsJson,
387
+ version: u.version,
388
+ epoch: u.epoch,
389
+ updatedAt: now,
390
+ ids: [...newIds],
391
+ skel,
392
+ refs: docSet.size > 0 && diffs,
393
+ materialized: true,
394
+ value: u.value,
395
+ seq: entry ? entry.seq : 0,
396
+ logCount: entry ? prev.logCount + 1 : 0,
397
+ logBytes: entry ? prev.logBytes + entryBytes : 0,
398
+ baseBytes,
399
+ });
400
+ this.notify(u.shard, changes, [key]);
166
401
  await this.evict(key);
167
402
  }
168
403
  /** Over `maxQueries`: drop the least recently refreshed records (never the one just written). */
@@ -187,38 +422,60 @@ export class OfflineStore {
187
422
  byShard.set(r.shard, [...(byShard.get(r.shard) ?? []), r]);
188
423
  for (const [shard, records] of byShard) {
189
424
  const c = { drop: records.map((r) => r.key), delete: [] };
425
+ const changes = [];
190
426
  const dec = new Map();
191
427
  for (const r of records)
192
428
  for (const id of r.ids)
193
429
  dec.set(id, (dec.get(id) ?? 0) + 1);
194
430
  for (const [id, n] of dec) {
195
- if ((this.refs.get(`${shard}\u0000${id}`) ?? 0) - n > 0)
431
+ const k = `${shard}\u0000${id}`;
432
+ if ((this.refs.get(k) ?? 0) - n > 0)
196
433
  continue;
197
434
  const table = localTableOfId(m, id);
198
- if (table)
435
+ if (table) {
199
436
  c.delete.push({ table, id });
437
+ changes.push({ table, id, before: this.rowDoc.has(k) ? this.rowDoc.get(k) : undefined, after: null });
438
+ }
200
439
  }
201
440
  await this.backend.commit(shard, c);
202
- for (const r of records) {
441
+ for (const r of records)
203
442
  this.records.delete(r.key);
204
- this.sigs.delete(r.key);
443
+ for (const [id, n] of dec) {
444
+ if (this.ref(shard, id, -n) > 0)
445
+ continue;
446
+ this.rowDoc.delete(`${shard}\u0000${id}`);
447
+ this.rowSig.delete(`${shard}\u0000${id}`);
205
448
  }
206
- for (const [id, n] of dec)
207
- this.ref(shard, id, -n);
449
+ this.notify(shard, changes, records.map((r) => r.key));
208
450
  }
209
451
  }
210
452
  async runQueryLocally(req) {
211
453
  await this.flush();
454
+ return await this.evaluate(req, this.registry.get(req.fn)).then((r) => r.result);
455
+ }
456
+ /** The local result of a request (registered: run over the replica; else / on failure: the cached value). */
457
+ async evaluate(req, fn) {
212
458
  const cached = this.records.get(recordKey(req.shard, req.fn, req.argsJson));
213
- const fn = this.registry.get(req.fn);
459
+ const fromCache = async () => {
460
+ if (!cached)
461
+ return undefined;
462
+ try {
463
+ return { value: await this.valueOf(cached), source: "cache" };
464
+ }
465
+ catch {
466
+ return undefined;
467
+ }
468
+ };
214
469
  if (!fn)
215
- return cached ? { value: cached.value, source: "cache" } : undefined;
470
+ return { result: await fromCache(), readSet: null };
216
471
  try {
217
- return { value: await this.runLocal(fn, req), source: "local" };
472
+ const { value, readSet } = await this.runLocal(fn, req);
473
+ return { result: { value, source: "local" }, readSet };
218
474
  }
219
475
  catch (e) {
220
- if (cached)
221
- return { value: cached.value, source: "cache" };
476
+ const result = await fromCache();
477
+ if (result)
478
+ return { result, readSet: null };
222
479
  throw e;
223
480
  }
224
481
  }
@@ -226,14 +483,12 @@ export class OfflineStore {
226
483
  async runLocal(fn, req) {
227
484
  const reader = this.backend.reader(req.shard);
228
485
  for (let attempt = 0;; attempt++) {
229
- let value;
486
+ let out;
230
487
  let error;
231
- let failed = false;
232
488
  try {
233
- value = runLocalQuery(fn, req.args, { schema: this.options.schema, reader, identity: req.identity, now: this.now() }).value;
489
+ out = runLocalQuery(fn, req.args, { schema: this.options.schema, reader, identity: req.identity, now: this.now() });
234
490
  }
235
491
  catch (e) {
236
- failed = true;
237
492
  error = e;
238
493
  }
239
494
  if (reader.misses && reader.fetch) {
@@ -242,19 +497,128 @@ export class OfflineStore {
242
497
  await reader.fetch();
243
498
  continue;
244
499
  }
245
- if (failed)
500
+ if (!out)
246
501
  throw error;
247
- return value;
502
+ return out;
503
+ }
504
+ }
505
+ // ---------------------------------------------------------------------------------------------- reactive local results
506
+ /**
507
+ * The local result of `req` now, and again whenever it may have changed: a registered query re-runs when rows
508
+ * inside its read set change; any request re-reads when the cached record of its args changes. Results equal to
509
+ * the last one sent are not re-sent.
510
+ */
511
+ watchQueryLocally(req, onResult, onError) {
512
+ const w = {
513
+ req,
514
+ key: recordKey(req.shard, req.fn, req.argsJson),
515
+ fn: this.registry.get(req.fn),
516
+ readSet: null,
517
+ last: null,
518
+ dirty: false,
519
+ closed: false,
520
+ failed: false,
521
+ onResult,
522
+ ...(onError ? { onError } : {}),
523
+ };
524
+ this.watches.add(w);
525
+ this.schedule([w]);
526
+ return () => {
527
+ w.closed = true;
528
+ this.watches.delete(w);
529
+ };
530
+ }
531
+ schedule(ws) {
532
+ let any = false;
533
+ for (const w of ws) {
534
+ if (w.closed)
535
+ continue;
536
+ w.dirty = true;
537
+ any = true;
248
538
  }
539
+ if (!any || this.rerunQueued)
540
+ return;
541
+ this.rerunQueued = true;
542
+ // In the write queue: a re-run sees every write handed to the store before it, and none half-done.
543
+ void this.enqueue(async () => {
544
+ this.rerunQueued = false;
545
+ for (const w of [...this.watches])
546
+ if (w.dirty)
547
+ await this.rerun(w);
548
+ }).catch(() => {
549
+ this.rerunQueued = false;
550
+ });
551
+ }
552
+ async rerun(w) {
553
+ w.dirty = false;
554
+ let result;
555
+ try {
556
+ const r = await this.evaluate(w.req, w.fn);
557
+ result = r.result;
558
+ w.readSet = r.readSet;
559
+ w.failed = false;
560
+ }
561
+ catch (e) {
562
+ w.readSet = null;
563
+ if (!w.failed && !w.closed)
564
+ w.onError?.(e);
565
+ w.failed = true;
566
+ return;
567
+ }
568
+ if (w.closed)
569
+ return;
570
+ const sig = result ? `${result.source}\u0000${encodeJson(result.value)}` : "";
571
+ if (sig === w.last)
572
+ return;
573
+ w.last = sig;
574
+ try {
575
+ w.onResult(result);
576
+ }
577
+ catch {
578
+ /* a listener's problem */
579
+ }
580
+ }
581
+ /** Rows of `shard` changed (and these records were rewritten): mark the watches they may affect. */
582
+ notify(shard, changes, keys) {
583
+ if (!this.watches.size || (!changes.length && !keys.length))
584
+ return;
585
+ const touched = new Set(keys);
586
+ const hit = [];
587
+ for (const w of this.watches) {
588
+ if (w.req.shard !== shard)
589
+ continue;
590
+ if (touched.has(w.key))
591
+ hit.push(w);
592
+ else if (w.fn && changes.length && (!w.readSet || localReadSetMatches(this.options.schema, w.readSet, changes)))
593
+ hit.push(w);
594
+ }
595
+ this.schedule(hit);
596
+ }
597
+ /** Someone else wrote rows (a shared TinyBase store): what we knew about them is stale; re-run what reads them. */
598
+ onExternal(c) {
599
+ for (const k of [...this.rowDoc.keys()]) {
600
+ if (!k.startsWith(`${c.shard}\u0000`))
601
+ continue;
602
+ this.rowDoc.delete(k);
603
+ this.rowSig.delete(k);
604
+ }
605
+ for (const r of this.records.values()) {
606
+ if (r.shard === c.shard && r.refs) {
607
+ r.materialized = false;
608
+ r.value = undefined;
609
+ }
610
+ }
611
+ this.notify(c.shard, c.tables.map((table) => ({ table, id: "", before: undefined, after: null })), []);
249
612
  }
250
613
  async setIdentity(identity) {
251
614
  await this.enqueue(async () => {
252
615
  const v = identity ? encodeJson(identity) : null;
616
+ // Storage first: a failed write leaves memory as it was.
617
+ await this.backend.setMeta("identity", v);
253
618
  if (v === null)
254
619
  delete this.meta.identity;
255
620
  else
256
621
  this.meta.identity = v;
257
- await this.backend.setMeta("identity", v);
258
622
  });
259
623
  }
260
624
  /** Forget every cached result and replicated row (e.g. on sign-out on a shared device). Pending mutations stay. */
@@ -263,9 +627,16 @@ export class OfflineStore {
263
627
  }
264
628
  async close() {
265
629
  await this.flush().catch(() => undefined);
630
+ for (const w of this.watches)
631
+ w.closed = true;
632
+ this.watches.clear();
633
+ this.stopExternal?.();
634
+ this.stopExternal = null;
266
635
  await this.backend.close();
267
636
  }
268
637
  }
638
+ /** Marks a record whose log does not apply (dropped on open). */
639
+ const BROKEN = Object.freeze({ broken: true });
269
640
  /** A `StorageAdapter` over any backend. */
270
641
  export function createOfflineStorage(backend, options) {
271
642
  return new OfflineStore(backend, options);