@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/dist/opfs.js ADDED
@@ -0,0 +1,66 @@
1
+ // The page half of OPFS storage: a `StorageAdapter` whose calls run in the app's offline Worker (opfs-worker.ts),
2
+ // where SQLite-Wasm owns the OPFS files. Local results are watched there and streamed back. The Worker reports its
3
+ // `sync` specs on load, so they are declared once, in the Worker.
4
+ //
5
+ // import { opfsStorage } from "@freecorps/ember-offline/opfs";
6
+ // new EmberClient({
7
+ // url,
8
+ // storage: opfsStorage({ worker: new Worker(new URL("./ember-offline.worker.ts", import.meta.url), { type: "module" }) }),
9
+ // optimisticUpdates: { "messages:send": addMessage },
10
+ // });
11
+ //
12
+ // If OPFS or the Worker fails to start, `load()` rejects and the client carries on without offline storage
13
+ // (`storage_error`), as with any adapter.
14
+ import { RemoteStorage } from "./remote.js";
15
+ export class OpfsStorage extends RemoteStorage {
16
+ options;
17
+ worker;
18
+ failed;
19
+ constructor(options) {
20
+ const worker = typeof options.worker === "function" ? options.worker() : options.worker;
21
+ super(worker);
22
+ this.options = options;
23
+ this.worker = worker;
24
+ // A Worker that cannot load (bad URL, syntax error, no module workers) fails the load instead of hanging it.
25
+ let fail = () => { };
26
+ this.failed = new Promise((_, reject) => (fail = reject));
27
+ this.failed.catch(() => undefined);
28
+ // After a successful load too: a Worker that reports an error (crash, wasm abort, OOM) is not trusted again —
29
+ // calls in flight and later ones fail with the error, watches get it once, and the Worker is terminated so it
30
+ // releases its OPFS handles (and, with several tabs, the leader lock for the next tab).
31
+ const w = worker;
32
+ w.addEventListener?.("error", (e) => {
33
+ const error = new Error(`offline worker failed: ${e?.message ?? "error"}`);
34
+ fail(error);
35
+ this.setUnavailable(error, { permanent: true });
36
+ this.terminate();
37
+ });
38
+ }
39
+ terminated = false;
40
+ terminate() {
41
+ if (this.terminated || this.options.terminate === false)
42
+ return;
43
+ this.terminated = true;
44
+ this.worker.terminate?.();
45
+ }
46
+ async load() {
47
+ return await Promise.race([super.load(), this.failed]);
48
+ }
49
+ async close() {
50
+ // A dead Worker never answers "close": stop waiting when it has failed or after the timeout.
51
+ let timer;
52
+ const timeout = new Promise((r) => (timer = setTimeout(r, this.options.closeTimeoutMs ?? 5000)));
53
+ try {
54
+ await Promise.race([this.shutdown(), this.failed.catch(() => undefined), timeout]);
55
+ }
56
+ finally {
57
+ clearTimeout(timer);
58
+ this.detach();
59
+ this.terminate();
60
+ }
61
+ }
62
+ }
63
+ /** Offline storage on SQLite-Wasm over OPFS, run in a dedicated Worker (see opfs-worker.ts). */
64
+ export function opfsStorage(options) {
65
+ return new OpfsStorage(options);
66
+ }
@@ -0,0 +1,109 @@
1
+ import { type LocalIdentity, type LocalQueryRequest, type LocalQueryResult, type PendingStorage, type QueryUpdate, type StorageAdapter, type SyncSpec } from "@freecorps/ember-client";
2
+ /** Anything that carries messages both ways (Worker, `self` in a worker, MessagePort, BroadcastChannel). */
3
+ export interface Endpoint {
4
+ postMessage(message: unknown): void;
5
+ addEventListener(type: "message", listener: (e: {
6
+ data: unknown;
7
+ }) => void): void;
8
+ removeEventListener(type: "message", listener: (e: {
9
+ data: unknown;
10
+ }) => void): void;
11
+ }
12
+ /** What can be served: a StorageAdapter, optionally with `watchQueryLocally` and `clear` (OfflineStore has both). */
13
+ export type ServableStorage = StorageAdapter & {
14
+ clear?(): Promise<void>;
15
+ };
16
+ type Op = "load" | "getClientId" | "setClientId" | "loadPending" | "putPending" | "removePending" | "applyPatch" | "runQueryLocally" | "setIdentity" | "flush" | "clear" | "close" | "watch" | "unwatch";
17
+ export declare const randomId: () => string;
18
+ /** Calls `op` on an adapter (the serving side of the protocol, also used to re-run calls locally). */
19
+ export declare function invoke(s: ServableStorage, op: Op, a: unknown[]): Promise<unknown>;
20
+ export interface ServeOptions {
21
+ /** The address requests must be sent to (default `"*"`: any). */
22
+ address?: string;
23
+ /** First message from a caller (tabs.ts watches its liveness). */
24
+ onPeer?(peer: string): void;
25
+ }
26
+ export interface Served {
27
+ /** Announce this server (callers resend what is in flight). */
28
+ announce(from: string): void;
29
+ /** Forget a caller's watches (it is gone). */
30
+ dropPeer(peer: string): void;
31
+ stop(): void;
32
+ }
33
+ /** Serves an adapter on an endpoint: answers calls addressed to it, streams watched results. */
34
+ export declare function serveStorage(storage: ServableStorage, endpoint: Endpoint, options?: ServeOptions): Served;
35
+ export interface RemoteStorageOptions {
36
+ /** This caller's address (default: random). */
37
+ id?: string;
38
+ /** The server's address (default `"*"`). */
39
+ to?: string;
40
+ /** Instead of posting, run calls here (a tab that became the leader runs its own calls locally). */
41
+ local?: ServableStorage | null;
42
+ /** A server announced itself (after the calls in flight were resent to it). */
43
+ onLeader?(from: string): void;
44
+ }
45
+ /**
46
+ * The client side: a StorageAdapter whose calls run on a remote `serveStorage`. Calls in flight are resent when a
47
+ * new server announces itself (`k: "leader"`), and watches are re-established there.
48
+ */
49
+ export declare class RemoteStorage implements StorageAdapter {
50
+ private readonly endpoint;
51
+ readonly id: string;
52
+ readonly persistPending: PendingStorage;
53
+ private syncSpecs;
54
+ private seq;
55
+ private readonly inflight;
56
+ private readonly watches;
57
+ /** Local unwatch functions while calls run locally. */
58
+ private readonly localWatches;
59
+ private local;
60
+ /** No server to send to (see setUnavailable): calls fail with this until one announces itself. */
61
+ private unavailable;
62
+ /** The server is gone for good (setUnavailable permanent): calls fail with this, announcements are ignored. */
63
+ private dead;
64
+ private readonly to;
65
+ private readonly onLeader;
66
+ private closed;
67
+ private readonly listener;
68
+ constructor(endpoint: Endpoint, options?: RemoteStorageOptions);
69
+ /** Known after `load()` (the server's `sync` specs, as names). */
70
+ get sync(): readonly SyncSpec[];
71
+ load(): Promise<{
72
+ identity?: LocalIdentity | null;
73
+ }>;
74
+ applyPatch(u: QueryUpdate): Promise<void>;
75
+ runQueryLocally(req: LocalQueryRequest): Promise<LocalQueryResult | undefined>;
76
+ setIdentity(identity: LocalIdentity | null): Promise<void>;
77
+ flush(): Promise<void>;
78
+ clear(): Promise<void>;
79
+ watchQueryLocally(req: LocalQueryRequest, onResult: (r: LocalQueryResult | undefined) => void, onError?: (e: Error) => void): () => void;
80
+ private establish;
81
+ /**
82
+ * Run calls here from now on (null: post them again). Calls in flight are re-run on the new target and watches
83
+ * re-established there.
84
+ */
85
+ setLocal(local: ServableStorage | null): void;
86
+ /**
87
+ * Nobody serves now (a tab that could not open the storage gave up the lead): calls in flight and new calls
88
+ * fail with `error` instead of waiting, watches get `error` once. The next server that announces itself ends
89
+ * this — calls go to it and watches are re-established there.
90
+ */
91
+ setUnavailable(error: Error, options?: {
92
+ permanent?: boolean;
93
+ }): void;
94
+ private call;
95
+ private dispatch;
96
+ /**
97
+ * A new server (or a local target): every call still in flight runs again there, in order; watches too. Each
98
+ * call goes out under a new request id, so only the server it was last sent to can answer it: a late answer of
99
+ * the previous server (to the old id) finds nothing.
100
+ */
101
+ private resend;
102
+ private onMessage;
103
+ /** Closes the served storage itself (then this caller): e.g. the Worker's database before terminating it. */
104
+ shutdown(): Promise<void>;
105
+ /** Stop: pending calls are rejected, the server drops this caller's watches. */
106
+ close(): void | Promise<void>;
107
+ protected detach(): void;
108
+ }
109
+ export {};
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
+ }