@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 +1 -1
- package/dist/backend.d.ts +38 -2
- package/dist/backend.js +4 -4
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/indexeddb.d.ts +2 -1
- package/dist/indexeddb.js +22 -8
- package/dist/memory.d.ts +3 -1
- package/dist/memory.js +16 -3
- package/dist/opfs-worker.d.ts +52 -0
- package/dist/opfs-worker.js +72 -0
- package/dist/opfs.d.ts +30 -0
- package/dist/opfs.js +66 -0
- package/dist/remote.d.ts +109 -0
- package/dist/remote.js +388 -0
- package/dist/sqlite-wasm.d.ts +30 -0
- package/dist/sqlite-wasm.js +77 -0
- package/dist/sqlite.d.ts +2 -1
- package/dist/sqlite.js +19 -5
- package/dist/store.d.ts +33 -3
- package/dist/store.js +430 -59
- package/dist/tabs.d.ts +79 -0
- package/dist/tabs.js +210 -0
- package/dist/tinybase.d.ts +78 -0
- package/dist/tinybase.js +294 -0
- package/package.json +34 -7
package/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
|
|
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
|
-
/**
|
|
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
|
|
2
|
-
// pending mutation queue, query records, and replicated rows per shard — and serves synchronous
|
|
3
|
-
// shard's rows to `runLocalQuery`. Backends: memory (memory.ts), SQLite (sqlite.ts, expo-sqlite.ts
|
|
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";
|
package/dist/indexeddb.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -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 {};
|