@freecorps/ember-offline 0.1.0 → 0.3.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/README.md +1 -1
- package/dist/backend.d.ts +38 -2
- package/dist/backend.js +4 -4
- package/dist/index.d.ts +9 -1
- package/dist/index.js +9 -1
- package/dist/indexeddb.d.ts +2 -1
- package/dist/indexeddb.js +22 -8
- package/dist/memory.d.ts +3 -1
- package/dist/memory.js +16 -3
- package/dist/mutations.d.ts +15 -0
- package/dist/mutations.js +112 -0
- package/dist/opfs-worker.d.ts +52 -0
- package/dist/opfs-worker.js +72 -0
- package/dist/opfs.d.ts +30 -0
- package/dist/opfs.js +66 -0
- package/dist/remote.d.ts +109 -0
- package/dist/remote.js +388 -0
- package/dist/sqlite-wasm.d.ts +30 -0
- package/dist/sqlite-wasm.js +77 -0
- package/dist/sqlite.d.ts +2 -1
- package/dist/sqlite.js +19 -5
- package/dist/store.d.ts +33 -3
- package/dist/store.js +430 -59
- package/dist/tabs.d.ts +79 -0
- package/dist/tabs.js +210 -0
- package/dist/tinybase.d.ts +78 -0
- package/dist/tinybase.js +294 -0
- package/package.json +34 -7
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 {
|
|
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
|
-
/**
|
|
24
|
-
|
|
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.
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
if (
|
|
299
|
+
newIds.add(doc._id);
|
|
300
|
+
if (this.rowSig.get(k) !== sig) {
|
|
131
301
|
c.upsert.push({ table, doc });
|
|
132
|
-
|
|
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
|
|
136
|
-
if (
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
470
|
+
return { result: await fromCache(), readSet: null };
|
|
216
471
|
try {
|
|
217
|
-
|
|
472
|
+
const { value, readSet } = await this.runLocal(fn, req);
|
|
473
|
+
return { result: { value, source: "local" }, readSet };
|
|
218
474
|
}
|
|
219
475
|
catch (e) {
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
486
|
+
let out;
|
|
230
487
|
let error;
|
|
231
|
-
let failed = false;
|
|
232
488
|
try {
|
|
233
|
-
|
|
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 (
|
|
500
|
+
if (!out)
|
|
246
501
|
throw error;
|
|
247
|
-
return
|
|
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);
|