@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/remote.js ADDED
@@ -0,0 +1,388 @@
1
+ // A `StorageAdapter` across a message boundary: the main thread ⇄ the Worker that owns an OPFS database
2
+ // (opfs.ts), and follower tabs ⇄ the leader tab (tabs.ts). One small request/response protocol over anything with
3
+ // `postMessage` + `message` events (Worker, worker global scope, MessagePort, BroadcastChannel); values cross by
4
+ // structured clone (bigint and Uint8Array included).
5
+ //
6
+ // → { ember: "offline/1", k: "req", id, from, to, op, a } a call (to: the serving address)
7
+ // ← { ember: "offline/1", k: "res", id, to, ok, v | e } its answer (to: the caller)
8
+ // ← { ember: "offline/1", k: "watch", to, wid, r | e } a watched local result (watchQueryLocally)
9
+ // ← { ember: "offline/1", k: "leader", from } a new server took over: resend in-flight calls
10
+ // → { ember: "offline/1", k: "bye", from } the caller is gone: drop its watches
11
+ //
12
+ // Operations: load (+ the server's `sync` specs), getClientId, setClientId, loadPending, putPending, removePending,
13
+ // applyPatch, runQueryLocally, setIdentity, flush, clear, close, watch / unwatch.
14
+ //
15
+ // Every operation is idempotent (results are keyed by query, the pending queue by `mid`, metadata by key), so a
16
+ // client resends whatever is still in flight when a new server announces itself — each call under a new `id`, so
17
+ // only the server it was last sent to can settle it. A stopped server (a leader leaving) answers nothing more: what
18
+ // it was still running is resent to the next one rather than failed with an error it got while closing.
19
+ import { getFunctionName, } from "@freecorps/ember-client";
20
+ const TAG = "offline/1";
21
+ const isMsg = (x) => typeof x === "object" && x !== null && x.ember === TAG;
22
+ const toErr = (e) => {
23
+ const x = e;
24
+ return { name: String(x?.name ?? "Error"), message: String(x?.message ?? e), ...(typeof x?.code === "string" ? { code: x.code } : {}) };
25
+ };
26
+ const fromErr = (e) => Object.assign(new Error(e.message), { name: e.name, ...(e.code ? { code: e.code } : {}) });
27
+ export const randomId = () => typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
28
+ /** `sync` specs as plain data (function references are proxies: not cloneable). */
29
+ const plainSync = (s) => (s ?? []).map((x) => ({ query: getFunctionName(x.query), ...(x.args ? { args: x.args } : {}), ...(x.shard !== undefined ? { shard: x.shard } : {}) }));
30
+ /** Calls `op` on an adapter (the serving side of the protocol, also used to re-run calls locally). */
31
+ export async function invoke(s, op, a) {
32
+ switch (op) {
33
+ case "load":
34
+ return { ...((await s.load()) ?? {}), sync: plainSync(s.sync) };
35
+ case "getClientId":
36
+ return await s.persistPending.getClientId();
37
+ case "setClientId":
38
+ return void (await s.persistPending.setClientId(a[0]));
39
+ case "loadPending":
40
+ return await s.persistPending.load();
41
+ case "putPending":
42
+ return void (await s.persistPending.put(a[0]));
43
+ case "removePending":
44
+ return void (await s.persistPending.remove(a[0]));
45
+ case "applyPatch":
46
+ return void (await s.applyPatch(a[0]));
47
+ case "runQueryLocally":
48
+ return (await s.runQueryLocally(a[0])) ?? null;
49
+ case "setIdentity":
50
+ return void (await s.setIdentity?.(a[0]));
51
+ case "flush":
52
+ return void (await s.flush?.());
53
+ case "clear":
54
+ return void (await s.clear?.());
55
+ case "close":
56
+ return void (await s.close?.());
57
+ default:
58
+ throw new Error(`offline storage: unknown operation ${op}`);
59
+ }
60
+ }
61
+ /** Serves an adapter on an endpoint: answers calls addressed to it, streams watched results. */
62
+ export function serveStorage(storage, endpoint, options = {}) {
63
+ const address = options.address ?? "*";
64
+ // Once stopped (a leader leaving), calls still running here are not answered: their callers resend them to the
65
+ // next server (every op is idempotent), instead of settling with an error this server got while closing.
66
+ let stopped = false;
67
+ const peers = new Set();
68
+ const watches = new Map();
69
+ const post = (m) => {
70
+ if (!stopped)
71
+ endpoint.postMessage(m);
72
+ };
73
+ const watchesOf = (peer) => {
74
+ let w = watches.get(peer);
75
+ if (!w)
76
+ watches.set(peer, (w = new Map()));
77
+ return w;
78
+ };
79
+ const dropPeer = (peer) => {
80
+ for (const off of watches.get(peer)?.values() ?? [])
81
+ off();
82
+ watches.delete(peer);
83
+ peers.delete(peer);
84
+ };
85
+ const listener = (ev) => {
86
+ const m = ev.data;
87
+ if (!isMsg(m))
88
+ return;
89
+ if (m.k === "bye")
90
+ return dropPeer(m.from);
91
+ if (m.k !== "req" || (address !== "*" && m.to !== address))
92
+ return;
93
+ if (!peers.has(m.from)) {
94
+ peers.add(m.from);
95
+ options.onPeer?.(m.from);
96
+ }
97
+ if (m.op === "watch") {
98
+ const [wid, req] = m.a;
99
+ const mine = watchesOf(m.from);
100
+ mine.get(wid)?.();
101
+ const send = (r) => post({ ember: TAG, k: "watch", to: m.from, wid, r: r ?? null });
102
+ const fail = (e) => post({ ember: TAG, k: "watch", to: m.from, wid, e: toErr(e) });
103
+ if (storage.watchQueryLocally)
104
+ mine.set(wid, storage.watchQueryLocally(req, send, fail));
105
+ else {
106
+ // One-shot fallback for adapters without watches.
107
+ Promise.resolve(storage.runQueryLocally(req)).then(send, fail);
108
+ mine.set(wid, () => { });
109
+ }
110
+ return post({ ember: TAG, k: "res", id: m.id, to: m.from, ok: true });
111
+ }
112
+ if (m.op === "unwatch") {
113
+ const mine = watchesOf(m.from);
114
+ mine.get(m.a[0])?.();
115
+ mine.delete(m.a[0]);
116
+ return post({ ember: TAG, k: "res", id: m.id, to: m.from, ok: true });
117
+ }
118
+ invoke(storage, m.op, m.a).then((v) => post({ ember: TAG, k: "res", id: m.id, to: m.from, ok: true, v }), (e) => post({ ember: TAG, k: "res", id: m.id, to: m.from, ok: false, e: toErr(e) }));
119
+ };
120
+ endpoint.addEventListener("message", listener);
121
+ return {
122
+ announce: (from) => post({ ember: TAG, k: "leader", from }),
123
+ dropPeer,
124
+ stop() {
125
+ if (stopped)
126
+ return;
127
+ stopped = true;
128
+ endpoint.removeEventListener("message", listener);
129
+ for (const p of [...watches.keys()])
130
+ dropPeer(p);
131
+ },
132
+ };
133
+ }
134
+ /**
135
+ * The client side: a StorageAdapter whose calls run on a remote `serveStorage`. Calls in flight are resent when a
136
+ * new server announces itself (`k: "leader"`), and watches are re-established there.
137
+ */
138
+ export class RemoteStorage {
139
+ endpoint;
140
+ id;
141
+ persistPending;
142
+ syncSpecs = [];
143
+ seq = 0;
144
+ inflight = new Map();
145
+ watches = new Map();
146
+ /** Local unwatch functions while calls run locally. */
147
+ localWatches = new Map();
148
+ local;
149
+ /** No server to send to (see setUnavailable): calls fail with this until one announces itself. */
150
+ unavailable = null;
151
+ /** The server is gone for good (setUnavailable permanent): calls fail with this, announcements are ignored. */
152
+ dead = null;
153
+ to;
154
+ onLeader;
155
+ closed = false;
156
+ listener = (ev) => this.onMessage(ev.data);
157
+ constructor(endpoint, options = {}) {
158
+ this.endpoint = endpoint;
159
+ this.id = options.id ?? randomId();
160
+ this.to = options.to ?? "*";
161
+ this.local = options.local ?? null;
162
+ this.onLeader = options.onLeader;
163
+ endpoint.addEventListener("message", this.listener);
164
+ this.persistPending = {
165
+ getClientId: () => this.call("getClientId"),
166
+ setClientId: (id) => this.call("setClientId", id),
167
+ load: () => this.call("loadPending"),
168
+ put: (r) => this.call("putPending", r),
169
+ remove: (mid) => this.call("removePending", mid),
170
+ };
171
+ }
172
+ /** Known after `load()` (the server's `sync` specs, as names). */
173
+ get sync() {
174
+ return this.syncSpecs;
175
+ }
176
+ async load() {
177
+ const r = (await this.call("load"));
178
+ this.syncSpecs = r.sync ?? [];
179
+ return { identity: r.identity ?? null };
180
+ }
181
+ applyPatch(u) {
182
+ return this.call("applyPatch", u);
183
+ }
184
+ async runQueryLocally(req) {
185
+ return (await this.call("runQueryLocally", req)) ?? undefined;
186
+ }
187
+ setIdentity(identity) {
188
+ return this.call("setIdentity", identity);
189
+ }
190
+ flush() {
191
+ return this.call("flush");
192
+ }
193
+ clear() {
194
+ return this.call("clear");
195
+ }
196
+ watchQueryLocally(req, onResult, onError) {
197
+ const wid = ++this.seq;
198
+ this.watches.set(wid, { req, onResult, ...(onError ? { onError } : {}) });
199
+ this.establish(wid);
200
+ return () => {
201
+ if (!this.watches.delete(wid))
202
+ return;
203
+ const off = this.localWatches.get(wid);
204
+ if (off) {
205
+ this.localWatches.delete(wid);
206
+ off();
207
+ }
208
+ else if (!this.closed && !this.unavailable)
209
+ void this.call("unwatch", wid).catch(() => undefined);
210
+ };
211
+ }
212
+ establish(wid) {
213
+ const w = this.watches.get(wid);
214
+ if (!w || this.closed)
215
+ return;
216
+ if (this.unavailable) {
217
+ // Kept: established again when a server announces itself.
218
+ w.onError?.(this.unavailable);
219
+ return;
220
+ }
221
+ const local = this.local;
222
+ if (local?.watchQueryLocally) {
223
+ this.localWatches.set(wid, local.watchQueryLocally(w.req, w.onResult, w.onError));
224
+ return;
225
+ }
226
+ if (local) {
227
+ // One-shot fallback for adapters without watches, as serveStorage gives followers.
228
+ this.localWatches.set(wid, () => { });
229
+ const live = () => this.watches.get(wid) === w && this.local === local && !this.closed;
230
+ Promise.resolve()
231
+ .then(() => local.runQueryLocally(w.req))
232
+ .then((r) => live() && w.onResult(r), (e) => live() && w.onError?.(e));
233
+ return;
234
+ }
235
+ this.call("watch", wid, w.req).catch((e) => w.onError?.(e));
236
+ }
237
+ /**
238
+ * Run calls here from now on (null: post them again). Calls in flight are re-run on the new target and watches
239
+ * re-established there.
240
+ */
241
+ setLocal(local) {
242
+ if (this.dead)
243
+ return;
244
+ for (const off of this.localWatches.values())
245
+ off();
246
+ this.localWatches.clear();
247
+ this.local = local;
248
+ this.unavailable = null;
249
+ this.resend();
250
+ }
251
+ /**
252
+ * Nobody serves now (a tab that could not open the storage gave up the lead): calls in flight and new calls
253
+ * fail with `error` instead of waiting, watches get `error` once. The next server that announces itself ends
254
+ * this — calls go to it and watches are re-established there.
255
+ */
256
+ setUnavailable(error, options = {}) {
257
+ if (this.closed || this.dead)
258
+ return;
259
+ for (const off of this.localWatches.values())
260
+ off();
261
+ this.localWatches.clear();
262
+ this.local = null;
263
+ this.unavailable = error;
264
+ // Permanent (the server itself died, e.g. a crashed Worker): no announcement brings it back.
265
+ if (options.permanent)
266
+ this.dead = error;
267
+ for (const [id, c] of [...this.inflight]) {
268
+ this.inflight.delete(id);
269
+ if (c.op === "watch" || c.op === "unwatch")
270
+ c.resolve(undefined);
271
+ else
272
+ c.reject(error);
273
+ }
274
+ for (const wid of this.watches.keys())
275
+ this.establish(wid);
276
+ }
277
+ call(op, ...a) {
278
+ if (this.closed)
279
+ return Promise.reject(new Error("offline storage: closed"));
280
+ if (this.unavailable)
281
+ return Promise.reject(this.unavailable);
282
+ return new Promise((resolve, reject) => {
283
+ const id = ++this.seq;
284
+ this.inflight.set(id, { op, a, resolve, reject });
285
+ this.dispatch(id);
286
+ });
287
+ }
288
+ dispatch(id) {
289
+ const c = this.inflight.get(id);
290
+ if (!c)
291
+ return;
292
+ if (this.local) {
293
+ const local = this.local;
294
+ invoke(local, c.op, c.a).then((v) => {
295
+ if (this.inflight.get(id) !== c || this.local !== local)
296
+ return;
297
+ this.inflight.delete(id);
298
+ c.resolve(v);
299
+ }, (e) => {
300
+ if (this.inflight.get(id) !== c || this.local !== local)
301
+ return;
302
+ this.inflight.delete(id);
303
+ c.reject(e);
304
+ });
305
+ return;
306
+ }
307
+ this.endpoint.postMessage({ ember: TAG, k: "req", id, from: this.id, to: this.to, op: c.op, a: c.a });
308
+ }
309
+ /**
310
+ * A new server (or a local target): every call still in flight runs again there, in order; watches too. Each
311
+ * call goes out under a new request id, so only the server it was last sent to can answer it: a late answer of
312
+ * the previous server (to the old id) finds nothing.
313
+ */
314
+ resend() {
315
+ const calls = [...this.inflight].sort(([x], [y]) => x - y);
316
+ this.inflight.clear();
317
+ for (const [, c] of calls) {
318
+ // (Un)watch calls addressed the old server: every live watch is established afresh below.
319
+ if (c.op === "watch" || c.op === "unwatch")
320
+ c.resolve(undefined);
321
+ else {
322
+ const id = ++this.seq;
323
+ this.inflight.set(id, c);
324
+ this.dispatch(id);
325
+ }
326
+ }
327
+ for (const wid of this.watches.keys())
328
+ this.establish(wid);
329
+ }
330
+ onMessage(m) {
331
+ if (!isMsg(m) || this.closed)
332
+ return;
333
+ if (m.k === "leader") {
334
+ if (this.local || this.dead)
335
+ return;
336
+ this.unavailable = null;
337
+ this.resend();
338
+ this.onLeader?.(m.from);
339
+ return;
340
+ }
341
+ if (m.k === "res" && m.to === this.id) {
342
+ const c = this.inflight.get(m.id);
343
+ if (!c)
344
+ return;
345
+ this.inflight.delete(m.id);
346
+ if (m.ok)
347
+ c.resolve(m.v);
348
+ else
349
+ c.reject(fromErr(m.e));
350
+ return;
351
+ }
352
+ if (m.k === "watch" && m.to === this.id) {
353
+ const w = this.watches.get(m.wid);
354
+ if (!w)
355
+ return;
356
+ if (m.e)
357
+ w.onError?.(fromErr(m.e));
358
+ else
359
+ w.onResult(m.r ?? undefined);
360
+ }
361
+ }
362
+ /** Closes the served storage itself (then this caller): e.g. the Worker's database before terminating it. */
363
+ async shutdown() {
364
+ if (this.closed)
365
+ return;
366
+ await this.call("close").catch(() => undefined);
367
+ this.detach();
368
+ }
369
+ /** Stop: pending calls are rejected, the server drops this caller's watches. */
370
+ close() {
371
+ this.detach();
372
+ }
373
+ detach() {
374
+ if (this.closed)
375
+ return;
376
+ this.closed = true;
377
+ for (const off of this.localWatches.values())
378
+ off();
379
+ this.localWatches.clear();
380
+ this.watches.clear();
381
+ if (!this.local)
382
+ this.endpoint.postMessage({ ember: TAG, k: "bye", from: this.id });
383
+ this.endpoint.removeEventListener("message", this.listener);
384
+ for (const c of this.inflight.values())
385
+ c.reject(new Error("offline storage: closed"));
386
+ this.inflight.clear();
387
+ }
388
+ }
@@ -0,0 +1,30 @@
1
+ import type { SqlDriver, SqlValue } from "@freecorps/ember-server";
2
+ import { type OfflineOptions, type OfflineStore } from "./store.ts";
3
+ /** The part of an oo1 prepared statement this driver uses. */
4
+ export interface WasmSqliteStatement {
5
+ readonly columnCount: number;
6
+ bind(values: SqlValue[]): unknown;
7
+ step(): boolean;
8
+ get(target: Record<string, unknown>): Record<string, unknown>;
9
+ reset(clearBindings?: boolean): unknown;
10
+ finalize(): unknown;
11
+ }
12
+ /** The part of an oo1 `DB` (`sqlite3.oo1.DB`, `OpfsSAHPoolDb`, ...) this driver uses. */
13
+ export interface WasmSqliteDatabase {
14
+ prepare(sql: string): WasmSqliteStatement;
15
+ exec(sql: string): unknown;
16
+ changes(): number;
17
+ close(): unknown;
18
+ }
19
+ /** `SqlDriver` over an oo1 database: cached prepared statements, savepoints for nested transactions. */
20
+ export declare function wasmSqliteDriver(db: WasmSqliteDatabase): SqlDriver & {
21
+ close(): void;
22
+ };
23
+ export interface SqliteWasmStorageOptions extends OfflineOptions {
24
+ /** Database name prefix. Default `"ember"`. */
25
+ name?: string;
26
+ /** Opens (creating if needed) a database by name, e.g. `(n) => new poolUtil.OpfsSAHPoolDb(`/${n}.db`)`. */
27
+ open: (name: string) => WasmSqliteDatabase;
28
+ }
29
+ /** Offline storage on SQLite-Wasm (same DDL, SQL and FTS5 as `sqliteStorage`). Runs where the database lives. */
30
+ export declare function sqliteWasmStorage(options: SqliteWasmStorageOptions): OfflineStore;
@@ -0,0 +1,77 @@
1
+ // SQLite compiled to WebAssembly (`@sqlite.org/sqlite-wasm`, the SQLite project's official build: FTS5 included)
2
+ // as a `SqlDriver`, so the SQLite backend runs on it unchanged: the server's DDL, row codec, SQL compiler and FTS5
3
+ // search. Any oo1 database works — in-memory, `kvvfs`, or OPFS through the `opfs-sahpool` VFS (see opfs.ts, which
4
+ // runs it in a dedicated Worker). In Node the same build runs over an in-memory file system (tests).
5
+ //
6
+ // import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
7
+ // const sqlite3 = await sqlite3InitModule();
8
+ // sqliteWasmStorage({ schema, queries, open: (name) => new sqlite3.oo1.DB(`/${name}.db`, "c") })
9
+ import { SqliteBackend } from "./sqlite.js";
10
+ import { createOfflineStorage } from "./store.js";
11
+ const WRITES = /^\s*(INSERT|UPDATE|DELETE|REPLACE)/i;
12
+ /** `SqlDriver` over an oo1 database: cached prepared statements, savepoints for nested transactions. */
13
+ export function wasmSqliteDriver(db) {
14
+ const cache = new Map();
15
+ let sp = 0;
16
+ const finalizeAll = () => {
17
+ for (const s of cache.values())
18
+ s.finalize();
19
+ cache.clear();
20
+ };
21
+ return {
22
+ exec(sql, ...params) {
23
+ let stmt = cache.get(sql);
24
+ if (!stmt) {
25
+ if (cache.size > 500)
26
+ finalizeAll();
27
+ stmt = db.prepare(sql);
28
+ cache.set(sql, stmt);
29
+ }
30
+ try {
31
+ if (params.length)
32
+ stmt.bind(params);
33
+ const rows = [];
34
+ if (stmt.columnCount > 0)
35
+ while (stmt.step())
36
+ rows.push(stmt.get({}));
37
+ else
38
+ stmt.step();
39
+ const write = WRITES.test(sql);
40
+ return {
41
+ rows,
42
+ rowsRead: write ? 0 : rows.length,
43
+ rowsWritten: write ? (rows.length || db.changes()) : 0,
44
+ };
45
+ }
46
+ finally {
47
+ stmt.reset(true);
48
+ }
49
+ },
50
+ transaction(fn) {
51
+ const name = `ember_sp_${sp++}`;
52
+ db.exec(`SAVEPOINT ${name}`);
53
+ try {
54
+ const out = fn();
55
+ if (out && typeof out.then === "function") {
56
+ Promise.resolve(out).catch(() => { });
57
+ throw new Error("transaction(fn): fn must be synchronous");
58
+ }
59
+ db.exec(`RELEASE ${name}`);
60
+ return out;
61
+ }
62
+ catch (e) {
63
+ db.exec(`ROLLBACK TO ${name}`);
64
+ db.exec(`RELEASE ${name}`);
65
+ throw e;
66
+ }
67
+ },
68
+ close: () => {
69
+ finalizeAll();
70
+ db.close();
71
+ },
72
+ };
73
+ }
74
+ /** Offline storage on SQLite-Wasm (same DDL, SQL and FTS5 as `sqliteStorage`). Runs where the database lives. */
75
+ export function sqliteWasmStorage(options) {
76
+ return createOfflineStorage(new SqliteBackend(options.schema, (n) => wasmSqliteDriver(options.open(n)), options.name ?? "ember"), options);
77
+ }
package/dist/sqlite.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type PendingMutationRecord } from "@freecorps/ember-client";
2
2
  import { type LocalSchema, type SqlDriver } from "@freecorps/ember-server";
