@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 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,10 @@
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 { localOptimisticUpdates, type LocalOptimisticOptions } from "./mutations.ts";
23
+ export { NeedData, recordKey, type BackendReader, type Commit, type ExternalChange, type OfflineBackend, type QueryLogEntry, type QueryRecord, } from "./backend.ts";
18
24
  export { MemoryBackend, memoryStorage } from "./memory.ts";
25
+ export { coordinateTabs, TabCoordinator, type LockManagerLike, type TabRole, type TabsOptions } from "./tabs.ts";
26
+ export { RemoteStorage, serveStorage, type Endpoint, type ServableStorage, type Served, type ServeOptions } from "./remote.ts";
19
27
  export type { LocalSchema } from "@freecorps/ember-server";
20
28
  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,8 @@
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 { localOptimisticUpdates } from "./mutations.js";
23
+ export { NeedData, recordKey, } from "./backend.js";
18
24
  export { MemoryBackend, memoryStorage } from "./memory.js";
25
+ export { coordinateTabs, TabCoordinator } from "./tabs.js";
26
+ 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,15 @@
1
+ import type { OptimisticUpdate } from "@freecorps/ember-client";
2
+ import { type Identity, type LocalSchema } from "@freecorps/ember-server";
3
+ export interface LocalOptimisticOptions {
4
+ schema: LocalSchema;
5
+ /** The app's mutation modules (`{ messages }` or `{ "messages:send": send }`): public `mutation(...)` definitions. */
6
+ mutations: unknown;
7
+ /** The queries whose loaded values are the replica and get optimistic values (public `query(...)` definitions). */
8
+ queries: unknown;
9
+ /** Who the mutation runs as locally (decoded from the client's token; not verified). */
10
+ identity?: () => Identity | null;
11
+ /** Errors from running a mutation or query locally (default: ignored; the server's answer stands). */
12
+ onError?: (fn: string, e: Error) => void;
13
+ }
14
+ /** One optimistic update per registered mutation, keyed by its name (`"messages:send"`), for `EmberClient`. */
15
+ export declare function localOptimisticUpdates(o: LocalOptimisticOptions): Record<string, OptimisticUpdate>;
@@ -0,0 +1,112 @@
1
+ // Optimistic updates without writing one per query (PLAN §10, Brigade RFC 0016 gap G5): the client runs the same
2
+ // `mutation(...)` the server registers over the documents its loaded queries returned, then runs the registered
3
+ // local queries again over the result. Every subscription whose value changes shows it until the server answers.
4
+ //
5
+ // import * as messages from "../ember/messages";
6
+ // const client = new EmberClient({
7
+ // url,
8
+ // optimisticUpdates: localOptimisticUpdates({ schema, mutations: { messages }, queries: { messages } }),
9
+ // });
10
+ //
11
+ // What it relies on, per call:
12
+ // - The replica is the documents in the loaded values of the registered queries (`documentsIn`), so a mutation sees
13
+ // exactly what the screen shows. A mutation that reads a row nobody loaded behaves as if the row did not exist; one
14
+ // that throws (a rule, `OfflineUnsupported`) changes nothing here, and the server's answer stands.
15
+ // - A query only gets an optimistic value when running it over that replica, before the mutation, gives back the
16
+ // value the server confirmed (deep equal). Projections, counts and queries over rows not on screen fail that check
17
+ // and keep their confirmed value, instead of showing something wrong.
18
+ // - Inserted documents get local ids; the server's confirmed value (its own ids) replaces them.
19
+ import { assertLocalMutation, documentsIn, localQueryRegistry, overlayReader, runLocalMutation, runLocalQuery, flattenFunctions, } from "@freecorps/ember-server";
20
+ import { compareValues } from "@freecorps/ember-values";
21
+ const EMPTY = { scan: () => [], get: () => null };
22
+ /**
23
+ * One clock and seed per pending mutation, kept for as long as it is pending. EmberClient re-runs every pending update
24
+ * on each recompute (a new snapshot, another mutation) with the same pending record: keyed by it, a mutation's local
25
+ * ids and `ctx.random`/`ctx.now` stay the same across re-runs (no remounted rows, stacked edits find the row), and two
26
+ * calls never share a seed, even when the app passes the same args object twice (a form, a double submit).
27
+ */
28
+ const runs = new WeakMap();
29
+ function runOf(mutation) {
30
+ const fresh = () => ({ now: Date.now(), seed: Math.floor(Math.random() * 0x100000000) });
31
+ if (!mutation)
32
+ return fresh();
33
+ let r = runs.get(mutation);
34
+ if (!r)
35
+ runs.set(mutation, (r = fresh()));
36
+ return r;
37
+ }
38
+ function sameValue(a, b) {
39
+ try {
40
+ return compareValues(a, b) === 0;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** One optimistic update per registered mutation, keyed by its name (`"messages:send"`), for `EmberClient`. */
47
+ export function localOptimisticUpdates(o) {
48
+ const queries = localQueryRegistry(o.queries);
49
+ const mutations = new Map();
50
+ for (const [name, fn] of flattenFunctions(o.mutations ?? {})) {
51
+ assertLocalMutation(name, fn);
52
+ mutations.set(name, fn);
53
+ }
54
+ const out = {};
55
+ for (const [name, fn] of mutations) {
56
+ out[name] = (store, args, mutation) => runOptimistic(o, queries, name, fn, store, args, mutation);
57
+ }
58
+ return out;
59
+ }
60
+ function runOptimistic(o, queries, name, fn, store, args, mutation) {
61
+ const m = o.schema.manifest;
62
+ // What the screen shows now (earlier pending mutations included: the store returns the optimistic view).
63
+ const loaded = [];
64
+ for (const [qname, qfn] of queries) {
65
+ for (const q of store.getAllQueries(qname)) {
66
+ if (q.value !== undefined)
67
+ loaded.push({ name: qname, fn: qfn, args: (q.args ?? {}), value: q.value });
68
+ }
69
+ }
70
+ if (!loaded.length)
71
+ return;
72
+ const replica = overlayReader(EMPTY, o.schema);
73
+ // Copies: what the mutation reads (and may change in place) is never the snapshot on screen.
74
+ for (const l of loaded)
75
+ for (const { table, doc } of documentsIn(m, l.value))
76
+ replica.put(table, structuredClone(doc));
77
+ const identity = o.identity?.() ?? null;
78
+ const { now, seed } = runOf(mutation);
79
+ // Only queries the replica reproduces get an optimistic value.
80
+ const reproducible = loaded.filter((l) => {
81
+ try {
82
+ return sameValue(runLocalQuery(l.fn, l.args, { schema: o.schema, reader: replica, identity, now }).value, l.value);
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ });
88
+ if (!reproducible.length)
89
+ return;
90
+ const after = overlayReader(replica, o.schema);
91
+ try {
92
+ runLocalMutation(fn, args, { schema: o.schema, reader: after, identity, now, seed });
93
+ }
94
+ catch (e) {
95
+ o.onError?.(name, e);
96
+ return;
97
+ }
98
+ if (!after.changes().length)
99
+ return;
100
+ for (const l of reproducible) {
101
+ let value;
102
+ try {
103
+ value = runLocalQuery(l.fn, l.args, { schema: o.schema, reader: after, identity, now }).value;
104
+ }
105
+ catch (e) {
106
+ o.onError?.(l.name, e);
107
+ continue;
108
+ }
109
+ if (!sameValue(value, l.value))
110
+ store.setQuery(l.name, l.args, value);
111
+ }
112
+ }
@@ -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;