@freecorps/ember-offline 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +11 -2
- package/dist/backend.d.ts +94 -0
- package/dist/backend.js +12 -0
- package/dist/expo-sqlite.d.ts +25 -0
- package/dist/expo-sqlite.js +72 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +25 -0
- package/dist/indexeddb.d.ts +38 -0
- package/dist/indexeddb.js +242 -0
- package/dist/memory.d.ts +40 -0
- package/dist/memory.js +116 -0
- 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 +39 -0
- package/dist/sqlite.js +129 -0
- package/dist/store.d.ts +97 -0
- package/dist/store.js +643 -0
- 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 +94 -4
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
|
-
#
|
|
1
|
+
# @freecorps/ember-offline
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
|
|
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,94 @@
|
|
|
1
|
+
import type { PendingMutationRecord } from "@freecorps/ember-client";
|
|
2
|
+
import type { Doc, LocalReader } from "@freecorps/ember-server";
|
|
3
|
+
import type { RowDiff } from "@freecorps/ember-values";
|
|
4
|
+
/** The last server value of one subscribed query, and the replicated rows it owns. */
|
|
5
|
+
export interface QueryRecord {
|
|
6
|
+
/** `${shard}\u0000${fn}\u0000${argsJson}` */
|
|
7
|
+
key: string;
|
|
8
|
+
shard: string;
|
|
9
|
+
fn: string;
|
|
10
|
+
args: Record<string, unknown>;
|
|
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
|
+
*/
|
|
16
|
+
value: unknown;
|
|
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;
|
|
20
|
+
/** ms; refreshed on every server value (retention / eviction). */
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
/** `_id`s of the complete documents in `value` (rows owned by this record). */
|
|
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;
|
|
28
|
+
}
|
|
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. */
|
|
45
|
+
export interface Commit {
|
|
46
|
+
/** Records written whole (their log entries are removed). */
|
|
47
|
+
put?: QueryRecord[];
|
|
48
|
+
/** Record keys to remove (with their log). */
|
|
49
|
+
drop?: string[];
|
|
50
|
+
append?: QueryLogEntry[];
|
|
51
|
+
upsert?: Array<{
|
|
52
|
+
table: string;
|
|
53
|
+
doc: Doc;
|
|
54
|
+
}>;
|
|
55
|
+
delete?: Array<{
|
|
56
|
+
table: string;
|
|
57
|
+
id: string;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reads of one shard. Async stores serve what they have fetched and record the rest as misses (throwing
|
|
62
|
+
* `NeedData` so the query stops early); the store then calls `fetch()` and runs the query again.
|
|
63
|
+
*/
|
|
64
|
+
export interface BackendReader extends LocalReader {
|
|
65
|
+
readonly misses?: number;
|
|
66
|
+
fetch?(): Promise<void>;
|
|
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
|
+
};
|
|
73
|
+
export interface OfflineBackend {
|
|
74
|
+
/** Open / migrate (a schema change the planner refuses resets the replica; pending survives). */
|
|
75
|
+
open(): Promise<{
|
|
76
|
+
meta: Record<string, string>;
|
|
77
|
+
queries: QueryRecord[];
|
|
78
|
+
log?: QueryLogEntry[];
|
|
79
|
+
}>;
|
|
80
|
+
setMeta(key: string, value: string | null): Promise<void> | void;
|
|
81
|
+
loadPending(): Promise<PendingMutationRecord[]> | PendingMutationRecord[];
|
|
82
|
+
putPending(record: PendingMutationRecord): Promise<void> | void;
|
|
83
|
+
removePending(mid: string): Promise<void> | void;
|
|
84
|
+
commit(shard: string, c: Commit): Promise<void> | void;
|
|
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;
|
|
88
|
+
close(): Promise<void> | void;
|
|
89
|
+
}
|
|
90
|
+
/** Thrown by async readers on a miss (see `BackendReader`). */
|
|
91
|
+
export declare class NeedData extends Error {
|
|
92
|
+
constructor();
|
|
93
|
+
}
|
|
94
|
+
export declare const recordKey: (shard: string, fn: string, argsJson: string) => string;
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
/** 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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@freecorps/ember-offline`: optional offline-first storage for `@freecorps/ember-client` (PLAN §10).
|
|
3
|
+
*
|
|
4
|
+
* import { opfsStorage } from "@freecorps/ember-offline/opfs"; // browsers: SQLite-Wasm on OPFS (Worker:
|
|
5
|
+
* // serveOpfsStorage from "/opfs-worker")
|
|
6
|
+
* import { indexedDbStorage } from "@freecorps/ember-offline/indexeddb"; // browsers
|
|
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
|
|
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
|
|
12
|
+
*
|
|
13
|
+
* new EmberClient({
|
|
14
|
+
* url,
|
|
15
|
+
* storage: indexedDbStorage({ name: "my-app", schema, queries: { messages: { list, search } }, sync: [...] }),
|
|
16
|
+
* optimisticUpdates: { "messages:send": addMessage },
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* Contract and limits: docs/design/contracts.md, "Offline".
|
|
20
|
+
*/
|
|
21
|
+
export { createOfflineStorage, OfflineStore, type OfflineOptions } from "./store.ts";
|
|
22
|
+
export { NeedData, recordKey, type BackendReader, type Commit, type ExternalChange, type OfflineBackend, type QueryLogEntry, type QueryRecord, } from "./backend.ts";
|
|
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";
|
|
26
|
+
export type { LocalSchema } from "@freecorps/ember-server";
|
|
27
|
+
export type { StorageAdapter, SyncSpec, LocalQueryResult, QueryUpdate } from "@freecorps/ember-client";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@freecorps/ember-offline`: optional offline-first storage for `@freecorps/ember-client` (PLAN §10).
|
|
3
|
+
*
|
|
4
|
+
* import { opfsStorage } from "@freecorps/ember-offline/opfs"; // browsers: SQLite-Wasm on OPFS (Worker:
|
|
5
|
+
* // serveOpfsStorage from "/opfs-worker")
|
|
6
|
+
* import { indexedDbStorage } from "@freecorps/ember-offline/indexeddb"; // browsers
|
|
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
|
|
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
|
|
12
|
+
*
|
|
13
|
+
* new EmberClient({
|
|
14
|
+
* url,
|
|
15
|
+
* storage: indexedDbStorage({ name: "my-app", schema, queries: { messages: { list, search } }, sync: [...] }),
|
|
16
|
+
* optimisticUpdates: { "messages:send": addMessage },
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* Contract and limits: docs/design/contracts.md, "Offline".
|
|
20
|
+
*/
|
|
21
|
+
export { createOfflineStorage, OfflineStore } from "./store.js";
|
|
22
|
+
export { NeedData, recordKey, } from "./backend.js";
|
|
23
|
+
export { MemoryBackend, memoryStorage } from "./memory.js";
|
|
24
|
+
export { coordinateTabs, TabCoordinator } from "./tabs.js";
|
|
25
|
+
export { RemoteStorage, serveStorage } from "./remote.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
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 QueryLogEntry, 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
|
+
log: QueryLogEntry[];
|
|
27
|
+
}>;
|
|
28
|
+
setMeta(key: string, value: string | null): Promise<void>;
|
|
29
|
+
loadPending(): Promise<PendingMutationRecord[]>;
|
|
30
|
+
putPending(r: PendingMutationRecord): Promise<void>;
|
|
31
|
+
removePending(mid: string): Promise<void>;
|
|
32
|
+
private row;
|
|
33
|
+
commit(shard: string, c: Commit): Promise<void>;
|
|
34
|
+
reader(shard: string): BackendReader;
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
/** Offline storage in IndexedDB (browsers). */
|
|
38
|
+
export declare function indexedDbStorage(options: IndexedDbStorageOptions): OfflineStore;
|
|
@@ -0,0 +1,242 @@
|
|
|
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 LOG = "log";
|
|
22
|
+
const tableStore = (table) => `t:${table}`;
|
|
23
|
+
const keyProp = (index) => `_k_${index}`;
|
|
24
|
+
const buf = (u) => u.slice().buffer;
|
|
25
|
+
const req = (r) => new Promise((resolve, reject) => {
|
|
26
|
+
r.onsuccess = () => resolve(r.result);
|
|
27
|
+
r.onerror = () => reject(r.error);
|
|
28
|
+
});
|
|
29
|
+
const done = (tx) => new Promise((resolve, reject) => {
|
|
30
|
+
tx.oncomplete = () => resolve();
|
|
31
|
+
tx.onerror = () => reject(tx.error);
|
|
32
|
+
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
|
33
|
+
});
|
|
34
|
+
export class IndexedDbBackend {
|
|
35
|
+
schema;
|
|
36
|
+
name;
|
|
37
|
+
db = null;
|
|
38
|
+
factory;
|
|
39
|
+
Range;
|
|
40
|
+
constructor(schema, name = "ember", factory, Range) {
|
|
41
|
+
this.schema = schema;
|
|
42
|
+
this.name = name;
|
|
43
|
+
const g = globalThis;
|
|
44
|
+
const f = factory ?? g.indexedDB;
|
|
45
|
+
const r = Range ?? g.IDBKeyRange;
|
|
46
|
+
if (!f || !r)
|
|
47
|
+
throw new Error("indexedDbStorage: IndexedDB is not available here");
|
|
48
|
+
this.factory = f;
|
|
49
|
+
this.Range = r;
|
|
50
|
+
}
|
|
51
|
+
get conn() {
|
|
52
|
+
if (!this.db)
|
|
53
|
+
throw new Error("IndexedDbBackend: not open");
|
|
54
|
+
return this.db;
|
|
55
|
+
}
|
|
56
|
+
/** Opens; a different schema version recreates the table stores (the replica is a cache; pending survives). */
|
|
57
|
+
openDb(version, reset = false) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const r = version === undefined ? this.factory.open(this.name) : this.factory.open(this.name, version);
|
|
60
|
+
r.onupgradeneeded = (ev) => {
|
|
61
|
+
const db = r.result;
|
|
62
|
+
const tx = r.transaction;
|
|
63
|
+
if (!db.objectStoreNames.contains(META))
|
|
64
|
+
db.createObjectStore(META);
|
|
65
|
+
if (!db.objectStoreNames.contains(PENDING))
|
|
66
|
+
db.createObjectStore(PENDING, { autoIncrement: true }).createIndex("mid", "mid", { unique: true });
|
|
67
|
+
if (!db.objectStoreNames.contains(QUERIES))
|
|
68
|
+
db.createObjectStore(QUERIES, { keyPath: "key" });
|
|
69
|
+
if (!db.objectStoreNames.contains(LOG))
|
|
70
|
+
db.createObjectStore(LOG, { keyPath: ["key", "seq"] });
|
|
71
|
+
if (ev.oldVersion === 0 || reset) {
|
|
72
|
+
for (const n of Array.from(db.objectStoreNames))
|
|
73
|
+
if (n.startsWith("t:"))
|
|
74
|
+
db.deleteObjectStore(n);
|
|
75
|
+
tx.objectStore(QUERIES).clear();
|
|
76
|
+
tx.objectStore(LOG).clear();
|
|
77
|
+
for (const [table, t] of Object.entries(this.schema.manifest.tables)) {
|
|
78
|
+
const s = db.createObjectStore(tableStore(table), { keyPath: ["s", "id"] });
|
|
79
|
+
for (const i of dbIndexesOf(t))
|
|
80
|
+
s.createIndex(i.name, ["s", keyProp(i.name)]);
|
|
81
|
+
}
|
|
82
|
+
tx.objectStore(META).put(this.schema.manifest.version, "schema");
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
r.onsuccess = () => {
|
|
86
|
+
const db = r.result;
|
|
87
|
+
db.onversionchange = () => db.close();
|
|
88
|
+
resolve(db);
|
|
89
|
+
};
|
|
90
|
+
r.onerror = () => reject(r.error);
|
|
91
|
+
r.onblocked = () => reject(new Error(`indexedDbStorage: database ${this.name} is open elsewhere with an older version`));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async open() {
|
|
95
|
+
let db = await this.openDb();
|
|
96
|
+
const stored = await req(db.transaction(META).objectStore(META).get("schema"));
|
|
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.
|
|
99
|
+
const v = db.version + 1;
|
|
100
|
+
db.close();
|
|
101
|
+
db = await this.openDb(v, stored !== this.schema.manifest.version);
|
|
102
|
+
}
|
|
103
|
+
this.db = db;
|
|
104
|
+
const tx = db.transaction([META, QUERIES, LOG]);
|
|
105
|
+
const metaStore = tx.objectStore(META);
|
|
106
|
+
const [keys, values, queries, log] = await Promise.all([
|
|
107
|
+
req(metaStore.getAllKeys()),
|
|
108
|
+
req(metaStore.getAll()),
|
|
109
|
+
req(tx.objectStore(QUERIES).getAll()),
|
|
110
|
+
req(tx.objectStore(LOG).getAll()),
|
|
111
|
+
]);
|
|
112
|
+
const meta = {};
|
|
113
|
+
keys.forEach((k, i) => {
|
|
114
|
+
if (typeof k === "string" && k !== "schema" && typeof values[i] === "string")
|
|
115
|
+
meta[k] = values[i];
|
|
116
|
+
});
|
|
117
|
+
return { meta, queries, log };
|
|
118
|
+
}
|
|
119
|
+
async setMeta(key, value) {
|
|
120
|
+
const tx = this.conn.transaction(META, "readwrite");
|
|
121
|
+
if (value === null)
|
|
122
|
+
tx.objectStore(META).delete(key);
|
|
123
|
+
else
|
|
124
|
+
tx.objectStore(META).put(value, key);
|
|
125
|
+
await done(tx);
|
|
126
|
+
}
|
|
127
|
+
async loadPending() {
|
|
128
|
+
return (await req(this.conn.transaction(PENDING).objectStore(PENDING).getAll()));
|
|
129
|
+
}
|
|
130
|
+
// Follow-up requests are issued from the request callbacks (not after an await) so the transaction stays active.
|
|
131
|
+
async putPending(r) {
|
|
132
|
+
const tx = this.conn.transaction(PENDING, "readwrite");
|
|
133
|
+
const s = tx.objectStore(PENDING);
|
|
134
|
+
const g = s.index("mid").getKey(r.mid);
|
|
135
|
+
g.onsuccess = () => void (g.result === undefined ? s.add(r) : s.put(r, g.result));
|
|
136
|
+
await done(tx);
|
|
137
|
+
}
|
|
138
|
+
async removePending(mid) {
|
|
139
|
+
const tx = this.conn.transaction(PENDING, "readwrite");
|
|
140
|
+
const s = tx.objectStore(PENDING);
|
|
141
|
+
const g = s.index("mid").getKey(mid);
|
|
142
|
+
g.onsuccess = () => void (g.result !== undefined && s.delete(g.result));
|
|
143
|
+
await done(tx);
|
|
144
|
+
}
|
|
145
|
+
row(shard, table, doc) {
|
|
146
|
+
const t = this.schema.manifest.tables[table];
|
|
147
|
+
const row = { s: shard, id: doc._id, doc };
|
|
148
|
+
for (const i of dbIndexesOf(t))
|
|
149
|
+
row[keyProp(i.name)] = buf(docKey(doc, indexFields(t, i.name)));
|
|
150
|
+
return row;
|
|
151
|
+
}
|
|
152
|
+
async commit(shard, c) {
|
|
153
|
+
const tables = new Set([...(c.upsert ?? []), ...(c.delete ?? [])].map((x) => tableStore(x.table)));
|
|
154
|
+
const tx = this.conn.transaction([QUERIES, LOG, ...tables], "readwrite");
|
|
155
|
+
const q = tx.objectStore(QUERIES);
|
|
156
|
+
const log = tx.objectStore(LOG);
|
|
157
|
+
const entriesOf = (key) => this.Range.bound([key], [key, []]);
|
|
158
|
+
for (const r of c.put ?? []) {
|
|
159
|
+
q.put(r);
|
|
160
|
+
log.delete(entriesOf(r.key));
|
|
161
|
+
}
|
|
162
|
+
for (const k of c.drop ?? []) {
|
|
163
|
+
q.delete(k);
|
|
164
|
+
log.delete(entriesOf(k));
|
|
165
|
+
}
|
|
166
|
+
for (const e of c.append ?? [])
|
|
167
|
+
log.put(e);
|
|
168
|
+
for (const { table, id } of c.delete ?? [])
|
|
169
|
+
tx.objectStore(tableStore(table)).delete([shard, id]);
|
|
170
|
+
for (const { table, doc } of c.upsert ?? [])
|
|
171
|
+
tx.objectStore(tableStore(table)).put(this.row(shard, table, doc));
|
|
172
|
+
await done(tx);
|
|
173
|
+
}
|
|
174
|
+
reader(shard) {
|
|
175
|
+
const cache = new Map();
|
|
176
|
+
let needs = [];
|
|
177
|
+
const lookup = (k, store, run) => {
|
|
178
|
+
if (cache.has(k))
|
|
179
|
+
return cache.get(k);
|
|
180
|
+
if (!needs.some((n) => n.k === k))
|
|
181
|
+
needs.push({ k, store, run });
|
|
182
|
+
throw new NeedData();
|
|
183
|
+
};
|
|
184
|
+
const hex = (u) => bytesToBase64(u, true);
|
|
185
|
+
const scanAll = (table) => lookup(`all\u0000${table}`, tableStore(table), async (tx) => {
|
|
186
|
+
const rows = (await req(tx.objectStore(tableStore(table)).getAll(this.Range.bound([shard], [shard, []]))));
|
|
187
|
+
return rows.map((r) => r.doc);
|
|
188
|
+
});
|
|
189
|
+
const reader = {
|
|
190
|
+
get misses() {
|
|
191
|
+
return needs.length;
|
|
192
|
+
},
|
|
193
|
+
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) => {
|
|
194
|
+
const range = this.Range.bound([shard, buf(lo)], [shard, buf(hi)], false, true);
|
|
195
|
+
const idx = tx.objectStore(tableStore(table)).index(index);
|
|
196
|
+
const key = keyProp(index);
|
|
197
|
+
const map = (rows) => rows.map((r) => ({ key: new Uint8Array(r[key]), doc: r.doc }));
|
|
198
|
+
if (asc)
|
|
199
|
+
return req(idx.getAll(range, limit)).then((rows) => map(rows));
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const out = [];
|
|
202
|
+
const c = idx.openCursor(range, "prev");
|
|
203
|
+
c.onerror = () => reject(c.error);
|
|
204
|
+
c.onsuccess = () => {
|
|
205
|
+
const cur = c.result;
|
|
206
|
+
if (!cur || out.length >= limit)
|
|
207
|
+
return resolve(map(out));
|
|
208
|
+
out.push(cur.value);
|
|
209
|
+
cur.continue();
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
}),
|
|
213
|
+
get: (table, id) => lookup(`g\u0000${table}\u0000${id}`, tableStore(table), async (tx) => {
|
|
214
|
+
const r = (await req(tx.objectStore(tableStore(table)).get([shard, id])));
|
|
215
|
+
return r?.doc ?? null;
|
|
216
|
+
}),
|
|
217
|
+
pluginQuery: (table, kind, spec, arg) => {
|
|
218
|
+
if (kind.kind !== "search")
|
|
219
|
+
throw new Error(`index kind ${kind.kind} is not supported in IndexedDB`);
|
|
220
|
+
return jsSearch(scanAll(table), spec, arg);
|
|
221
|
+
},
|
|
222
|
+
fetch: async () => {
|
|
223
|
+
const batch = needs;
|
|
224
|
+
needs = [];
|
|
225
|
+
if (!batch.length)
|
|
226
|
+
return;
|
|
227
|
+
const tx = this.conn.transaction([...new Set(batch.map((n) => n.store))]);
|
|
228
|
+
const results = await Promise.all(batch.map((n) => n.run(tx)));
|
|
229
|
+
batch.forEach((n, i) => cache.set(n.k, results[i]));
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
return reader;
|
|
233
|
+
}
|
|
234
|
+
close() {
|
|
235
|
+
this.db?.close();
|
|
236
|
+
this.db = null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Offline storage in IndexedDB (browsers). */
|
|
240
|
+
export function indexedDbStorage(options) {
|
|
241
|
+
return createOfflineStorage(new IndexedDbBackend(options.schema, options.name, options.indexedDB, options.IDBKeyRange), options);
|
|
242
|
+
}
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PendingMutationRecord } from "@freecorps/ember-client";
|
|
2
|
+
import { type Doc, type LocalSchema } from "@freecorps/ember-server";
|
|
3
|
+
import type { BackendReader, Commit, OfflineBackend, QueryLogEntry, 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
|
+
readonly log: Map<string, QueryLogEntry[]>;
|
|
10
|
+
/** shard → table → id → doc */
|
|
11
|
+
readonly rows: Map<string, Map<string, Map<string, Doc>>>;
|
|
12
|
+
/** Sorted rows per `shard\0table\0index`, rebuilt after writes. */
|
|
13
|
+
private sorted;
|
|
14
|
+
schema: LocalSchema | null;
|
|
15
|
+
constructor(schema?: LocalSchema);
|
|
16
|
+
/** Schema version the rows were written with: another one resets the replica (pending stays). */
|
|
17
|
+
private version;
|
|
18
|
+
open(): Promise<{
|
|
19
|
+
meta: {
|
|
20
|
+
[k: string]: string;
|
|
21
|
+
};
|
|
22
|
+
queries: QueryRecord[];
|
|
23
|
+
log: QueryLogEntry[];
|
|
24
|
+
}>;
|
|
25
|
+
setMeta(key: string, value: string | null): void;
|
|
26
|
+
loadPending(): PendingMutationRecord[];
|
|
27
|
+
putPending(r: PendingMutationRecord): void;
|
|
28
|
+
removePending(mid: string): void;
|
|
29
|
+
private table;
|
|
30
|
+
commit(shard: string, c: Commit): void;
|
|
31
|
+
reader(shard: string): BackendReader;
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Offline storage in memory: local queries and cached results without persistence (or, with a shared `backend`,
|
|
36
|
+
* surviving a client re-creation in the same process).
|
|
37
|
+
*/
|
|
38
|
+
export declare function memoryStorage(options: OfflineOptions & {
|
|
39
|
+
backend?: MemoryBackend;
|
|
40
|
+
}): OfflineStore;
|