3
- import type { BackendReader, Commit, OfflineBackend, QueryRecord } from "./backend.ts";
3
+ import type { BackendReader, Commit, OfflineBackend, QueryLogEntry, QueryRecord } from "./backend.ts";
4
4
  import { type OfflineOptions, type OfflineStore } from "./store.ts";
5
5
  export type OpenSqlite = (name: string) => SqlDriver & {
6
6
  close?(): void;
@@ -23,6 +23,7 @@ export declare class SqliteBackend implements OfflineBackend {
23
23
  open(): Promise<{
24
24
  meta: Record<string, string>;
25
25
  queries: QueryRecord[];
26
+ log: QueryLogEntry[];
26
27
  }>;
27
28
  /** Opens (and migrates) a shard's database, registering it in the main database. */
28
29
  private shard;
package/dist/sqlite.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // `withSearchIndex` runs on FTS5 locally. A main database holds metadata, the pending queue and the shard list.
5
5
  //
6
6
  // <name> _ember_local_meta, _ember_local_pending, _ember_local_shards
7
- // <name>-<sha(shard)> schema tables + _ember_local_queries (records commit atomically with their rows)
7
+ // <name>-<sha(shard)> schema tables + _ember_local_queries + _ember_local_log (records, their row-diff log and
8
+ // their rows commit atomically)
8
9
  import { decodeJson, encodeJson } from "@freecorps/ember-client";
9
10
  import { jsSearch, prepareLocalDatabase, sha256Hex, sqlLocalReader, sqlReplicaWriter, } from "@freecorps/ember-server";
10
11
  import { createOfflineStorage } from "./store.js";
@@ -13,7 +14,10 @@ const MAIN_DDL = [
13
14
  `CREATE TABLE IF NOT EXISTS "_ember_local_pending" ("seq" INTEGER PRIMARY KEY AUTOINCREMENT, "mid" TEXT NOT NULL UNIQUE, "record" TEXT NOT NULL) STRICT`,
14
15
  `CREATE TABLE IF NOT EXISTS "_ember_local_shards" ("shard" TEXT PRIMARY KEY, "db" TEXT NOT NULL) STRICT`,
15
16
  ];
16
- const QUERIES_DDL = `CREATE TABLE IF NOT EXISTS "_ember_local_queries" ("key" TEXT PRIMARY KEY, "record" TEXT NOT NULL) STRICT`;
17
+ const QUERIES_DDL = [
18
+ `CREATE TABLE IF NOT EXISTS "_ember_local_queries" ("key" TEXT PRIMARY KEY, "record" TEXT NOT NULL) STRICT`,
19
+ `CREATE TABLE IF NOT EXISTS "_ember_local_log" ("key" TEXT NOT NULL, "seq" INTEGER NOT NULL, "entry" TEXT NOT NULL, PRIMARY KEY ("key", "seq")) STRICT`,
20
+ ];
17
21
  export class SqliteBackend {
18
22
  schema;
19
23
  openDb;
@@ -39,13 +43,16 @@ export class SqliteBackend {
39
43
  for (const r of main.exec(`SELECT "k", "v" FROM "_ember_local_meta"`).rows)
40
44
  meta[String(r.k)] = String(r.v);
41
45
  const queries = [];
46
+ const log = [];
42
47
  for (const r of main.exec(`SELECT "shard", "db" FROM "_ember_local_shards"`).rows) {
43
48
  this.known.set(String(r.shard), String(r.db));
44
49
  const s = this.shard(String(r.shard));
45
50
  for (const q of s.driver.exec(`SELECT "record" FROM "_ember_local_queries"`).rows)
46
51
  queries.push(decodeJson(String(q.record)));
52
+ for (const e of s.driver.exec(`SELECT "entry" FROM "_ember_local_log" ORDER BY "key", "seq"`).rows)
53
+ log.push(decodeJson(String(e.entry)));
47
54
  }
48
- return { meta, queries };
55
+ return { meta, queries, log };
49
56
  }
50
57
  /** Opens (and migrates) a shard's database, registering it in the main database. */
51
58
  shard(shard) {
@@ -60,7 +67,8 @@ export class SqliteBackend {
60
67
  }
61
68
  const driver = this.openDb(dbName);
62
69
  prepareLocalDatabase(driver, this.schema);
63
- driver.exec(QUERIES_DDL);
70
+ for (const ddl of QUERIES_DDL)
71
+ driver.exec(ddl);
64
72
  s = { driver, writer: sqlReplicaWriter(driver, this.schema), reader: sqlLocalReader(driver, this.schema) };
65
73
  this.shards.set(shard, s);
66
74
  return s;
@@ -85,9 +93,15 @@ export class SqliteBackend {
85
93
  s.driver.transaction(() => {
86
94
  for (const r of c.put ?? []) {
87
95
  s.driver.exec(`INSERT INTO "_ember_local_queries" ("key", "record") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "record" = excluded."record"`, r.key, encodeJson(r));
96
+ s.driver.exec(`DELETE FROM "_ember_local_log" WHERE "key" = ?`, r.key);
88
97
  }
89
- for (const k of c.drop ?? [])
98
+ for (const k of c.drop ?? []) {
90
99
  s.driver.exec(`DELETE FROM "_ember_local_queries" WHERE "key" = ?`, k);
100
+ s.driver.exec(`DELETE FROM "_ember_local_log" WHERE "key" = ?`, k);
101
+ }
102
+ for (const e of c.append ?? []) {
103
+ s.driver.exec(`INSERT OR REPLACE INTO "_ember_local_log" ("key", "seq", "entry") VALUES (?, ?, ?)`, e.key, e.seq, encodeJson(e));
104
+ }
91
105
  for (const { table, id } of c.delete ?? [])
92
106
  s.writer.delete(table, id);
93
107
  for (const { table, doc } of c.upsert ?? [])
package/dist/store.d.ts CHANGED
@@ -16,6 +16,12 @@ export interface OfflineOptions {
16
16
  maxQueries?: number;
17
17
  /** Records not refreshed by the server for this long are dropped on load (with the rows only they own). Default 30 days. */
18
18
  retentionMs?: number;
19
+ /**
20
+ * Store replicated documents in values as row references and persist a changed value as a row diff appended to
21
+ * the record's log (default true). `false` rewrites the whole value on every change (the previous format; kept
22
+ * for measurements and as an escape hatch).
23
+ */
24
+ persistDiffs?: boolean;
19
25
  now?: () => number;
20
26
  }
21
27
  export declare class OfflineStore implements StorageAdapter {
@@ -28,11 +34,15 @@ export declare class OfflineStore implements StorageAdapter {
28
34
  private readonly records;
29
35
  /** `${shard}\0${id}` → number of records owning the row. */
30
36
  private readonly refs;
31
- /** record key → id → stable JSON of the doc (lazy): unchanged rows are not rewritten. */
32
- private readonly sigs;
37
+ /** `${shard}\0${id}` → the row as last written / read (unknown after a restart until needed). */
38
+ private readonly rowDoc;
39
+ private readonly rowSig;
40
+ private readonly watches;
41
+ private rerunQueued;
33
42
  private meta;
34
43
  private chain;
35
44
  private opened;
45
+ private stopExternal;
36
46
  private readonly now;
37
47
  constructor(backend: OfflineBackend, options: OfflineOptions);
38
48
  /** Names of the queries that run locally. */
@@ -41,12 +51,18 @@ export declare class OfflineStore implements StorageAdapter {
41
51
  identity: LocalIdentity | null;
42
52
  }>;
43
53
  private open;
54
+ /** A stored record with its log applied. A log that does not apply marks the record broken (dropped on open). */
55
+ private replay;
44
56
  private ref;
45
57
  private enqueue;
46
58
  /** Resolves when every write handed to the store so far is committed. */
47
59
  flush(): Promise<void>;
48
60
  applyPatch(u: QueryUpdate): Promise<void>;
49
- private sigsOf;
61
+ /** Reads rows by id from the backend (async readers: fetch and retry until nothing is missing). */
62
+ private readRows;
63
+ /** The full value of a record (resolving its row references from the replica). */
64
+ private valueOf;
65
+ private know;
50
66
  private apply;
51
67
  /** Over `maxQueries`: drop the least recently refreshed records (never the one just written). */
52
68
  private evict;
@@ -56,8 +72,22 @@ export declare class OfflineStore implements StorageAdapter {
56
72
  */
57
73
  private dropRecords;
58
74
  runQueryLocally(req: LocalQueryRequest): Promise<LocalQueryResult | undefined>;
75
+ /** The local result of a request (registered: run over the replica; else / on failure: the cached value). */
76
+ private evaluate;
59
77
  /** Runs a registered query over a shard's replica (re-running after async fetches until nothing is missing). */
60
78
  private runLocal;
79
+ /**
80
+ * The local result of `req` now, and again whenever it may have changed: a registered query re-runs when rows
81
+ * inside its read set change; any request re-reads when the cached record of its args changes. Results equal to
82
+ * the last one sent are not re-sent.
83
+ */
84
+ watchQueryLocally(req: LocalQueryRequest, onResult: (r: LocalQueryResult | undefined) => void, onError?: (e: Error) => void): () => void;
85
+ private schedule;
86
+ private rerun;
87
+ /** Rows of `shard` changed (and these records were rewritten): mark the watches they may affect. */
88
+ private notify;
89
+ /** Someone else wrote rows (a shared TinyBase store): what we knew about them is stale; re-run what reads them. */
90
+ private onExternal;
61
91
  setIdentity(identity: LocalIdentity | null): Promise<void>;
62
92
  /** Forget every cached result and replicated row (e.g. on sign-out on a shared device). Pending mutations stay. */
63
93
  clear(): Promise<void>;