@freecorps/ember-offline 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FreeCorps
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,12 @@
1
- # Temporary Holding Version
1
+ # @freecorps/ember-offline
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Offline-first storage for Ember clients: persisted query results, replicated rows and local query execution over IndexedDB, SQLite or expo-sqlite.
4
+
5
+ Part of [Ember](https://github.com/freecorps/ember), a Convex-style backend on Cloudflare Workers and Durable
6
+ Objects. Source: [`packages/offline`](https://github.com/freecorps/ember/tree/main/packages/offline).
7
+
8
+ ```sh
9
+ npm install @freecorps/ember-offline
10
+ ```
11
+
12
+ License: MIT.
@@ -0,0 +1,58 @@
1
+ import type { PendingMutationRecord } from "@freecorps/ember-client";
2
+ import type { Doc, LocalReader } from "@freecorps/ember-server";
3
+ /** The last server value of one subscribed query, and the replicated rows it owns. */
4
+ export interface QueryRecord {
5
+ /** `${shard}\u0000${fn}\u0000${argsJson}` */
6
+ key: string;
7
+ shard: string;
8
+ fn: string;
9
+ args: Record<string, unknown>;
10
+ argsJson: string;
11
+ value: unknown;
12
+ version: number;
13
+ /** ms; refreshed on every server value (retention / eviction). */
14
+ updatedAt: number;
15
+ /** `_id`s of the complete documents in `value` (rows owned by this record). */
16
+ ids: string[];
17
+ }
18
+ /** One atomic write to a shard: query records and the rows they own. */
19
+ export interface Commit {
20
+ put?: QueryRecord[];
21
+ /** Record keys to remove. */
22
+ drop?: string[];
23
+ upsert?: Array<{
24
+ table: string;
25
+ doc: Doc;
26
+ }>;
27
+ delete?: Array<{
28
+ table: string;
29
+ id: string;
30
+ }>;
31
+ }
32
+ /**
33
+ * Reads of one shard. Async stores serve what they have fetched and record the rest as misses (throwing
34
+ * `NeedData` so the query stops early); the store then calls `fetch()` and runs the query again.
35
+ */
36
+ export interface BackendReader extends LocalReader {
37
+ readonly misses?: number;
38
+ fetch?(): Promise<void>;
39
+ }
40
+ export interface OfflineBackend {
41
+ /** Open / migrate (a schema change the planner refuses resets the replica; pending survives). */
42
+ open(): Promise<{
43
+ meta: Record<string, string>;
44
+ queries: QueryRecord[];
45
+ }>;
46
+ setMeta(key: string, value: string | null): Promise<void> | void;
47
+ loadPending(): Promise<PendingMutationRecord[]> | PendingMutationRecord[];
48
+ putPending(record: PendingMutationRecord): Promise<void> | void;
49
+ removePending(mid: string): Promise<void> | void;
50
+ commit(shard: string, c: Commit): Promise<void> | void;
51
+ reader(shard: string): BackendReader;
52
+ close(): Promise<void> | void;
53
+ }
54
+ /** Thrown by async readers on a miss (see `BackendReader`). */
55
+ export declare class NeedData extends Error {
56
+ constructor();
57
+ }
58
+ export declare const recordKey: (shard: string, fn: string, argsJson: string) => string;
@@ -0,0 +1,12 @@
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).
5
+ /** Thrown by async readers on a miss (see `BackendReader`). */
6
+ export class NeedData extends Error {
7
+ constructor() {
8
+ super("offline store: data not loaded yet");
9
+ this.name = "NeedData";
10
+ }
11
+ }
12
+ export const recordKey = (shard, fn, argsJson) => `${shard}\u0000${fn}\u0000${argsJson}`;
@@ -0,0 +1,25 @@
1
+ import type { SqlDriver, SqlValue } from "@freecorps/ember-server";
2
+ import { type OfflineOptions, type OfflineStore } from "./store.ts";
3
+ /** The part of expo-sqlite's `SQLiteDatabase` this adapter uses (synchronous calls). */
4
+ export interface ExpoSqliteDatabase {
5
+ getAllSync(source: string, params: SqlValue[]): unknown[];
6
+ runSync(source: string, params: SqlValue[]): {
7
+ changes: number;
8
+ };
9
+ execSync(source: string): void;
10
+ closeSync?(): void;
11
+ }
12
+ export type OpenDatabaseSync = (name: string) => ExpoSqliteDatabase;
13
+ /** `SqlDriver` over an expo-sqlite database (savepoints for nested transactions, like the server's drivers). */
14
+ export declare function expoSqliteDriver(db: ExpoSqliteDatabase): SqlDriver & {
15
+ close(): void;
16
+ };
17
+ export declare function loadExpoSqlite(): OpenDatabaseSync | null;
18
+ export interface ExpoSqliteStorageOptions extends OfflineOptions {
19
+ /** Database name prefix (files `<name>.db`, `<name>-<hash>.db`). Default `"ember"`. */
20
+ name?: string;
21
+ /** expo-sqlite's `openDatabaseSync` (default: required lazily). */
22
+ openDatabaseSync?: OpenDatabaseSync;
23
+ }
24
+ /** Offline storage on expo-sqlite: the server's DDL, SQL compiler and FTS5 search, on the device. */
25
+ export declare function expoSqliteStorage(options: ExpoSqliteStorageOptions): OfflineStore;
@@ -0,0 +1,72 @@
1
+ // expo-sqlite (React Native / Expo) offline storage: the SQLite backend over expo-sqlite's synchronous API.
2
+ // `expo-sqlite` is an optional peer dependency, loaded lazily with a literal `require` (Metro treats it as
3
+ // optional); pass `openDatabaseSync` to inject it explicitly. FTS5 ships enabled in expo-sqlite (config plugin
4
+ // `enableFTS`, default true), so `withSearchIndex` runs locally with `unicode61 remove_diacritics 2`.
5
+ //
6
+ // import { expoSqliteStorage } from "@freecorps/ember-offline/expo-sqlite";
7
+ // createEmberClient({ url, storage: expoSqliteStorage({ schema, queries: { messages } }) })
8
+ import { SqliteBackend } from "./sqlite.js";
9
+ import { createOfflineStorage } from "./store.js";
10
+ const RETURNS_ROWS = /^\s*(SELECT|WITH|PRAGMA|VALUES)\b|\bRETURNING\b/i;
11
+ /** `SqlDriver` over an expo-sqlite database (savepoints for nested transactions, like the server's drivers). */
12
+ export function expoSqliteDriver(db) {
13
+ let sp = 0;
14
+ return {
15
+ exec(sql, ...params) {
16
+ if (RETURNS_ROWS.test(sql)) {
17
+ const rows = db.getAllSync(sql, params);
18
+ const write = /^\s*(INSERT|UPDATE|DELETE|REPLACE)/i.test(sql);
19
+ return { rows, rowsRead: write ? 0 : rows.length, rowsWritten: write ? rows.length : 0 };
20
+ }
21
+ const r = db.runSync(sql, params);
22
+ return { rows: [], rowsRead: 0, rowsWritten: Number(r?.changes ?? 0) };
23
+ },
24
+ transaction(fn) {
25
+ const name = `ember_sp_${sp++}`;
26
+ db.execSync(`SAVEPOINT ${name}`);
27
+ try {
28
+ const out = fn();
29
+ if (out && typeof out.then === "function") {
30
+ Promise.resolve(out).catch(() => { });
31
+ throw new Error("transaction(fn): fn must be synchronous");
32
+ }
33
+ db.execSync(`RELEASE ${name}`);
34
+ return out;
35
+ }
36
+ catch (e) {
37
+ db.execSync(`ROLLBACK TO ${name}`);
38
+ db.execSync(`RELEASE ${name}`);
39
+ throw e;
40
+ }
41
+ },
42
+ close: () => db.closeSync?.(),
43
+ };
44
+ }
45
+ export function loadExpoSqlite() {
46
+ try {
47
+ const m = require("expo-sqlite");
48
+ const open = m?.openDatabaseSync ?? m?.default?.openDatabaseSync;
49
+ return typeof open === "function" ? open : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** Offline storage on expo-sqlite: the server's DDL, SQL compiler and FTS5 search, on the device. */
56
+ export function expoSqliteStorage(options) {
57
+ const open = options.openDatabaseSync ?? loadExpoSqlite();
58
+ if (!open)
59
+ throw new Error("expoSqliteStorage: install expo-sqlite or pass openDatabaseSync");
60
+ const db = (name) => {
61
+ const d = open(`${name}.db`);
62
+ // WAL keeps reads fast while the replica is written; foreign keys are unused.
63
+ try {
64
+ d.execSync("PRAGMA journal_mode = WAL");
65
+ }
66
+ catch {
67
+ /* not supported: keep the default */
68
+ }
69
+ return expoSqliteDriver(d);
70
+ };
71
+ return createOfflineStorage(new SqliteBackend(options.schema, db, options.name ?? "ember"), options);
72
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@freecorps/ember-offline`: optional offline-first storage for `@freecorps/ember-client` (PLAN §10).
3
+ *
4
+ * import { indexedDbStorage } from "@freecorps/ember-offline/indexeddb"; // browsers
5
+ * import { expoSqliteStorage } from "@freecorps/ember-offline/expo-sqlite"; // React Native / Expo
6
+ * import { sqliteStorage } from "@freecorps/ember-offline/sqlite"; // any synchronous SqlDriver
7
+ *
8
+ * new EmberClient({
9
+ * url,
10
+ * storage: indexedDbStorage({ name: "my-app", schema, queries: { messages: { list, search } }, sync: [...] }),
11
+ * optimisticUpdates: { "messages:send": addMessage },
12
+ * });
13
+ *
14
+ * Contract and limits: docs/design/contracts.md, "Offline".
15
+ */
16
+ export { createOfflineStorage, OfflineStore, type OfflineOptions } from "./store.ts";
17
+ export { NeedData, recordKey, type BackendReader, type Commit, type OfflineBackend, type QueryRecord } from "./backend.ts";
18
+ export { MemoryBackend, memoryStorage } from "./memory.ts";
19
+ export type { LocalSchema } from "@freecorps/ember-server";
20
+ export type { StorageAdapter, SyncSpec, LocalQueryResult, QueryUpdate } from "@freecorps/ember-client";
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@freecorps/ember-offline`: optional offline-first storage for `@freecorps/ember-client` (PLAN §10).
3
+ *
4
+ * import { indexedDbStorage } from "@freecorps/ember-offline/indexeddb"; // browsers
5
+ * import { expoSqliteStorage } from "@freecorps/ember-offline/expo-sqlite"; // React Native / Expo
6
+ * import { sqliteStorage } from "@freecorps/ember-offline/sqlite"; // any synchronous SqlDriver
7
+ *
8
+ * new EmberClient({
9
+ * url,
10
+ * storage: indexedDbStorage({ name: "my-app", schema, queries: { messages: { list, search } }, sync: [...] }),
11
+ * optimisticUpdates: { "messages:send": addMessage },
12
+ * });
13
+ *
14
+ * Contract and limits: docs/design/contracts.md, "Offline".
15
+ */
16
+ export { createOfflineStorage, OfflineStore } from "./store.js";
17
+ export { NeedData, recordKey } from "./backend.js";
18
+ export { MemoryBackend, memoryStorage } from "./memory.js";
@@ -0,0 +1,37 @@
1
+ import type { PendingMutationRecord } from "@freecorps/ember-client";
2
+ import { type LocalSchema } from "@freecorps/ember-server";
3
+ import { type BackendReader, type Commit, type OfflineBackend, type QueryRecord } from "./backend.ts";
4
+ import { type OfflineOptions, type OfflineStore } from "./store.ts";
5
+ export interface IndexedDbStorageOptions extends OfflineOptions {
6
+ /** Database name. Default `"ember"`. */
7
+ name?: string;
8
+ /** Default: `globalThis.indexedDB` (inject `fake-indexeddb` in tests). */
9
+ indexedDB?: IDBFactory;
10
+ /** Default: `globalThis.IDBKeyRange`. */
11
+ IDBKeyRange?: typeof IDBKeyRange;
12
+ }
13
+ export declare class IndexedDbBackend implements OfflineBackend {
14
+ private readonly schema;
15
+ private readonly name;
16
+ private db;
17
+ private readonly factory;
18
+ private readonly Range;
19
+ constructor(schema: LocalSchema, name?: string, factory?: IDBFactory, Range?: typeof IDBKeyRange);
20
+ private get conn();
21
+ /** Opens; a different schema version recreates the table stores (the replica is a cache; pending survives). */
22
+ private openDb;
23
+ open(): Promise<{
24
+ meta: Record<string, string>;
25
+ queries: QueryRecord[];
26
+ }>;
27
+ setMeta(key: string, value: string | null): Promise<void>;
28
+ loadPending(): Promise<PendingMutationRecord[]>;
29
+ putPending(r: PendingMutationRecord): Promise<void>;
30
+ removePending(mid: string): Promise<void>;
31
+ private row;
32
+ commit(shard: string, c: Commit): Promise<void>;
33
+ reader(shard: string): BackendReader;
34
+ close(): void;
35
+ }
36
+ /** Offline storage in IndexedDB (browsers). */
37
+ export declare function indexedDbStorage(options: IndexedDbStorageOptions): OfflineStore;
@@ -0,0 +1,228 @@
1
+ // IndexedDB offline storage (browsers). One object store per table holds the replicated documents of every shard,
2
+ // keyed `[shard, _id]`; each db index (and `by_creation_time`) is an IndexedDB index on `[shard, _k_<index>]`,
3
+ // where `_k_<index>` is the server's encoded key (`encodeKey([fields..., _creationTime, _id])`) as an ArrayBuffer.
4
+ // IndexedDB compares binary keys bytewise, like SQLite compares the BLOB key column, so every index range the
5
+ // query compiler computes is one `IDBKeyRange.bound([shard, lo], [shard, hi], false, true)`.
6
+ //
7
+ // Queries are synchronous and IndexedDB is not: the reader serves scans it has already fetched and records the
8
+ // others as misses; the store fetches them (one transaction) and re-runs the query until nothing is missing.
9
+ // Search has no FTS here: `withSearchIndex` loads the table's rows of that shard and runs `jsSearch` (same
10
+ // tokenizer and matching, BM25 over the replica).
11
+ //
12
+ // import { indexedDbStorage } from "@freecorps/ember-offline/indexeddb";
13
+ // new EmberClient({ url, storage: indexedDbStorage({ name: "my-app", schema, queries: { messages } }) })
14
+ import { dbIndexesOf, docKey, indexFields, jsSearch } from "@freecorps/ember-server";
15
+ import { bytesToBase64 } from "@freecorps/ember-values";
16
+ import { NeedData } from "./backend.js";
17
+ import { createOfflineStorage } from "./store.js";
18
+ const META = "meta";
19
+ const PENDING = "pending";
20
+ const QUERIES = "queries";
21
+ const tableStore = (table) => `t:${table}`;
22
+ const keyProp = (index) => `_k_${index}`;
23
+ const buf = (u) => u.slice().buffer;
24
+ const req = (r) => new Promise((resolve, reject) => {
25
+ r.onsuccess = () => resolve(r.result);
26
+ r.onerror = () => reject(r.error);
27
+ });
28
+ const done = (tx) => new Promise((resolve, reject) => {
29
+ tx.oncomplete = () => resolve();
30
+ tx.onerror = () => reject(tx.error);
31
+ tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
32
+ });
33
+ export class IndexedDbBackend {
34
+ schema;
35
+ name;
36
+ db = null;
37
+ factory;
38
+ Range;
39
+ constructor(schema, name = "ember", factory, Range) {
40
+ this.schema = schema;
41
+ this.name = name;
42
+ const g = globalThis;
43
+ const f = factory ?? g.indexedDB;
44
+ const r = Range ?? g.IDBKeyRange;
45
+ if (!f || !r)
46
+ throw new Error("indexedDbStorage: IndexedDB is not available here");
47
+ this.factory = f;
48
+ this.Range = r;
49
+ }
50
+ get conn() {
51
+ if (!this.db)
52
+ throw new Error("IndexedDbBackend: not open");
53
+ return this.db;
54
+ }
55
+ /** Opens; a different schema version recreates the table stores (the replica is a cache; pending survives). */
56
+ openDb(version, reset = false) {
57
+ return new Promise((resolve, reject) => {
58
+ const r = version === undefined ? this.factory.open(this.name) : this.factory.open(this.name, version);
59
+ r.onupgradeneeded = (ev) => {
60
+ const db = r.result;
61
+ const tx = r.transaction;
62
+ if (!db.objectStoreNames.contains(META))
63
+ db.createObjectStore(META);
64
+ if (!db.objectStoreNames.contains(PENDING))
65
+ db.createObjectStore(PENDING, { autoIncrement: true }).createIndex("mid", "mid", { unique: true });
66
+ if (!db.objectStoreNames.contains(QUERIES))
67
+ db.createObjectStore(QUERIES, { keyPath: "key" });
68
+ if (ev.oldVersion === 0 || reset) {
69
+ for (const n of Array.from(db.objectStoreNames))
70
+ if (n.startsWith("t:"))
71
+ db.deleteObjectStore(n);
72
+ tx.objectStore(QUERIES).clear();
73
+ for (const [table, t] of Object.entries(this.schema.manifest.tables)) {
74
+ const s = db.createObjectStore(tableStore(table), { keyPath: ["s", "id"] });
75
+ for (const i of dbIndexesOf(t))
76
+ s.createIndex(i.name, ["s", keyProp(i.name)]);
77
+ }
78
+ tx.objectStore(META).put(this.schema.manifest.version, "schema");
79
+ }
80
+ };
81
+ r.onsuccess = () => {
82
+ const db = r.result;
83
+ db.onversionchange = () => db.close();
84
+ resolve(db);
85
+ };
86
+ r.onerror = () => reject(r.error);
87
+ r.onblocked = () => reject(new Error(`indexedDbStorage: database ${this.name} is open elsewhere with an older version`));
88
+ });
89
+ }
90
+ async open() {
91
+ let db = await this.openDb();
92
+ const stored = await req(db.transaction(META).objectStore(META).get("schema"));
93
+ if (stored !== this.schema.manifest.version) {
94
+ const v = db.version + 1;
95
+ db.close();
96
+ db = await this.openDb(v, true);
97
+ }
98
+ this.db = db;
99
+ const tx = db.transaction([META, QUERIES]);
100
+ const metaStore = tx.objectStore(META);
101
+ const [keys, values, queries] = await Promise.all([
102
+ req(metaStore.getAllKeys()),
103
+ req(metaStore.getAll()),
104
+ req(tx.objectStore(QUERIES).getAll()),
105
+ ]);
106
+ const meta = {};
107
+ keys.forEach((k, i) => {
108
+ if (typeof k === "string" && k !== "schema" && typeof values[i] === "string")
109
+ meta[k] = values[i];
110
+ });
111
+ return { meta, queries };
112
+ }
113
+ async setMeta(key, value) {
114
+ const tx = this.conn.transaction(META, "readwrite");
115
+ if (value === null)
116
+ tx.objectStore(META).delete(key);
117
+ else
118
+ tx.objectStore(META).put(value, key);
119
+ await done(tx);
120
+ }
121
+ async loadPending() {
122
+ return (await req(this.conn.transaction(PENDING).objectStore(PENDING).getAll()));
123
+ }
124
+ // Follow-up requests are issued from the request callbacks (not after an await) so the transaction stays active.
125
+ async putPending(r) {
126
+ const tx = this.conn.transaction(PENDING, "readwrite");
127
+ const s = tx.objectStore(PENDING);
128
+ const g = s.index("mid").getKey(r.mid);
129
+ g.onsuccess = () => void (g.result === undefined ? s.add(r) : s.put(r, g.result));
130
+ await done(tx);
131
+ }
132
+ async removePending(mid) {
133
+ const tx = this.conn.transaction(PENDING, "readwrite");
134
+ const s = tx.objectStore(PENDING);
135
+ const g = s.index("mid").getKey(mid);
136
+ g.onsuccess = () => void (g.result !== undefined && s.delete(g.result));
137
+ await done(tx);
138
+ }
139
+ row(shard, table, doc) {
140
+ const t = this.schema.manifest.tables[table];
141
+ const row = { s: shard, id: doc._id, doc };
142
+ for (const i of dbIndexesOf(t))
143
+ row[keyProp(i.name)] = buf(docKey(doc, indexFields(t, i.name)));
144
+ return row;
145
+ }
146
+ async commit(shard, c) {
147
+ const tables = new Set([...(c.upsert ?? []), ...(c.delete ?? [])].map((x) => tableStore(x.table)));
148
+ const tx = this.conn.transaction([QUERIES, ...tables], "readwrite");
149
+ const q = tx.objectStore(QUERIES);
150
+ for (const r of c.put ?? [])
151
+ q.put(r);
152
+ for (const k of c.drop ?? [])
153
+ q.delete(k);
154
+ for (const { table, id } of c.delete ?? [])
155
+ tx.objectStore(tableStore(table)).delete([shard, id]);
156
+ for (const { table, doc } of c.upsert ?? [])
157
+ tx.objectStore(tableStore(table)).put(this.row(shard, table, doc));
158
+ await done(tx);
159
+ }
160
+ reader(shard) {
161
+ const cache = new Map();
162
+ let needs = [];
163
+ const lookup = (k, store, run) => {
164
+ if (cache.has(k))
165
+ return cache.get(k);
166
+ if (!needs.some((n) => n.k === k))
167
+ needs.push({ k, store, run });
168
+ throw new NeedData();
169
+ };
170
+ const hex = (u) => bytesToBase64(u, true);
171
+ const scanAll = (table) => lookup(`all\u0000${table}`, tableStore(table), async (tx) => {
172
+ const rows = (await req(tx.objectStore(tableStore(table)).getAll(this.Range.bound([shard], [shard, []]))));
173
+ return rows.map((r) => r.doc);
174
+ });
175
+ const reader = {
176
+ get misses() {
177
+ return needs.length;
178
+ },
179
+ scan: (table, index, lo, hi, asc, limit) => lookup(`s\u0000${table}\u0000${index}\u0000${hex(lo)}\u0000${hex(hi)}\u0000${asc}\u0000${limit}`, tableStore(table), (tx) => {
180
+ const range = this.Range.bound([shard, buf(lo)], [shard, buf(hi)], false, true);
181
+ const idx = tx.objectStore(tableStore(table)).index(index);
182
+ const key = keyProp(index);
183
+ const map = (rows) => rows.map((r) => ({ key: new Uint8Array(r[key]), doc: r.doc }));
184
+ if (asc)
185
+ return req(idx.getAll(range, limit)).then((rows) => map(rows));
186
+ return new Promise((resolve, reject) => {
187
+ const out = [];
188
+ const c = idx.openCursor(range, "prev");
189
+ c.onerror = () => reject(c.error);
190
+ c.onsuccess = () => {
191
+ const cur = c.result;
192
+ if (!cur || out.length >= limit)
193
+ return resolve(map(out));
194
+ out.push(cur.value);
195
+ cur.continue();
196
+ };
197
+ });
198
+ }),
199
+ get: (table, id) => lookup(`g\u0000${table}\u0000${id}`, tableStore(table), async (tx) => {
200
+ const r = (await req(tx.objectStore(tableStore(table)).get([shard, id])));
201
+ return r?.doc ?? null;
202
+ }),
203
+ pluginQuery: (table, kind, spec, arg) => {
204
+ if (kind.kind !== "search")
205
+ throw new Error(`index kind ${kind.kind} is not supported in IndexedDB`);
206
+ return jsSearch(scanAll(table), spec, arg);
207
+ },
208
+ fetch: async () => {
209
+ const batch = needs;
210
+ needs = [];
211
+ if (!batch.length)
212
+ return;
213
+ const tx = this.conn.transaction([...new Set(batch.map((n) => n.store))]);
214
+ const results = await Promise.all(batch.map((n) => n.run(tx)));
215
+ batch.forEach((n, i) => cache.set(n.k, results[i]));
216
+ },
217
+ };
218
+ return reader;
219
+ }
220
+ close() {
221
+ this.db?.close();
222
+ this.db = null;
223
+ }
224
+ }
225
+ /** Offline storage in IndexedDB (browsers). */
226
+ export function indexedDbStorage(options) {
227
+ return createOfflineStorage(new IndexedDbBackend(options.schema, options.name, options.indexedDB, options.IDBKeyRange), options);
228
+ }
@@ -0,0 +1,38 @@
1
+ import type { PendingMutationRecord } from "@freecorps/ember-client";
2
+ import { type Doc, type LocalSchema } from "@freecorps/ember-server";
3
+ import type { BackendReader, Commit, OfflineBackend, QueryRecord } from "./backend.ts";
4
+ import { type OfflineOptions, type OfflineStore } from "./store.ts";
5
+ export declare class MemoryBackend implements OfflineBackend {
6
+ readonly meta: Map<string, string>;
7
+ readonly pending: Map<string, PendingMutationRecord>;
8
+ readonly queries: Map<string, QueryRecord>;
9
+ /** shard → table → id → doc */
10
+ readonly rows: Map<string, Map<string, Map<string, Doc>>>;
11
+ /** Sorted rows per `shard\0table\0index`, rebuilt after writes. */
12
+ private sorted;
13
+ schema: LocalSchema | null;
14
+ constructor(schema?: LocalSchema);
15
+ /** Schema version the rows were written with: another one resets the replica (pending stays). */
16
+ private version;
17
+ open(): Promise<{
18
+ meta: {
19
+ [k: string]: string;
20
+ };
21
+ queries: QueryRecord[];
22
+ }>;
23
+ setMeta(key: string, value: string | null): void;
24
+ loadPending(): PendingMutationRecord[];
25
+ putPending(r: PendingMutationRecord): void;
26
+ removePending(mid: string): void;
27
+ private table;
28
+ commit(shard: string, c: Commit): void;
29
+ reader(shard: string): BackendReader;
30
+ close(): void;
31
+ }
32
+ /**
33
+ * Offline storage in memory: local queries and cached results without persistence (or, with a shared `backend`,
34
+ * surviving a client re-creation in the same process).
35
+ */
36
+ export declare function memoryStorage(options: OfflineOptions & {
37
+ backend?: MemoryBackend;
38
+ }): OfflineStore;
package/dist/memory.js ADDED
@@ -0,0 +1,103 @@
1
+ // In-memory backend: the replica lives as long as the backend object. Useful for tests, SSR and as the reference
2
+ // semantics of the other backends (same keys: `docKey` over the index fields, compared bytewise).
3
+ import { compareBytes, inKeyRange } from "@freecorps/ember-values";
4
+ import { docKey, indexFields, jsSearch } from "@freecorps/ember-server";
5
+ import { createOfflineStorage } from "./store.js";
6
+ export class MemoryBackend {
7
+ meta = new Map();
8
+ pending = new Map();
9
+ queries = new Map();
10
+ /** shard → table → id → doc */
11
+ rows = new Map();
12
+ /** Sorted rows per `shard\0table\0index`, rebuilt after writes. */
13
+ sorted = new Map();
14
+ schema = null;
15
+ constructor(schema) {
16
+ this.schema = schema ?? null;
17
+ }
18
+ /** Schema version the rows were written with: another one resets the replica (pending stays). */
19
+ version = null;
20
+ async open() {
21
+ const v = this.schema?.manifest.version ?? null;
22
+ if (this.version !== null && this.version !== v) {
23
+ this.rows.clear();
24
+ this.queries.clear();
25
+ this.sorted.clear();
26
+ }
27
+ this.version = v;
28
+ return { meta: Object.fromEntries(this.meta), queries: [...this.queries.values()] };
29
+ }
30
+ setMeta(key, value) {
31
+ if (value === null)
32
+ this.meta.delete(key);
33
+ else
34
+ this.meta.set(key, value);
35
+ }
36
+ loadPending() {
37
+ return [...this.pending.values()];
38
+ }
39
+ putPending(r) {
40
+ this.pending.set(r.mid, r);
41
+ }
42
+ removePending(mid) {
43
+ this.pending.delete(mid);
44
+ }
45
+ table(shard, table) {
46
+ let s = this.rows.get(shard);
47
+ if (!s)
48
+ this.rows.set(shard, (s = new Map()));
49
+ let t = s.get(table);
50
+ if (!t)
51
+ s.set(table, (t = new Map()));
52
+ return t;
53
+ }
54
+ commit(shard, c) {
55
+ for (const r of c.put ?? [])
56
+ this.queries.set(r.key, r);
57
+ for (const k of c.drop ?? [])
58
+ this.queries.delete(k);
59
+ for (const { table, doc } of c.upsert ?? [])
60
+ this.table(shard, table).set(doc._id, doc);
61
+ for (const { table, id } of c.delete ?? [])
62
+ this.table(shard, table).delete(id);
63
+ if (c.upsert?.length || c.delete?.length)
64
+ this.sorted.clear();
65
+ }
66
+ reader(shard) {
67
+ const schema = this.schema;
68
+ if (!schema)
69
+ throw new Error("MemoryBackend: no schema");
70
+ const sortedRows = (table, index) => {
71
+ const k = `${shard}\u0000${table}\u0000${index}`;
72
+ let rows = this.sorted.get(k);
73
+ if (!rows) {
74
+ const fields = indexFields(schema.manifest.tables[table], index);
75
+ rows = [...this.table(shard, table).values()].map((doc) => ({ key: docKey(doc, fields), doc })).sort((a, b) => compareBytes(a.key, b.key));
76
+ this.sorted.set(k, rows);
77
+ }
78
+ return rows;
79
+ };
80
+ return {
81
+ scan: (table, index, lo, hi, asc, limit) => {
82
+ const inRange = sortedRows(table, index).filter((r) => inKeyRange(r.key, lo, hi));
83
+ return (asc ? inRange : inRange.reverse()).slice(0, limit);
84
+ },
85
+ get: (table, id) => this.table(shard, table).get(id) ?? null,
86
+ pluginQuery: (table, kind, spec, arg) => {
87
+ if (kind.kind !== "search")
88
+ throw new Error(`index kind ${kind.kind} is not supported offline`);
89
+ return jsSearch([...this.table(shard, table).values()], spec, arg);
90
+ },
91
+ };
92
+ }
93
+ close() { }
94
+ }
95
+ /**
96
+ * Offline storage in memory: local queries and cached results without persistence (or, with a shared `backend`,
97
+ * surviving a client re-creation in the same process).
98
+ */
99
+ export function memoryStorage(options) {
100
+ const backend = options.backend ?? new MemoryBackend(options.schema);
101
+ backend.schema = options.schema;
102
+ return createOfflineStorage(backend, options);
103
+ }
@@ -0,0 +1,38 @@
1
+ import { type PendingMutationRecord } from "@freecorps/ember-client";
2
+ import { type LocalSchema, type SqlDriver } from "@freecorps/ember-server";
3
+ import type { BackendReader, Commit, OfflineBackend, QueryRecord } from "./backend.ts";
4
+ import { type OfflineOptions, type OfflineStore } from "./store.ts";
5
+ export type OpenSqlite = (name: string) => SqlDriver & {
6
+ close?(): void;
7
+ };
8
+ export interface SqliteStorageOptions extends OfflineOptions {
9
+ /** Database name prefix. Default `"ember"`. */
10
+ name?: string;
11
+ /** Opens (creating if needed) a database by name. */
12
+ open: OpenSqlite;
13
+ }
14
+ export declare class SqliteBackend implements OfflineBackend {
15
+ private readonly schema;
16
+ private readonly openDb;
17
+ private readonly name;
18
+ private main;
19
+ private readonly shards;
20
+ private readonly known;
21
+ constructor(schema: LocalSchema, openDb: OpenSqlite, name?: string);
22
+ private get db();
23
+ open(): Promise<{
24
+ meta: Record<string, string>;
25
+ queries: QueryRecord[];
26
+ }>;
27
+ /** Opens (and migrates) a shard's database, registering it in the main database. */
28
+ private shard;
29
+ setMeta(key: string, value: string | null): void;
30
+ loadPending(): PendingMutationRecord[];
31
+ putPending(r: PendingMutationRecord): void;
32
+ removePending(mid: string): void;
33
+ commit(shard: string, c: Commit): void;
34
+ reader(shard: string): BackendReader;
35
+ close(): void;
36
+ }
37
+ /** Offline storage on SQLite through any synchronous driver factory (`SqlDriver`). */
38
+ export declare function sqliteStorage(options: SqliteStorageOptions): OfflineStore;
package/dist/sqlite.js ADDED
@@ -0,0 +1,115 @@
1
+ // SQLite backend over the server's `SqlDriver` (synchronous): expo-sqlite in apps (expo-sqlite.ts), node:sqlite in
2
+ // tests. Each shard's replica is its own database with the server's DDL (typed columns, encoded key columns,
3
+ // FTS5 `unicode61 remove_diacritics 2`), written with the server's row codec and read with the server's SQL —
4
+ // `withSearchIndex` runs on FTS5 locally. A main database holds metadata, the pending queue and the shard list.
5
+ //
6
+ // <name> _ember_local_meta, _ember_local_pending, _ember_local_shards
7
+ // <name>-<sha(shard)> schema tables + _ember_local_queries (records commit atomically with their rows)
8
+ import { decodeJson, encodeJson } from "@freecorps/ember-client";
9
+ import { jsSearch, prepareLocalDatabase, sha256Hex, sqlLocalReader, sqlReplicaWriter, } from "@freecorps/ember-server";
10
+ import { createOfflineStorage } from "./store.js";
11
+ const MAIN_DDL = [
12
+ `CREATE TABLE IF NOT EXISTS "_ember_local_meta" ("k" TEXT PRIMARY KEY, "v" TEXT NOT NULL) STRICT`,
13
+ `CREATE TABLE IF NOT EXISTS "_ember_local_pending" ("seq" INTEGER PRIMARY KEY AUTOINCREMENT, "mid" TEXT NOT NULL UNIQUE, "record" TEXT NOT NULL) STRICT`,
14
+ `CREATE TABLE IF NOT EXISTS "_ember_local_shards" ("shard" TEXT PRIMARY KEY, "db" TEXT NOT NULL) STRICT`,
15
+ ];
16
+ const QUERIES_DDL = `CREATE TABLE IF NOT EXISTS "_ember_local_queries" ("key" TEXT PRIMARY KEY, "record" TEXT NOT NULL) STRICT`;
17
+ export class SqliteBackend {
18
+ schema;
19
+ openDb;
20
+ name;
21
+ main = null;
22
+ shards = new Map();
23
+ known = new Map();
24
+ constructor(schema, openDb, name = "ember") {
25
+ this.schema = schema;
26
+ this.openDb = openDb;
27
+ this.name = name;
28
+ }
29
+ get db() {
30
+ if (!this.main)
31
+ throw new Error("SqliteBackend: not open");
32
+ return this.main;
33
+ }
34
+ async open() {
35
+ const main = (this.main = this.openDb(this.name));
36
+ for (const s of MAIN_DDL)
37
+ main.exec(s);
38
+ const meta = {};
39
+ for (const r of main.exec(`SELECT "k", "v" FROM "_ember_local_meta"`).rows)
40
+ meta[String(r.k)] = String(r.v);
41
+ const queries = [];
42
+ for (const r of main.exec(`SELECT "shard", "db" FROM "_ember_local_shards"`).rows) {
43
+ this.known.set(String(r.shard), String(r.db));
44
+ const s = this.shard(String(r.shard));
45
+ for (const q of s.driver.exec(`SELECT "record" FROM "_ember_local_queries"`).rows)
46
+ queries.push(decodeJson(String(q.record)));
47
+ }
48
+ return { meta, queries };
49
+ }
50
+ /** Opens (and migrates) a shard's database, registering it in the main database. */
51
+ shard(shard) {
52
+ let s = this.shards.get(shard);
53
+ if (s)
54
+ return s;
55
+ let dbName = this.known.get(shard);
56
+ if (!dbName) {
57
+ dbName = `${this.name}-${sha256Hex(shard).slice(0, 24)}`;
58
+ this.db.exec(`INSERT OR IGNORE INTO "_ember_local_shards" ("shard", "db") VALUES (?, ?)`, shard, dbName);
59
+ this.known.set(shard, dbName);
60
+ }
61
+ const driver = this.openDb(dbName);
62
+ prepareLocalDatabase(driver, this.schema);
63
+ driver.exec(QUERIES_DDL);
64
+ s = { driver, writer: sqlReplicaWriter(driver, this.schema), reader: sqlLocalReader(driver, this.schema) };
65
+ this.shards.set(shard, s);
66
+ return s;
67
+ }
68
+ setMeta(key, value) {
69
+ if (value === null)
70
+ this.db.exec(`DELETE FROM "_ember_local_meta" WHERE "k" = ?`, key);
71
+ else
72
+ this.db.exec(`INSERT INTO "_ember_local_meta" ("k", "v") VALUES (?, ?) ON CONFLICT ("k") DO UPDATE SET "v" = excluded."v"`, key, value);
73
+ }
74
+ loadPending() {
75
+ return this.db.exec(`SELECT "record" FROM "_ember_local_pending" ORDER BY "seq"`).rows.map((r) => decodeJson(String(r.record)));
76
+ }
77
+ putPending(r) {
78
+ this.db.exec(`INSERT INTO "_ember_local_pending" ("mid", "record") VALUES (?, ?) ON CONFLICT ("mid") DO UPDATE SET "record" = excluded."record"`, r.mid, encodeJson(r));
79
+ }
80
+ removePending(mid) {
81
+ this.db.exec(`DELETE FROM "_ember_local_pending" WHERE "mid" = ?`, mid);
82
+ }
83
+ commit(shard, c) {
84
+ const s = this.shard(shard);
85
+ s.driver.transaction(() => {
86
+ for (const r of c.put ?? []) {
87
+ s.driver.exec(`INSERT INTO "_ember_local_queries" ("key", "record") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "record" = excluded."record"`, r.key, encodeJson(r));
88
+ }
89
+ for (const k of c.drop ?? [])
90
+ s.driver.exec(`DELETE FROM "_ember_local_queries" WHERE "key" = ?`, k);
91
+ for (const { table, id } of c.delete ?? [])
92
+ s.writer.delete(table, id);
93
+ for (const { table, doc } of c.upsert ?? [])
94
+ s.writer.upsert(table, doc);
95
+ });
96
+ }
97
+ reader(shard) {
98
+ // A shard never written has no replica: read as empty without creating a database for it.
99
+ if (!this.known.has(shard)) {
100
+ return { scan: () => [], get: () => null, pluginQuery: (_t, _k, spec, arg) => jsSearch([], spec, arg) };
101
+ }
102
+ return this.shard(shard).reader;
103
+ }
104
+ close() {
105
+ for (const s of this.shards.values())
106
+ s.driver.close?.();
107
+ this.shards.clear();
108
+ this.main?.close?.();
109
+ this.main = null;
110
+ }
111
+ }
112
+ /** Offline storage on SQLite through any synchronous driver factory (`SqlDriver`). */
113
+ export function sqliteStorage(options) {
114
+ return createOfflineStorage(new SqliteBackend(options.schema, options.open, options.name), options);
115
+ }
@@ -0,0 +1,67 @@
1
+ import { type LocalIdentity, type LocalQueryRequest, type LocalQueryResult, type PendingStorage, type QueryUpdate, type StorageAdapter, type SyncSpec } from "@freecorps/ember-client";
2
+ import { type LocalSchema } from "@freecorps/ember-server";
3
+ import { type OfflineBackend } from "./backend.ts";
4
+ export interface OfflineOptions {
5
+ /** The app's schema (`ember/schema.ts` default export): tables, indexes, index kinds. */
6
+ schema: LocalSchema;
7
+ /**
8
+ * Queries that may run on the client, by name: `{ "messages:list": list }` or modules `{ messages }` — the same
9
+ * `query()` definitions the server registers (import them from your `ember/` modules). Only public queries are
10
+ * accepted. Their results are replicated row by row and they run locally on cold start / offline.
11
+ */
12
+ queries?: unknown;
13
+ /** Queries kept subscribed for the client's whole life, so their rows stay replicated (e.g. "last 90 days"). */
14
+ sync?: SyncSpec[];
15
+ /** Cached query records kept (least recently refreshed are evicted first). Default 500. */
16
+ maxQueries?: number;
17
+ /** Records not refreshed by the server for this long are dropped on load (with the rows only they own). Default 30 days. */
18
+ retentionMs?: number;
19
+ now?: () => number;
20
+ }
21
+ export declare class OfflineStore implements StorageAdapter {
22
+ private readonly backend;
23
+ private readonly options;
24
+ readonly sync: readonly SyncSpec[];
25
+ readonly persistPending: PendingStorage;
26
+ private readonly registry;
27
+ private readonly replicated;
28
+ private readonly records;
29
+ /** `${shard}\0${id}` → number of records owning the row. */
30
+ private readonly refs;
31
+ /** record key → id → stable JSON of the doc (lazy): unchanged rows are not rewritten. */
32
+ private readonly sigs;
33
+ private meta;
34
+ private chain;
35
+ private opened;
36
+ private readonly now;
37
+ constructor(backend: OfflineBackend, options: OfflineOptions);
38
+ /** Names of the queries that run locally. */
39
+ get localQueries(): string[];
40
+ load(): Promise<{
41
+ identity: LocalIdentity | null;
42
+ }>;
43
+ private open;
44
+ private ref;
45
+ private enqueue;
46
+ /** Resolves when every write handed to the store so far is committed. */
47
+ flush(): Promise<void>;
48
+ applyPatch(u: QueryUpdate): Promise<void>;
49
+ private sigsOf;
50
+ private apply;
51
+ /** Over `maxQueries`: drop the least recently refreshed records (never the one just written). */
52
+ private evict;
53
+ /**
54
+ * Per shard: commit the drop first, then forget the records and their refcounts in memory, so a failed commit
55
+ * leaves memory matching storage (shards committed before the failure stay dropped, as they are in storage).
56
+ */
57
+ private dropRecords;
58
+ runQueryLocally(req: LocalQueryRequest): Promise<LocalQueryResult | undefined>;
59
+ /** Runs a registered query over a shard's replica (re-running after async fetches until nothing is missing). */
60
+ private runLocal;
61
+ setIdentity(identity: LocalIdentity | null): Promise<void>;
62
+ /** Forget every cached result and replicated row (e.g. on sign-out on a shared device). Pending mutations stay. */
63
+ clear(): Promise<void>;
64
+ close(): Promise<void>;
65
+ }
66
+ /** A `StorageAdapter` over any backend. */
67
+ export declare function createOfflineStorage(backend: OfflineBackend, options: OfflineOptions): OfflineStore;
package/dist/store.js ADDED
@@ -0,0 +1,272 @@
1
+ // The generic offline store: implements the client's `StorageAdapter` over any `OfflineBackend`.
2
+ //
3
+ // Replication by ownership. Every confirmed value of an *offline* query (registered to run locally, or listed in
4
+ // `sync`) owns the complete documents it contains. A row is kept while at least one stored query record owns it;
5
+ // when the last owner's new server value no longer contains it (deleted, or moved out of every cached range), the
6
+ // row is deleted. So the replica is always the union of the last server results of the offline queries — never a
7
+ // row the server did not return, never a row no stored result vouches for. Other queries only get their last value
8
+ // cached (`source: "cache"`).
9
+ import { decodeJson, encodeJson, getFunctionName, stableStringify, } from "@freecorps/ember-client";
10
+ import { documentsIn, localQueryRegistry, localTableOfId, runLocalQuery, } from "@freecorps/ember-server";
11
+ import { recordKey } from "./backend.js";
12
+ const DAY = 86_400_000;
13
+ export class OfflineStore {
14
+ backend;
15
+ options;
16
+ sync;
17
+ persistPending;
18
+ registry;
19
+ replicated;
20
+ records = new Map();
21
+ /** `${shard}\0${id}` → number of records owning the row. */
22
+ refs = new Map();
23
+ /** record key → id → stable JSON of the doc (lazy): unchanged rows are not rewritten. */
24
+ sigs = new Map();
25
+ meta = {};
26
+ chain = Promise.resolve();
27
+ opened = null;
28
+ now;
29
+ constructor(backend, options) {
30
+ this.backend = backend;
31
+ this.options = options;
32
+ this.registry = localQueryRegistry(options.queries ?? {});
33
+ this.sync = options.sync ?? [];
34
+ this.now = options.now ?? Date.now;
35
+ this.replicated = new Set([...this.registry.keys(), ...this.sync.map((s) => getFunctionName(s.query))]);
36
+ // Through the same write queue as results, so `flush()` covers the pending queue too.
37
+ this.persistPending = {
38
+ getClientId: () => this.enqueue(async () => this.meta.clientId ?? null),
39
+ setClientId: (id) => this.enqueue(async () => {
40
+ this.meta.clientId = id;
41
+ await this.backend.setMeta("clientId", id);
42
+ }),
43
+ load: () => this.enqueue(async () => await this.backend.loadPending()),
44
+ put: (r) => this.enqueue(async () => void (await this.backend.putPending(r))),
45
+ remove: (mid) => this.enqueue(async () => void (await this.backend.removePending(mid))),
46
+ };
47
+ }
48
+ /** Names of the queries that run locally. */
49
+ get localQueries() {
50
+ return [...this.registry.keys()];
51
+ }
52
+ async load() {
53
+ await this.open();
54
+ const raw = this.meta.identity;
55
+ let identity = null;
56
+ try {
57
+ identity = raw ? decodeJson(raw) : null;
58
+ }
59
+ catch {
60
+ identity = null;
61
+ }
62
+ return { identity };
63
+ }
64
+ open() {
65
+ if (!this.opened) {
66
+ this.opened = (async () => {
67
+ const { meta, queries } = await this.backend.open();
68
+ this.meta = { ...meta };
69
+ for (const r of queries) {
70
+ this.records.set(r.key, r);
71
+ for (const id of r.ids)
72
+ this.ref(r.shard, id, +1);
73
+ }
74
+ // Retention: what the server has not refreshed for too long goes, with the rows only it owned.
75
+ const cutoff = this.now() - (this.options.retentionMs ?? 30 * DAY);
76
+ const stale = [...this.records.values()].filter((r) => r.updatedAt < cutoff);
77
+ if (stale.length)
78
+ await this.dropRecords(stale);
79
+ })();
80
+ this.chain = this.opened.catch(() => undefined);
81
+ }
82
+ return this.opened;
83
+ }
84
+ ref(shard, id, d) {
85
+ const k = `${shard}\u0000${id}`;
86
+ const n = (this.refs.get(k) ?? 0) + d;
87
+ if (n > 0)
88
+ this.refs.set(k, n);
89
+ else
90
+ this.refs.delete(k);
91
+ return n;
92
+ }
93
+ enqueue(f) {
94
+ const next = this.chain.then(() => this.open()).then(f);
95
+ this.chain = next.catch(() => undefined);
96
+ return next;
97
+ }
98
+ /** Resolves when every write handed to the store so far is committed. */
99
+ flush() {
100
+ return this.enqueue(async () => { });
101
+ }
102
+ applyPatch(u) {
103
+ return this.enqueue(() => this.apply(u));
104
+ }
105
+ sigsOf(r) {
106
+ let m = this.sigs.get(r.key);
107
+ if (!m) {
108
+ m = new Map(documentsIn(this.options.schema.manifest, r.value).map(({ doc }) => [doc._id, stableStringify(doc)]));
109
+ this.sigs.set(r.key, m);
110
+ }
111
+ return m;
112
+ }
113
+ async apply(u) {
114
+ const m = this.options.schema.manifest;
115
+ const key = recordKey(u.shard, u.fn, u.argsJson);
116
+ const prev = this.records.get(key);
117
+ const docs = this.replicated.has(u.fn) ? documentsIn(m, u.value) : [];
118
+ const oldSigs = prev ? this.sigsOf(prev) : new Map();
119
+ const newSigs = new Map();
120
+ const c = { put: [], upsert: [], delete: [] };
121
+ // Refcount changes are applied as computed and undone if the commit fails, so memory never runs ahead of storage.
122
+ const deltas = [];
123
+ const ref = (id, d) => {
124
+ deltas.push([id, d]);
125
+ return this.ref(u.shard, id, d);
126
+ };
127
+ for (const { table, doc } of docs) {
128
+ const sig = stableStringify(doc);
129
+ newSigs.set(doc._id, sig);
130
+ if (oldSigs.get(doc._id) !== sig)
131
+ c.upsert.push({ table, doc });
132
+ if (!oldSigs.has(doc._id))
133
+ ref(doc._id, +1);
134
+ }
135
+ for (const id of prev?.ids ?? []) {
136
+ if (newSigs.has(id))
137
+ continue;
138
+ if (ref(id, -1) === 0) {
139
+ const table = localTableOfId(m, id);
140
+ if (table)
141
+ c.delete.push({ table, id });
142
+ }
143
+ }
144
+ const record = {
145
+ key,
146
+ shard: u.shard,
147
+ fn: u.fn,
148
+ args: u.args,
149
+ argsJson: u.argsJson,
150
+ value: u.value,
151
+ version: u.version,
152
+ updatedAt: this.now(),
153
+ ids: [...newSigs.keys()],
154
+ };
155
+ c.put.push(record);
156
+ try {
157
+ await this.backend.commit(u.shard, c);
158
+ }
159
+ catch (e) {
160
+ for (const [id, d] of deltas.reverse())
161
+ this.ref(u.shard, id, -d);
162
+ throw e;
163
+ }
164
+ this.records.set(key, record);
165
+ this.sigs.set(key, newSigs);
166
+ await this.evict(key);
167
+ }
168
+ /** Over `maxQueries`: drop the least recently refreshed records (never the one just written). */
169
+ async evict(keep) {
170
+ const max = this.options.maxQueries ?? 500;
171
+ if (this.records.size <= max)
172
+ return;
173
+ const victims = [...this.records.values()]
174
+ .filter((r) => r.key !== keep)
175
+ .sort((a, b) => a.updatedAt - b.updatedAt)
176
+ .slice(0, this.records.size - max);
177
+ await this.dropRecords(victims);
178
+ }
179
+ /**
180
+ * Per shard: commit the drop first, then forget the records and their refcounts in memory, so a failed commit
181
+ * leaves memory matching storage (shards committed before the failure stay dropped, as they are in storage).
182
+ */
183
+ async dropRecords(rs) {
184
+ const m = this.options.schema.manifest;
185
+ const byShard = new Map();
186
+ for (const r of rs)
187
+ byShard.set(r.shard, [...(byShard.get(r.shard) ?? []), r]);
188
+ for (const [shard, records] of byShard) {
189
+ const c = { drop: records.map((r) => r.key), delete: [] };
190
+ const dec = new Map();
191
+ for (const r of records)
192
+ for (const id of r.ids)
193
+ dec.set(id, (dec.get(id) ?? 0) + 1);
194
+ for (const [id, n] of dec) {
195
+ if ((this.refs.get(`${shard}\u0000${id}`) ?? 0) - n > 0)
196
+ continue;
197
+ const table = localTableOfId(m, id);
198
+ if (table)
199
+ c.delete.push({ table, id });
200
+ }
201
+ await this.backend.commit(shard, c);
202
+ for (const r of records) {
203
+ this.records.delete(r.key);
204
+ this.sigs.delete(r.key);
205
+ }
206
+ for (const [id, n] of dec)
207
+ this.ref(shard, id, -n);
208
+ }
209
+ }
210
+ async runQueryLocally(req) {
211
+ await this.flush();
212
+ const cached = this.records.get(recordKey(req.shard, req.fn, req.argsJson));
213
+ const fn = this.registry.get(req.fn);
214
+ if (!fn)
215
+ return cached ? { value: cached.value, source: "cache" } : undefined;
216
+ try {
217
+ return { value: await this.runLocal(fn, req), source: "local" };
218
+ }
219
+ catch (e) {
220
+ if (cached)
221
+ return { value: cached.value, source: "cache" };
222
+ throw e;
223
+ }
224
+ }
225
+ /** Runs a registered query over a shard's replica (re-running after async fetches until nothing is missing). */
226
+ async runLocal(fn, req) {
227
+ const reader = this.backend.reader(req.shard);
228
+ for (let attempt = 0;; attempt++) {
229
+ let value;
230
+ let error;
231
+ let failed = false;
232
+ try {
233
+ value = runLocalQuery(fn, req.args, { schema: this.options.schema, reader, identity: req.identity, now: this.now() }).value;
234
+ }
235
+ catch (e) {
236
+ failed = true;
237
+ error = e;
238
+ }
239
+ if (reader.misses && reader.fetch) {
240
+ if (attempt >= 1000)
241
+ throw new Error(`${req.fn}: too many storage round trips`);
242
+ await reader.fetch();
243
+ continue;
244
+ }
245
+ if (failed)
246
+ throw error;
247
+ return value;
248
+ }
249
+ }
250
+ async setIdentity(identity) {
251
+ await this.enqueue(async () => {
252
+ const v = identity ? encodeJson(identity) : null;
253
+ if (v === null)
254
+ delete this.meta.identity;
255
+ else
256
+ this.meta.identity = v;
257
+ await this.backend.setMeta("identity", v);
258
+ });
259
+ }
260
+ /** Forget every cached result and replicated row (e.g. on sign-out on a shared device). Pending mutations stay. */
261
+ async clear() {
262
+ await this.enqueue(() => this.dropRecords([...this.records.values()]));
263
+ }
264
+ async close() {
265
+ await this.flush().catch(() => undefined);
266
+ await this.backend.close();
267
+ }
268
+ }
269
+ /** A `StorageAdapter` over any backend. */
270
+ export function createOfflineStorage(backend, options) {
271
+ return new OfflineStore(backend, options);
272
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@freecorps/ember-offline",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Offline-first storage for Ember clients: persisted query results, replicated rows and local query execution over IndexedDB, SQLite or expo-sqlite.",
5
+ "keywords": [
6
+ "ember",
7
+ "convex",
8
+ "cloudflare",
9
+ "offline-first",
10
+ "indexeddb",
11
+ "sqlite",
12
+ "expo-sqlite",
13
+ "sync"
14
+ ],
15
+ "homepage": "https://github.com/freecorps/ember/tree/main/packages/offline#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/freecorps/ember/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/freecorps/ember.git",
22
+ "directory": "packages/offline"
23
+ },
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./indexeddb": {
35
+ "types": "./dist/indexeddb.d.ts",
36
+ "default": "./dist/indexeddb.js"
37
+ },
38
+ "./sqlite": {
39
+ "types": "./dist/sqlite.d.ts",
40
+ "default": "./dist/sqlite.js"
41
+ },
42
+ "./expo-sqlite": {
43
+ "types": "./dist/expo-sqlite.d.ts",
44
+ "default": "./dist/expo-sqlite.js"
45
+ }
46
+ },
47
+ "files": [
48
+ "dist"
49
+ ],
50
+ "engines": {
51
+ "node": ">=22"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "dependencies": {
57
+ "@freecorps/ember-client": "0.1.0",
58
+ "@freecorps/ember-server": "0.1.0",
59
+ "@freecorps/ember-values": "0.1.0"
60
+ },
61
+ "peerDependencies": {
62
+ "expo-sqlite": ">=15"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "expo-sqlite": {
66
+ "optional": true
67
+ }
68
+ }
69
+ }