@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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @freecorps/ember-offline
2
2
 
3
- Offline-first storage for Ember clients: persisted query results, replicated rows and local query execution over IndexedDB, SQLite or expo-sqlite.
3
+ Offline-first storage for Ember clients: persisted query results, replicated rows and reactive local query execution over IndexedDB, SQLite (expo-sqlite, SQLite-Wasm on OPFS) or TinyBase, coordinated across tabs.
4
4
 
5
5
  Part of [Ember](https://github.com/freecorps/ember), a Convex-style backend on Cloudflare Workers and Durable
6
6
  Objects. Source: [`packages/offline`](https://github.com/freecorps/ember/tree/main/packages/offline).
package/dist/backend.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PendingMutationRecord } from "@freecorps/ember-client";
2
2
  import type { Doc, LocalReader } from "@freecorps/ember-server";
3
+ import type { RowDiff } from "@freecorps/ember-values";
3
4
  /** The last server value of one subscribed query, and the replicated rows it owns. */
4
5
  export interface QueryRecord {
5
6
  /** `${shard}\u0000${fn}\u0000${argsJson}` */
@@ -8,18 +9,45 @@ export interface QueryRecord {
8
9
  fn: string;
9
10
  args: Record<string, unknown>;
10
11
  argsJson: string;
12
+ /**
13
+ * The value (the base of the log when entries follow). With `refs`, the replicated documents in it are stored
14
+ * as row references `{ _id, $row: 1 }` and read back from the replicated rows.
15
+ */
11
16
  value: unknown;
12
17
  version: number;
18
+ /** The shard epoch of `version` (absent in records written before it, or when the client did not know it). */
19
+ epoch?: string;
13
20
  /** ms; refreshed on every server value (retention / eviction). */
14
21
  updatedAt: number;
15
22
  /** `_id`s of the complete documents in `value` (rows owned by this record). */
16
23
  ids: string[];
24
+ /** `value` holds row references (format 2). Absent in records written before it. */
25
+ refs?: true;
26
+ /** Encoded size of `value` (bounds the log: entries never outgrow the base). */
27
+ size?: number;
17
28
  }
18
- /** One atomic write to a shard: query records and the rows they own. */
29
+ /**
30
+ * One appended change of a record: the row diff from the previous stored value (references stay references), the
31
+ * new version, and the owned ids that came and went. Replayed over `QueryRecord.value` on open, in `seq` order;
32
+ * a `put` of the record (compaction) removes its entries.
33
+ */
34
+ export interface QueryLogEntry {
35
+ key: string;
36
+ seq: number;
37
+ diff: RowDiff;
38
+ version: number;
39
+ epoch?: string;
40
+ updatedAt: number;
41
+ add: string[];
42
+ remove: string[];
43
+ }
44
+ /** One atomic write to a shard: query records, their log, and the rows they own. */
19
45
  export interface Commit {
46
+ /** Records written whole (their log entries are removed). */
20
47
  put?: QueryRecord[];
21
- /** Record keys to remove. */
48
+ /** Record keys to remove (with their log). */
22
49
  drop?: string[];
50
+ append?: QueryLogEntry[];
23
51
  upsert?: Array<{
24
52
  table: string;
25
53
  doc: Doc;
@@ -37,11 +65,17 @@ export interface BackendReader extends LocalReader {
37
65
  readonly misses?: number;
38
66
  fetch?(): Promise<void>;
39
67
  }
68
+ /** Rows a backend saw change outside `commit` (another writer of a shared store): tables per shard. */
69
+ export type ExternalChange = {
70
+ shard: string;
71
+ tables: string[];
72
+ };
40
73
  export interface OfflineBackend {
41
74
  /** Open / migrate (a schema change the planner refuses resets the replica; pending survives). */
42
75
  open(): Promise<{
43
76
  meta: Record<string, string>;
44
77
  queries: QueryRecord[];
78
+ log?: QueryLogEntry[];
45
79
  }>;
46
80
  setMeta(key: string, value: string | null): Promise<void> | void;
47
81
  loadPending(): Promise<PendingMutationRecord[]> | PendingMutationRecord[];
@@ -49,6 +83,8 @@ export interface OfflineBackend {
49
83
  removePending(mid: string): Promise<void> | void;
50
84
  commit(shard: string, c: Commit): Promise<void> | void;
51
85
  reader(shard: string): BackendReader;
86
+ /** Optional: report row changes made by someone else (e.g. the app writing to a shared TinyBase store). */
87
+ onExternalChange?(listener: (c: ExternalChange) => void): () => void;
52
88
  close(): Promise<void> | void;
53
89
  }
54
90
  /** Thrown by async readers on a miss (see `BackendReader`). */
package/dist/backend.js CHANGED
@@ -1,7 +1,7 @@
1
- // The storage seam under the generic offline store (store.ts). A backend persists four things — metadata, the
2
- // pending mutation queue, query records, and replicated rows per shard — and serves synchronous reads of one
3
- // shard's rows to `runLocalQuery`. Backends: memory (memory.ts), SQLite (sqlite.ts, expo-sqlite.ts), IndexedDB
4
- // (indexeddb.ts).
1
+ // The storage seam under the generic offline store (store.ts). A backend persists five things — metadata, the
2
+ // pending mutation queue, query records, their diff logs, and replicated rows per shard — and serves synchronous
3
+ // reads of one shard's rows to `runLocalQuery`. Backends: memory (memory.ts), SQLite (sqlite.ts, expo-sqlite.ts,
4
+ // sqlite-wasm.ts / opfs), IndexedDB (indexeddb.ts), TinyBase (tinybase.ts).
5
5
  /** Thrown by async readers on a miss (see `BackendReader`). */
6
6
  export class NeedData extends Error {
7
7
  constructor() {
package/dist/index.d.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * `@freecorps/ember-offline`: optional offline-first storage for `@freecorps/ember-client` (PLAN §10).
3
3
  *
4
+ * import { opfsStorage } from "@freecorps/ember-offline/opfs"; // browsers: SQLite-Wasm on OPFS (Worker:
5
+ * // serveOpfsStorage from "/opfs-worker")
4
6
  * import { indexedDbStorage } from "@freecorps/ember-offline/indexeddb"; // browsers
5
7
  * import { expoSqliteStorage } from "@freecorps/ember-offline/expo-sqlite"; // React Native / Expo
8
+ * import { tinybaseStorage } from "@freecorps/ember-offline/tinybase"; // the replica in a TinyBase Store
6
9
  * import { sqliteStorage } from "@freecorps/ember-offline/sqlite"; // any synchronous SqlDriver
10
+ * import { sqliteWasmStorage } from "@freecorps/ember-offline/sqlite-wasm"; // any SQLite-Wasm oo1 database
11
+ * import { coordinateTabs } from "@freecorps/ember-offline"; // several tabs, one storage
7
12
  *
8
13
  * new EmberClient({
9
14
  * url,
@@ -14,7 +19,9 @@
14
19
  * Contract and limits: docs/design/contracts.md, "Offline".
15
20
  */
16
21
  export { createOfflineStorage, OfflineStore, type OfflineOptions } from "./store.ts";
17
- export { NeedData, recordKey, type BackendReader, type Commit, type OfflineBackend, type QueryRecord } from "./backend.ts";
22
+ export { NeedData, recordKey, type BackendReader, type Commit, type ExternalChange, type OfflineBackend, type QueryLogEntry, type QueryRecord, } from "./backend.ts";
18
23
  export { MemoryBackend, memoryStorage } from "./memory.ts";
24
+ export { coordinateTabs, TabCoordinator, type LockManagerLike, type TabRole, type TabsOptions } from "./tabs.ts";
25
+ export { RemoteStorage, serveStorage, type Endpoint, type ServableStorage, type Served, type ServeOptions } from "./remote.ts";
19
26
  export type { LocalSchema } from "@freecorps/ember-server";
20
27
  export type { StorageAdapter, SyncSpec, LocalQueryResult, QueryUpdate } from "@freecorps/ember-client";
package/dist/index.js CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * `@freecorps/ember-offline`: optional offline-first storage for `@freecorps/ember-client` (PLAN §10).
3
3
  *
4
+ * import { opfsStorage } from "@freecorps/ember-offline/opfs"; // browsers: SQLite-Wasm on OPFS (Worker:
5
+ * // serveOpfsStorage from "/opfs-worker")
4
6
  * import { indexedDbStorage } from "@freecorps/ember-offline/indexeddb"; // browsers
5
7
  * import { expoSqliteStorage } from "@freecorps/ember-offline/expo-sqlite"; // React Native / Expo
8
+ * import { tinybaseStorage } from "@freecorps/ember-offline/tinybase"; // the replica in a TinyBase Store
6
9
  * import { sqliteStorage } from "@freecorps/ember-offline/sqlite"; // any synchronous SqlDriver
10
+ * import { sqliteWasmStorage } from "@freecorps/ember-offline/sqlite-wasm"; // any SQLite-Wasm oo1 database
11
+ * import { coordinateTabs } from "@freecorps/ember-offline"; // several tabs, one storage
7
12
  *
8
13
  * new EmberClient({
9
14
  * url,
@@ -14,5 +19,7 @@
14
19
  * Contract and limits: docs/design/contracts.md, "Offline".
15
20
  */
16
21
  export { createOfflineStorage, OfflineStore } from "./store.js";
17
- export { NeedData, recordKey } from "./backend.js";
22
+ export { NeedData, recordKey, } from "./backend.js";
18
23
  export { MemoryBackend, memoryStorage } from "./memory.js";
24
+ export { coordinateTabs, TabCoordinator } from "./tabs.js";
25
+ export { RemoteStorage, serveStorage } from "./remote.js";
@@ -1,6 +1,6 @@
1
1
  import type { PendingMutationRecord } from "@freecorps/ember-client";
2
2
  import { type LocalSchema } from "@freecorps/ember-server";
3
- import { type BackendReader, type Commit, type OfflineBackend, type QueryRecord } from "./backend.ts";
3
+ import { type BackendReader, type Commit, type OfflineBackend, type QueryLogEntry, type QueryRecord } from "./backend.ts";
4
4
  import { type OfflineOptions, type OfflineStore } from "./store.ts";
5
5
  export interface IndexedDbStorageOptions extends OfflineOptions {
6
6
  /** Database name. Default `"ember"`. */
@@ -23,6 +23,7 @@ export declare class IndexedDbBackend implements OfflineBackend {
23
23
  open(): Promise<{
24
24
  meta: Record<string, string>;
25
25
  queries: QueryRecord[];
26
+ log: QueryLogEntry[];
26
27
  }>;
27
28
  setMeta(key: string, value: string | null): Promise<void>;
28
29
  loadPending(): Promise<PendingMutationRecord[]>;
package/dist/indexeddb.js CHANGED
@@ -18,6 +18,7 @@ import { createOfflineStorage } from "./store.js";
18
18
  const META = "meta";
19
19
  const PENDING = "pending";
20
20
  const QUERIES = "queries";
21
+ const LOG = "log";
21
22
  const tableStore = (table) => `t:${table}`;
22
23
  const keyProp = (index) => `_k_${index}`;
23
24
  const buf = (u) => u.slice().buffer;
@@ -65,11 +66,14 @@ export class IndexedDbBackend {
65
66
  db.createObjectStore(PENDING, { autoIncrement: true }).createIndex("mid", "mid", { unique: true });
66
67
  if (!db.objectStoreNames.contains(QUERIES))
67
68
  db.createObjectStore(QUERIES, { keyPath: "key" });
69
+ if (!db.objectStoreNames.contains(LOG))
70
+ db.createObjectStore(LOG, { keyPath: ["key", "seq"] });
68
71
  if (ev.oldVersion === 0 || reset) {
69
72
  for (const n of Array.from(db.objectStoreNames))
70
73
  if (n.startsWith("t:"))
71
74
  db.deleteObjectStore(n);
72
75
  tx.objectStore(QUERIES).clear();
76
+ tx.objectStore(LOG).clear();
73
77
  for (const [table, t] of Object.entries(this.schema.manifest.tables)) {
74
78
  const s = db.createObjectStore(tableStore(table), { keyPath: ["s", "id"] });
75
79
  for (const i of dbIndexesOf(t))
@@ -90,25 +94,27 @@ export class IndexedDbBackend {
90
94
  async open() {
91
95
  let db = await this.openDb();
92
96
  const stored = await req(db.transaction(META).objectStore(META).get("schema"));
93
- if (stored !== this.schema.manifest.version) {
97
+ if (stored !== this.schema.manifest.version || !db.objectStoreNames.contains(LOG)) {
98
+ // A new schema resets the replica; a database from before the log only gains the store.
94
99
  const v = db.version + 1;
95
100
  db.close();
96
- db = await this.openDb(v, true);
101
+ db = await this.openDb(v, stored !== this.schema.manifest.version);
97
102
  }
98
103
  this.db = db;
99
- const tx = db.transaction([META, QUERIES]);
104
+ const tx = db.transaction([META, QUERIES, LOG]);
100
105
  const metaStore = tx.objectStore(META);
101
- const [keys, values, queries] = await Promise.all([
106
+ const [keys, values, queries, log] = await Promise.all([
102
107
  req(metaStore.getAllKeys()),
103
108
  req(metaStore.getAll()),
104
109
  req(tx.objectStore(QUERIES).getAll()),
110
+ req(tx.objectStore(LOG).getAll()),
105
111
  ]);
106
112
  const meta = {};
107
113
  keys.forEach((k, i) => {
108
114
  if (typeof k === "string" && k !== "schema" && typeof values[i] === "string")
109
115
  meta[k] = values[i];
110
116
  });
111
- return { meta, queries };
117
+ return { meta, queries, log };
112
118
  }
113
119
  async setMeta(key, value) {
114
120
  const tx = this.conn.transaction(META, "readwrite");
@@ -145,12 +151,20 @@ export class IndexedDbBackend {
145
151
  }
146
152
  async commit(shard, c) {
147
153
  const tables = new Set([...(c.upsert ?? []), ...(c.delete ?? [])].map((x) => tableStore(x.table)));
148
- const tx = this.conn.transaction([QUERIES, ...tables], "readwrite");
154
+ const tx = this.conn.transaction([QUERIES, LOG, ...tables], "readwrite");
149
155
  const q = tx.objectStore(QUERIES);
150
- for (const r of c.put ?? [])
156
+ const log = tx.objectStore(LOG);
157
+ const entriesOf = (key) => this.Range.bound([key], [key, []]);
158
+ for (const r of c.put ?? []) {
151
159
  q.put(r);
152
- for (const k of c.drop ?? [])
160
+ log.delete(entriesOf(r.key));
161
+ }
162
+ for (const k of c.drop ?? []) {
153
163
  q.delete(k);
164
+ log.delete(entriesOf(k));
165
+ }
166
+ for (const e of c.append ?? [])
167
+ log.put(e);
154
168
  for (const { table, id } of c.delete ?? [])
155
169
  tx.objectStore(tableStore(table)).delete([shard, id]);
156
170
  for (const { table, doc } of c.upsert ?? [])
package/dist/memory.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { PendingMutationRecord } from "@freecorps/ember-client";
2
2
  import { type Doc, type LocalSchema } 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 declare class MemoryBackend implements OfflineBackend {
6
6
  readonly meta: Map<string, string>;
7
7
  readonly pending: Map<string, PendingMutationRecord>;
8
8
  readonly queries: Map<string, QueryRecord>;
9
+ readonly log: Map<string, QueryLogEntry[]>;
9
10
  /** shard → table → id → doc */
10
11
  readonly rows: Map<string, Map<string, Map<string, Doc>>>;
11
12
  /** Sorted rows per `shard\0table\0index`, rebuilt after writes. */
@@ -19,6 +20,7 @@ export declare class MemoryBackend implements OfflineBackend {
19
20
  [k: string]: string;
20
21
  };
21
22
  queries: QueryRecord[];
23
+ log: QueryLogEntry[];
22
24
  }>;
23
25
  setMeta(key: string, value: string | null): void;
24
26
  loadPending(): PendingMutationRecord[];
package/dist/memory.js CHANGED
@@ -7,6 +7,7 @@ export class MemoryBackend {
7
7
  meta = new Map();
8
8
  pending = new Map();
9
9
  queries = new Map();
10
+ log = new Map();
10
11
  /** shard → table → id → doc */
11
12
  rows = new Map();
12
13
  /** Sorted rows per `shard\0table\0index`, rebuilt after writes. */
@@ -22,10 +23,11 @@ export class MemoryBackend {
22
23
  if (this.version !== null && this.version !== v) {
23
24
  this.rows.clear();
24
25
  this.queries.clear();
26
+ this.log.clear();
25
27
  this.sorted.clear();
26
28
  }
27
29
  this.version = v;
28
- return { meta: Object.fromEntries(this.meta), queries: [...this.queries.values()] };
30
+ return { meta: Object.fromEntries(this.meta), queries: [...this.queries.values()], log: [...this.log.values()].flat() };
29
31
  }
30
32
  setMeta(key, value) {
31
33
  if (value === null)
@@ -52,10 +54,21 @@ export class MemoryBackend {
52
54
  return t;
53
55
  }
54
56
  commit(shard, c) {
55
- for (const r of c.put ?? [])
57
+ for (const r of c.put ?? []) {
56
58
  this.queries.set(r.key, r);
57
- for (const k of c.drop ?? [])
59
+ this.log.delete(r.key);
60
+ }
61
+ for (const k of c.drop ?? []) {
58
62
  this.queries.delete(k);
63
+ this.log.delete(k);
64
+ }
65
+ // Keyed by (key, seq), like the SQLite (INSERT OR REPLACE) and IndexedDB (put) logs.
66
+ for (const e of c.append ?? []) {
67
+ const list = (this.log.get(e.key) ?? []).filter((x) => x.seq !== e.seq);
68
+ list.push(e);
69
+ list.sort((a, b) => a.seq - b.seq);
70
+ this.log.set(e.key, list);
71
+ }
59
72
  for (const { table, doc } of c.upsert ?? [])
60
73
  this.table(shard, table).set(doc._id, doc);
61
74
  for (const { table, id } of c.delete ?? [])
@@ -0,0 +1,52 @@
1
+ import { type Endpoint, type Served } from "./remote.ts";
2
+ import { type WasmSqliteDatabase } from "./sqlite-wasm.ts";
3
+ import type { OfflineOptions, OfflineStore } from "./store.ts";
4
+ import { type LockManagerLike, type TabCoordinator, type TabRole } from "./tabs.ts";
5
+ /** The part of the SQLite-Wasm module (`await sqlite3InitModule()`) used here. */
6
+ export interface Sqlite3Module {
7
+ installOpfsSAHPoolVfs(opts: {
8
+ name?: string;
9
+ directory?: string;
10
+ initialCapacity?: number;
11
+ clearOnInit?: boolean;
12
+ }): Promise<SahPool>;
13
+ }
14
+ /** The part of the `opfs-sahpool` pool utility used here. */
15
+ export interface SahPool {
16
+ OpfsSAHPoolDb: new (filename: string) => WasmSqliteDatabase;
17
+ reserveMinimumCapacity(n: number): Promise<number>;
18
+ pauseVfs(): unknown;
19
+ unpauseVfs(): Promise<unknown>;
20
+ isPaused(): boolean;
21
+ }
22
+ export interface OpfsWorkerOptions extends OfflineOptions {
23
+ /** Storage name (OPFS directory `.ember-<name>`, database prefix, tab lock / channel). Default `"ember"`. */
24
+ name?: string;
25
+ /** Loads SQLite-Wasm. Default: `import("@sqlite.org/sqlite-wasm")` and its default init. */
26
+ sqlite3?: () => Promise<Sqlite3Module>;
27
+ /**
28
+ * Files the pool keeps open: two per database (data + journal) — the main database plus one per shard. Default 16
29
+ * (7 shards). Raise it for apps that use many shards.
30
+ */
31
+ capacity?: number;
32
+ /** Coordinate the tabs that open this storage (default true; needs Web Locks). */
33
+ multiTab?: boolean;
34
+ /** Tests: the lock manager / channel (default `navigator.locks`, `BroadcastChannel`). */
35
+ locks?: LockManagerLike | null;
36
+ channel?: Endpoint & {
37
+ close(): void;
38
+ };
39
+ /** Where the page is (default: the worker global scope). */
40
+ scope?: Endpoint;
41
+ /** This Worker became the leader (owns the files) / a follower / closed. */
42
+ onRoleChange?(role: TabRole): void;
43
+ }
44
+ /** Opens OPFS storage in this Worker (the leader's job). Call `close()` to release the files (pauses the VFS). */
45
+ export declare function openOpfsStore(options: OpfsWorkerOptions, loaded?: {
46
+ pool?: SahPool;
47
+ }): Promise<OfflineStore>;
48
+ /** Serves OPFS storage to the page (`opfsStorage`), coordinating with the other tabs' Workers. */
49
+ export declare function serveOpfsStorage(options: OpfsWorkerOptions): {
50
+ storage: TabCoordinator;
51
+ served: Served;
52
+ };
@@ -0,0 +1,72 @@
1
+ // The Worker half of OPFS storage: SQLite-Wasm (`@sqlite.org/sqlite-wasm`, optional peer) on the Origin Private
2
+ // File System through the `opfs-sahpool` VFS. Sync access handles exist only in dedicated Workers, and the SQL path
3
+ // is synchronous (`SqlDriver`), so the whole offline store runs here — the server's DDL, row codec, SQL compiler and
4
+ // FTS5 search, exactly as `sqliteStorage` — and the page talks to it with `opfsStorage` (opfs.ts) over postMessage.
5
+ // `opfs-sahpool` needs no COOP/COEP headers (no SharedArrayBuffer).
6
+ //
7
+ // Tabs: the pool's files can be held by one Worker at a time, so the Workers of every tab elect a leader with Web
8
+ // Locks (tabs.ts); only the leader installs the VFS and opens the databases, the others forward over a
9
+ // BroadcastChannel. When the leader goes away, the next Worker installs the VFS (retrying while the old handles
10
+ // are released) and takes over.
11
+ //
12
+ // // ember-offline.worker.ts — bundled as a module worker; imports the same schema and query() definitions
13
+ // import { serveOpfsStorage } from "@freecorps/ember-offline/opfs-worker";
14
+ // import schema from "../ember/schema";
15
+ // import { list, search } from "../ember/messages";
16
+ // serveOpfsStorage({ name: "chat", schema, queries: { "messages:list": list, "messages:search": search },
17
+ // sync: [{ query: "messages:recent", args: { days: 90 } }] });
18
+ import { serveStorage } from "./remote.js";
19
+ import { sqliteWasmStorage } from "./sqlite-wasm.js";
20
+ import { coordinateTabs } from "./tabs.js";
21
+ // Only apps that import this module (OPFS users) make their bundler resolve the optional peer.
22
+ const defaultSqlite3 = async () => {
23
+ const m = (await import("@sqlite.org/sqlite-wasm"));
24
+ return await m.default();
25
+ };
26
+ /** Opens OPFS storage in this Worker (the leader's job). Call `close()` to release the files (pauses the VFS). */
27
+ export async function openOpfsStore(options, loaded) {
28
+ const name = options.name ?? "ember";
29
+ let pool = loaded?.pool;
30
+ if (!pool) {
31
+ const sqlite3 = await (options.sqlite3 ?? defaultSqlite3)();
32
+ pool = await sqlite3.installOpfsSAHPoolVfs({ name: `ember-${name}`, directory: `.ember-${name}`, initialCapacity: options.capacity ?? 16 });
33
+ if (loaded)
34
+ loaded.pool = pool;
35
+ }
36
+ else if (pool.isPaused())
37
+ await pool.unpauseVfs();
38
+ const p = pool;
39
+ try {
40
+ await p.reserveMinimumCapacity(options.capacity ?? 16);
41
+ }
42
+ catch (e) {
43
+ // Do not keep the files while failing: another tab's Worker (or our next attempt) must be able to take them.
44
+ if (!p.isPaused())
45
+ p.pauseVfs();
46
+ throw e;
47
+ }
48
+ const store = sqliteWasmStorage({ ...options, name, open: (n) => new p.OpfsSAHPoolDb(`/${n}.db`) });
49
+ const close = store.close.bind(store);
50
+ store.close = async () => {
51
+ await close();
52
+ // Release the access handles so another tab's Worker can take over.
53
+ if (!p.isPaused())
54
+ p.pauseVfs();
55
+ };
56
+ return store;
57
+ }
58
+ /** Serves OPFS storage to the page (`opfsStorage`), coordinating with the other tabs' Workers. */
59
+ export function serveOpfsStorage(options) {
60
+ const name = options.name ?? "ember";
61
+ const loaded = {};
62
+ const storage = coordinateTabs({
63
+ name: `opfs:${name}`,
64
+ open: () => openOpfsStore(options, loaded),
65
+ ...(options.multiTab === false ? { locks: null } : options.locks !== undefined ? { locks: options.locks } : {}),
66
+ ...(options.channel ? { channel: options.channel } : {}),
67
+ ...(options.onRoleChange ? { onRoleChange: options.onRoleChange } : {}),
68
+ });
69
+ const scope = options.scope ?? globalThis;
70
+ const served = serveStorage(storage, scope);
71
+ return { storage, served };
72
+ }
package/dist/opfs.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { RemoteStorage, type Endpoint } from "./remote.ts";
2
+ export interface OpfsStorageOptions {
3
+ /** The Worker that calls `serveOpfsStorage` (or a factory for it). */
4
+ worker: (Endpoint & {
5
+ terminate?(): void;
6
+ }) | (() => Endpoint & {
7
+ terminate?(): void;
8
+ });
9
+ /** Terminate the Worker on `close()` (default true). */
10
+ terminate?: boolean;
11
+ /**
12
+ * How long `close()` waits for the Worker to close its database (default 5000 ms). A Worker that reported an
13
+ * error is not waited for at all; one that went silent is given up on after this, then terminated.
14
+ */
15
+ closeTimeoutMs?: number;
16
+ }
17
+ export declare class OpfsStorage extends RemoteStorage {
18
+ private readonly options;
19
+ private readonly worker;
20
+ private readonly failed;
21
+ constructor(options: OpfsStorageOptions);
22
+ private terminated;
23
+ private terminate;
24
+ load(): Promise<{
25
+ identity?: import("@freecorps/ember-client").LocalIdentity | null;
26
+ }>;
27
+ close(): Promise<void>;
28
+ }
29
+ /** Offline storage on SQLite-Wasm over OPFS, run in a dedicated Worker (see opfs-worker.ts). */
30
+ export declare function opfsStorage(options: OpfsStorageOptions): OpfsStorage;
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 {};