@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/dist/tabs.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LocalIdentity, LocalQueryRequest, LocalQueryResult, PendingStorage, QueryUpdate, StorageAdapter, SyncSpec } from "@freecorps/ember-client";
|
|
2
|
+
import { type Endpoint, type ServableStorage } from "./remote.ts";
|
|
3
|
+
/** The part of `navigator.locks` (Web Locks API) used here. */
|
|
4
|
+
export interface LockManagerLike {
|
|
5
|
+
request(name: string, callback: (lock: unknown) => unknown): Promise<unknown>;
|
|
6
|
+
request(name: string, options: {
|
|
7
|
+
ifAvailable?: boolean;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}, callback: (lock: unknown) => unknown): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `failed`: this tab got the lead but could not open the storage, and no other tab leads yet — its calls fail.
|
|
13
|
+
* It follows (and queues behind) the next tab that announces itself as the leader.
|
|
14
|
+
*/
|
|
15
|
+
export type TabRole = "starting" | "leader" | "follower" | "failed" | "closed";
|
|
16
|
+
export interface TabsOptions {
|
|
17
|
+
/** The storage name the tabs share (lock and channel names derive from it). */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Opens the storage; called in the tab that becomes the leader (again in the next leader if it goes away). */
|
|
20
|
+
open(): ServableStorage | Promise<ServableStorage>;
|
|
21
|
+
/** Default `navigator.locks`; `null`: no coordination (this tab always leads). */
|
|
22
|
+
locks?: LockManagerLike | null;
|
|
23
|
+
/** Default `new BroadcastChannel("ember-offline:<name>")`. */
|
|
24
|
+
channel?: Endpoint & {
|
|
25
|
+
close(): void;
|
|
26
|
+
};
|
|
27
|
+
/** Tries to open (and load) the storage before giving up the lead (default 10, with backoff up to 1 s). */
|
|
28
|
+
openAttempts?: number;
|
|
29
|
+
onRoleChange?(role: TabRole): void;
|
|
30
|
+
}
|
|
31
|
+
/** A StorageAdapter that runs on the leader tab's storage, whichever tab that is. */
|
|
32
|
+
export declare class TabCoordinator implements StorageAdapter {
|
|
33
|
+
private readonly options;
|
|
34
|
+
readonly id: string;
|
|
35
|
+
private roleValue;
|
|
36
|
+
private readonly remote;
|
|
37
|
+
private readonly channel;
|
|
38
|
+
private readonly locks;
|
|
39
|
+
private readonly base;
|
|
40
|
+
private store;
|
|
41
|
+
private served;
|
|
42
|
+
private releaseLeader;
|
|
43
|
+
private releaseTab;
|
|
44
|
+
private readonly abort;
|
|
45
|
+
constructor(options: TabsOptions);
|
|
46
|
+
/** Follower: queue for the leader lock (granted when the leader goes away). */
|
|
47
|
+
private follow;
|
|
48
|
+
/** Another tab leads: a tab that failed to open follows it, and queues to try again when that one goes. */
|
|
49
|
+
private onLeader;
|
|
50
|
+
get role(): TabRole;
|
|
51
|
+
private setRole;
|
|
52
|
+
private get closed();
|
|
53
|
+
/**
|
|
54
|
+
* Runs while this tab holds the leader lock: open the storage, serve the other tabs, run our own calls locally,
|
|
55
|
+
* until close(). If the storage does not open, return (releasing the lock) as `failed`.
|
|
56
|
+
*/
|
|
57
|
+
private lead;
|
|
58
|
+
/** A follower's watches go when its tab does (its tab lock becomes free). */
|
|
59
|
+
private watchPeer;
|
|
60
|
+
get persistPending(): PendingStorage;
|
|
61
|
+
get sync(): readonly SyncSpec[];
|
|
62
|
+
load(): Promise<{
|
|
63
|
+
identity?: LocalIdentity | null;
|
|
64
|
+
}>;
|
|
65
|
+
applyPatch(u: QueryUpdate): Promise<void>;
|
|
66
|
+
runQueryLocally(req: LocalQueryRequest): Promise<LocalQueryResult | undefined>;
|
|
67
|
+
watchQueryLocally(req: LocalQueryRequest, onResult: (r: LocalQueryResult | undefined) => void, onError?: (e: Error) => void): () => void;
|
|
68
|
+
setIdentity(identity: LocalIdentity | null): Promise<void>;
|
|
69
|
+
flush(): Promise<void>;
|
|
70
|
+
clear(): Promise<void>;
|
|
71
|
+
/** Leave: a leader closes its storage and hands the lock to the next tab; a follower's watches are dropped. */
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Coordinates every tab that opens the storage `name`: one leader opens it (`open`), the others go through it.
|
|
76
|
+
*
|
|
77
|
+
* storage: coordinateTabs({ name: "chat", open: () => indexedDbStorage({ name: "chat", schema, queries, sync }) })
|
|
78
|
+
*/
|
|
79
|
+
export declare function coordinateTabs(options: TabsOptions): TabCoordinator;
|
package/dist/tabs.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Several tabs, one offline storage name. Exactly one tab — the leader — opens the storage and writes it (replicated
|
|
2
|
+
// rows, results, the pending queue); the others are followers that send every call to it. Leadership is a Web Lock;
|
|
3
|
+
// calls and results travel on a BroadcastChannel with the protocol of remote.ts.
|
|
4
|
+
//
|
|
5
|
+
// lock "ember-offline:<name>" held by the leader for as long as it lives (or until close()).
|
|
6
|
+
// lock "ember-offline:<name>:tab:<id>" held by each tab for its life; the leader waits on it to learn that a
|
|
7
|
+
// follower is gone (and drops its watches).
|
|
8
|
+
// channel "ember-offline:<name>" follower → leader: { k: "req", to: "leader", ... }; answers and watched
|
|
9
|
+
// results come back addressed to the follower's id.
|
|
10
|
+
//
|
|
11
|
+
// Start: a tab asks for the leader lock with `ifAvailable`. Granted → it opens the storage, serves the channel and
|
|
12
|
+
// announces itself (`k: "leader"`). Not granted → it is a follower and queues for the lock. When the leader goes
|
|
13
|
+
// away (tab closed, crashed, `close()`), the browser hands the lock to the next tab in the queue, which opens the
|
|
14
|
+
// storage (retrying while the old owner releases it, e.g. OPFS access handles) and announces itself; followers
|
|
15
|
+
// resend every call still in flight and re-establish their watches. A follower that becomes the leader re-runs its
|
|
16
|
+
// own in-flight calls locally. All calls are idempotent, so a call the old leader ran before dying is harmless.
|
|
17
|
+
//
|
|
18
|
+
// A tab that gets the lock but cannot open the storage (`openAttempts` tries) closes whatever it half-opened and
|
|
19
|
+
// gives the lock up as `failed`: its calls in flight and new ones fail at once (there may be no other tab to wait
|
|
20
|
+
// for), its watches get the error once. The next tab in the queue tries in turn; when one announces itself, the
|
|
21
|
+
// failed tab follows it like any follower (calls and watches go there) and queues for the lock again. Every tab that
|
|
22
|
+
// is neither leading nor failed is queued, so a call in flight always ends up answered or failed, never stuck.
|
|
23
|
+
//
|
|
24
|
+
// Local results stay live across tabs: the leader's store re-runs watched queries on every write, whoever sent it,
|
|
25
|
+
// and streams them to the watching tab. Without Web Locks (very old browsers) each tab opens the storage alone —
|
|
26
|
+
// the previous behaviour.
|
|
27
|
+
import { randomId, RemoteStorage, serveStorage } from "./remote.js";
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
/** A StorageAdapter that runs on the leader tab's storage, whichever tab that is. */
|
|
30
|
+
export class TabCoordinator {
|
|
31
|
+
options;
|
|
32
|
+
id = randomId();
|
|
33
|
+
roleValue = "starting";
|
|
34
|
+
remote;
|
|
35
|
+
channel;
|
|
36
|
+
locks;
|
|
37
|
+
base;
|
|
38
|
+
store = null;
|
|
39
|
+
served = null;
|
|
40
|
+
releaseLeader = null;
|
|
41
|
+
releaseTab = null;
|
|
42
|
+
abort = new AbortController();
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.options = options;
|
|
45
|
+
this.base = `ember-offline:${options.name}`;
|
|
46
|
+
const nav = globalThis.navigator;
|
|
47
|
+
this.locks = options.locks === undefined ? (nav?.locks ?? null) : options.locks;
|
|
48
|
+
this.channel = options.channel ?? new BroadcastChannel(this.base);
|
|
49
|
+
this.remote = new RemoteStorage(this.channel, { id: this.id, to: "leader", onLeader: () => this.onLeader() });
|
|
50
|
+
if (!this.locks) {
|
|
51
|
+
void this.lead();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const locks = this.locks;
|
|
55
|
+
void locks.request(`${this.base}:tab:${this.id}`, () => new Promise((r) => (this.releaseTab = r)));
|
|
56
|
+
void locks.request(this.base, { ifAvailable: true }, (lock) => {
|
|
57
|
+
if (lock)
|
|
58
|
+
return this.lead();
|
|
59
|
+
this.follow();
|
|
60
|
+
return undefined;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Follower: queue for the leader lock (granted when the leader goes away). */
|
|
64
|
+
follow() {
|
|
65
|
+
if (this.closed || !this.locks)
|
|
66
|
+
return;
|
|
67
|
+
this.setRole("follower");
|
|
68
|
+
void this.locks.request(this.base, { signal: this.abort.signal }, () => this.lead()).catch(() => undefined);
|
|
69
|
+
}
|
|
70
|
+
/** Another tab leads: a tab that failed to open follows it, and queues to try again when that one goes. */
|
|
71
|
+
onLeader() {
|
|
72
|
+
if (this.roleValue === "failed")
|
|
73
|
+
this.follow();
|
|
74
|
+
}
|
|
75
|
+
get role() {
|
|
76
|
+
return this.roleValue;
|
|
77
|
+
}
|
|
78
|
+
setRole(r) {
|
|
79
|
+
if (this.roleValue === r || this.roleValue === "closed")
|
|
80
|
+
return;
|
|
81
|
+
this.roleValue = r;
|
|
82
|
+
this.options.onRoleChange?.(r);
|
|
83
|
+
}
|
|
84
|
+
get closed() {
|
|
85
|
+
return this.roleValue === "closed";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Runs while this tab holds the leader lock: open the storage, serve the other tabs, run our own calls locally,
|
|
89
|
+
* until close(). If the storage does not open, return (releasing the lock) as `failed`.
|
|
90
|
+
*/
|
|
91
|
+
async lead() {
|
|
92
|
+
if (this.closed)
|
|
93
|
+
return;
|
|
94
|
+
let store = null;
|
|
95
|
+
let error;
|
|
96
|
+
const attempts = Math.max(1, this.options.openAttempts ?? 10);
|
|
97
|
+
for (let attempt = 0; attempt < attempts && !this.closed; attempt++) {
|
|
98
|
+
try {
|
|
99
|
+
store = await this.options.open();
|
|
100
|
+
await store.load();
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
error = e;
|
|
105
|
+
// Opened but not loaded: release it (e.g. OPFS access handles) before trying again or giving up.
|
|
106
|
+
const opened = store;
|
|
107
|
+
store = null;
|
|
108
|
+
await Promise.resolve()
|
|
109
|
+
.then(() => opened?.close?.())
|
|
110
|
+
.catch(() => undefined);
|
|
111
|
+
// The previous leader may still be releasing the storage (OPFS handles close with its worker).
|
|
112
|
+
if (attempt + 1 < attempts)
|
|
113
|
+
await sleep(Math.min(1000, 20 * 2 ** attempt));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (this.closed) {
|
|
117
|
+
// close() came during the takeover: what we opened meanwhile is closed here.
|
|
118
|
+
await Promise.resolve()
|
|
119
|
+
.then(() => store?.close?.())
|
|
120
|
+
.catch(() => undefined);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!store) {
|
|
124
|
+
// Nobody gets a half-open storage. The lock goes to the next tab, which may do better; until a leader
|
|
125
|
+
// announces itself our calls fail (none may ever come), then we follow it (onLeader).
|
|
126
|
+
const e = error instanceof Error ? error : new Error(String(error));
|
|
127
|
+
if (this.locks)
|
|
128
|
+
this.remote.setUnavailable(e);
|
|
129
|
+
// Without Web Locks every tab leads alone: there is nobody to follow.
|
|
130
|
+
else
|
|
131
|
+
this.remote.setLocal(failing(e));
|
|
132
|
+
this.setRole("failed");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.store = store;
|
|
136
|
+
this.served = serveStorage(store, this.channel, { address: "leader", onPeer: (p) => this.watchPeer(p) });
|
|
137
|
+
this.remote.setLocal(store);
|
|
138
|
+
this.served.announce(this.id);
|
|
139
|
+
this.setRole("leader");
|
|
140
|
+
await new Promise((r) => (this.releaseLeader = r));
|
|
141
|
+
}
|
|
142
|
+
/** A follower's watches go when its tab does (its tab lock becomes free). */
|
|
143
|
+
watchPeer(peer) {
|
|
144
|
+
void this.locks?.request(`${this.base}:tab:${peer}`, () => this.served?.dropPeer(peer)).catch(() => undefined);
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------------------------- StorageAdapter
|
|
147
|
+
get persistPending() {
|
|
148
|
+
return this.remote.persistPending;
|
|
149
|
+
}
|
|
150
|
+
get sync() {
|
|
151
|
+
return this.remote.sync;
|
|
152
|
+
}
|
|
153
|
+
load() {
|
|
154
|
+
return this.remote.load();
|
|
155
|
+
}
|
|
156
|
+
applyPatch(u) {
|
|
157
|
+
return this.remote.applyPatch(u);
|
|
158
|
+
}
|
|
159
|
+
runQueryLocally(req) {
|
|
160
|
+
return this.remote.runQueryLocally(req);
|
|
161
|
+
}
|
|
162
|
+
watchQueryLocally(req, onResult, onError) {
|
|
163
|
+
return this.remote.watchQueryLocally(req, onResult, onError);
|
|
164
|
+
}
|
|
165
|
+
setIdentity(identity) {
|
|
166
|
+
return this.remote.setIdentity(identity);
|
|
167
|
+
}
|
|
168
|
+
flush() {
|
|
169
|
+
return this.remote.flush();
|
|
170
|
+
}
|
|
171
|
+
clear() {
|
|
172
|
+
return this.remote.clear();
|
|
173
|
+
}
|
|
174
|
+
/** Leave: a leader closes its storage and hands the lock to the next tab; a follower's watches are dropped. */
|
|
175
|
+
async close() {
|
|
176
|
+
if (this.roleValue === "closed")
|
|
177
|
+
return;
|
|
178
|
+
if (this.store)
|
|
179
|
+
await this.remote.flush().catch(() => undefined);
|
|
180
|
+
this.roleValue = "closed";
|
|
181
|
+
this.options.onRoleChange?.("closed");
|
|
182
|
+
this.abort.abort();
|
|
183
|
+
this.remote.close();
|
|
184
|
+
this.served?.stop();
|
|
185
|
+
this.served = null;
|
|
186
|
+
const store = this.store;
|
|
187
|
+
this.store = null;
|
|
188
|
+
await store?.close?.();
|
|
189
|
+
this.releaseLeader?.();
|
|
190
|
+
this.releaseTab?.();
|
|
191
|
+
this.channel.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function failing(error) {
|
|
195
|
+
const fail = () => Promise.reject(error);
|
|
196
|
+
return {
|
|
197
|
+
load: fail,
|
|
198
|
+
applyPatch: fail,
|
|
199
|
+
runQueryLocally: fail,
|
|
200
|
+
persistPending: { getClientId: fail, setClientId: fail, load: fail, put: fail, remove: fail },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Coordinates every tab that opens the storage `name`: one leader opens it (`open`), the others go through it.
|
|
205
|
+
*
|
|
206
|
+
* storage: coordinateTabs({ name: "chat", open: () => indexedDbStorage({ name: "chat", schema, queries, sync }) })
|
|
207
|
+
*/
|
|
208
|
+
export function coordinateTabs(options) {
|
|
209
|
+
return new TabCoordinator(options);
|
|
210
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type PendingMutationRecord } from "@freecorps/ember-client";
|
|
2
|
+
import { type LocalSchema } from "@freecorps/ember-server";
|
|
3
|
+
import type { BackendReader, Commit, ExternalChange, OfflineBackend, QueryLogEntry, QueryRecord } from "./backend.ts";
|
|
4
|
+
import { type OfflineOptions, type OfflineStore } from "./store.ts";
|
|
5
|
+
type Cell = string | number | boolean | null | Record<string, unknown> | unknown[];
|
|
6
|
+
type Row = Record<string, Cell>;
|
|
7
|
+
/** The part of a TinyBase `Store` (v5+) used here. */
|
|
8
|
+
export interface TinyStore {
|
|
9
|
+
getRow(tableId: string, rowId: string): Row;
|
|
10
|
+
hasRow(tableId: string, rowId: string): boolean;
|
|
11
|
+
getTable(tableId: string): Record<string, Row>;
|
|
12
|
+
setRow(tableId: string, rowId: string, row: Row): unknown;
|
|
13
|
+
delRow(tableId: string, rowId: string): unknown;
|
|
14
|
+
delTable(tableId: string): unknown;
|
|
15
|
+
getValue(valueId: string): unknown;
|
|
16
|
+
setValue(valueId: string, value: string | number | boolean): unknown;
|
|
17
|
+
delValue(valueId: string): unknown;
|
|
18
|
+
getValueIds(): string[];
|
|
19
|
+
transaction<T>(actions: () => T): T;
|
|
20
|
+
addRowListener(tableId: string | null, rowId: string | null, listener: (store: unknown, tableId: string, rowId: string, getCellChange?: (t: string, r: string, c: string) => [boolean, unknown, unknown]) => void): string;
|
|
21
|
+
delListener(listenerId: string): unknown;
|
|
22
|
+
}
|
|
23
|
+
/** A TinyBase persister (or anything with load/save). */
|
|
24
|
+
export interface TinyPersister {
|
|
25
|
+
load(): Promise<unknown>;
|
|
26
|
+
save(): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
export interface TinybaseStorageOptions extends OfflineOptions {
|
|
29
|
+
/** The app's TinyBase store. */
|
|
30
|
+
store: TinyStore;
|
|
31
|
+
/** Loaded on open, saved after every write (omit if the app runs its own auto-load / auto-save). */
|
|
32
|
+
persister?: TinyPersister;
|
|
33
|
+
}
|
|
34
|
+
export declare class TinybaseBackend implements OfflineBackend {
|
|
35
|
+
private readonly schema;
|
|
36
|
+
private readonly store;
|
|
37
|
+
private readonly persister?;
|
|
38
|
+
/** Shards in first-seen order; the first one's rows carry no `_shard` cell. */
|
|
39
|
+
private shards;
|
|
40
|
+
/** `table` → id → doc (decoded lazily, dropped on any change of the table). */
|
|
41
|
+
private docs;
|
|
42
|
+
private sorted;
|
|
43
|
+
private writing;
|
|
44
|
+
private listener;
|
|
45
|
+
private readonly external;
|
|
46
|
+
private saving;
|
|
47
|
+
constructor(schema: LocalSchema, store: TinyStore, persister?: TinyPersister | undefined);
|
|
48
|
+
private get tables();
|
|
49
|
+
open(): Promise<{
|
|
50
|
+
meta: Record<string, string>;
|
|
51
|
+
queries: QueryRecord[];
|
|
52
|
+
log: QueryLogEntry[];
|
|
53
|
+
}>;
|
|
54
|
+
/** Our writes run with `writing` set: the listener only reports other writers' changes. */
|
|
55
|
+
private write;
|
|
56
|
+
/**
|
|
57
|
+
* A write that is only done once the persister saved it. Every row and value it changes is recorded first; if
|
|
58
|
+
* the save fails, they are put back (and the shard list with them), so the Store never holds a write the
|
|
59
|
+
* offline store was told failed (it rolls its own memory back and the next server value retries).
|
|
60
|
+
*/
|
|
61
|
+
private writeSaved;
|
|
62
|
+
private save;
|
|
63
|
+
private changed;
|
|
64
|
+
onExternalChange(listener: (c: ExternalChange) => void): () => void;
|
|
65
|
+
private shardCell;
|
|
66
|
+
setMeta(key: string, value: string | null): Promise<void>;
|
|
67
|
+
loadPending(): PendingMutationRecord[];
|
|
68
|
+
putPending(r: PendingMutationRecord): Promise<void>;
|
|
69
|
+
removePending(mid: string): Promise<void>;
|
|
70
|
+
commit(shard: string, c: Commit): Promise<void>;
|
|
71
|
+
/** `table`'s rows (all shards), decoded once per change of the table. */
|
|
72
|
+
private rowsOf;
|
|
73
|
+
reader(shard: string): BackendReader;
|
|
74
|
+
close(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
/** Offline storage in a TinyBase store (reactive in the app's TinyBase bindings). */
|
|
77
|
+
export declare function tinybaseStorage(options: TinybaseStorageOptions): OfflineStore;
|
|
78
|
+
export {};
|
package/dist/tinybase.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// TinyBase offline storage: the replica lives in the app's own TinyBase `Store`, one TinyBase table per Ember table
|
|
2
|
+
// (row id = `_id`, cells = the document's fields), so components already bound to TinyBase (`useTable`, `useRow`,
|
|
3
|
+
// queries, metrics) render replicated rows directly and stay reactive. Ember's own state sits in `_ember_*` tables
|
|
4
|
+
// (query records, their row-diff log, the pending queue) and `_ember_*` values (metadata).
|
|
5
|
+
//
|
|
6
|
+
// Why a backend over the Store, not a TinyBase persister: a persister copies a whole Store to and from some storage;
|
|
7
|
+
// it neither puts Ember's rows where the app reads them nor answers ranged reads by the server's encoded index keys.
|
|
8
|
+
// As a backend, local queries run with the same compiler as elsewhere (index ranges by encoded key, `jsSearch`),
|
|
9
|
+
// rows are written with ownership and atomically (`store.transaction`), and persistence stays the app's choice:
|
|
10
|
+
// any TinyBase persister on the same Store (IndexedDB, OPFS, expo-sqlite, a file...) — pass it as `persister` to have
|
|
11
|
+
// it loaded on open and saved after every write, or run its auto-load/auto-save yourself.
|
|
12
|
+
//
|
|
13
|
+
// Cells are JSON values: strings, numbers and booleans as they are; objects, arrays and null as JSON; values JSON
|
|
14
|
+
// cannot carry (bigint, bytes, NaN/±Infinity/-0) in Ember's tagged form (`{"$int64": "…"}`, `{"$bytes": "…"}`).
|
|
15
|
+
// Rows of other shards than the first one carry a `_shard` cell. Writes the app makes to these tables are seen
|
|
16
|
+
// (local queries re-run) but are overwritten by the next server value: the server stays the source of truth.
|
|
17
|
+
//
|
|
18
|
+
// import { createStore } from "tinybase";
|
|
19
|
+
// import { tinybaseStorage } from "@freecorps/ember-offline/tinybase";
|
|
20
|
+
// const store = createStore();
|
|
21
|
+
// new EmberClient({ url, storage: tinybaseStorage({ store, schema, queries: { messages } }) });
|
|
22
|
+
import { decodeJson, encodeJson } from "@freecorps/ember-client";
|
|
23
|
+
import { compareBytes, inKeyRange } from "@freecorps/ember-values";
|
|
24
|
+
import { docKey, indexFields, jsSearch } from "@freecorps/ember-server";
|
|
25
|
+
import { createOfflineStorage } from "./store.js";
|
|
26
|
+
const QUERIES = "_ember_queries";
|
|
27
|
+
const LOG = "_ember_log";
|
|
28
|
+
const PENDING = "_ember_pending";
|
|
29
|
+
const META = "_ember_meta:";
|
|
30
|
+
const SCHEMA = "_ember_schema";
|
|
31
|
+
const SHARDS = "_ember_shards";
|
|
32
|
+
const SHARD_CELL = "_shard";
|
|
33
|
+
const toRow = (doc, shard) => {
|
|
34
|
+
const row = JSON.parse(encodeJson(doc));
|
|
35
|
+
if (shard !== null)
|
|
36
|
+
row[SHARD_CELL] = shard;
|
|
37
|
+
return row;
|
|
38
|
+
};
|
|
39
|
+
const toDoc = (row) => {
|
|
40
|
+
const { [SHARD_CELL]: _s, ...rest } = row;
|
|
41
|
+
return decodeJson(JSON.stringify(rest));
|
|
42
|
+
};
|
|
43
|
+
export class TinybaseBackend {
|
|
44
|
+
schema;
|
|
45
|
+
store;
|
|
46
|
+
persister;
|
|
47
|
+
/** Shards in first-seen order; the first one's rows carry no `_shard` cell. */
|
|
48
|
+
shards = [];
|
|
49
|
+
/** `table` → id → doc (decoded lazily, dropped on any change of the table). */
|
|
50
|
+
docs = new Map();
|
|
51
|
+
sorted = new Map();
|
|
52
|
+
writing = false;
|
|
53
|
+
listener = null;
|
|
54
|
+
external = new Set();
|
|
55
|
+
saving = Promise.resolve();
|
|
56
|
+
constructor(schema, store, persister) {
|
|
57
|
+
this.schema = schema;
|
|
58
|
+
this.store = store;
|
|
59
|
+
this.persister = persister;
|
|
60
|
+
}
|
|
61
|
+
get tables() {
|
|
62
|
+
return Object.keys(this.schema.manifest.tables);
|
|
63
|
+
}
|
|
64
|
+
async open() {
|
|
65
|
+
await this.persister?.load();
|
|
66
|
+
const s = this.store;
|
|
67
|
+
if (s.getValue(SCHEMA) !== this.schema.manifest.version) {
|
|
68
|
+
// A new schema: the replica is a cache, the server resends it (the pending queue and metadata stay).
|
|
69
|
+
await this.writeSaved((tx) => {
|
|
70
|
+
for (const t of this.tables)
|
|
71
|
+
tx.delTable(t);
|
|
72
|
+
tx.delTable(QUERIES);
|
|
73
|
+
tx.delTable(LOG);
|
|
74
|
+
tx.delValue(SHARDS);
|
|
75
|
+
tx.setValue(SCHEMA, this.schema.manifest.version);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const raw = s.getValue(SHARDS);
|
|
79
|
+
this.shards = typeof raw === "string" && raw ? JSON.parse(raw) : [];
|
|
80
|
+
const meta = {};
|
|
81
|
+
for (const id of s.getValueIds()) {
|
|
82
|
+
const v = s.getValue(id);
|
|
83
|
+
if (id.startsWith(META) && typeof v === "string")
|
|
84
|
+
meta[id.slice(META.length)] = v;
|
|
85
|
+
}
|
|
86
|
+
const queries = Object.values(s.getTable(QUERIES)).map((r) => decodeJson(String(r.record)));
|
|
87
|
+
const log = Object.values(s.getTable(LOG)).map((r) => decodeJson(String(r.entry)));
|
|
88
|
+
this.listener = s.addRowListener(null, null, (_s, table, rowId, change) => this.changed(table, rowId, change));
|
|
89
|
+
return { meta, queries, log };
|
|
90
|
+
}
|
|
91
|
+
/** Our writes run with `writing` set: the listener only reports other writers' changes. */
|
|
92
|
+
write(f) {
|
|
93
|
+
this.writing = true;
|
|
94
|
+
try {
|
|
95
|
+
this.store.transaction(f);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
this.writing = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A write that is only done once the persister saved it. Every row and value it changes is recorded first; if
|
|
103
|
+
* the save fails, they are put back (and the shard list with them), so the Store never holds a write the
|
|
104
|
+
* offline store was told failed (it rolls its own memory back and the next server value retries).
|
|
105
|
+
*/
|
|
106
|
+
async writeSaved(f) {
|
|
107
|
+
const s = this.store;
|
|
108
|
+
const rows = new Map();
|
|
109
|
+
const values = new Map();
|
|
110
|
+
const shards = this.shards;
|
|
111
|
+
const keepRow = (table, id) => {
|
|
112
|
+
const k = `${table}\u0000${id}`;
|
|
113
|
+
if (!rows.has(k))
|
|
114
|
+
rows.set(k, { table, id, row: s.hasRow(table, id) ? s.getRow(table, id) : null });
|
|
115
|
+
};
|
|
116
|
+
const keepValue = (id) => {
|
|
117
|
+
if (!values.has(id))
|
|
118
|
+
values.set(id, s.getValue(id));
|
|
119
|
+
};
|
|
120
|
+
const tx = {
|
|
121
|
+
setRow: (table, id, row) => (keepRow(table, id), s.setRow(table, id, row)),
|
|
122
|
+
delRow: (table, id) => (keepRow(table, id), s.delRow(table, id)),
|
|
123
|
+
delTable: (table) => {
|
|
124
|
+
for (const id of Object.keys(s.getTable(table)))
|
|
125
|
+
keepRow(table, id);
|
|
126
|
+
s.delTable(table);
|
|
127
|
+
},
|
|
128
|
+
setValue: (id, v) => (keepValue(id), s.setValue(id, v)),
|
|
129
|
+
delValue: (id) => (keepValue(id), s.delValue(id)),
|
|
130
|
+
};
|
|
131
|
+
this.write(() => f(tx));
|
|
132
|
+
try {
|
|
133
|
+
await this.save();
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
this.write(() => {
|
|
137
|
+
for (const { table, id, row } of rows.values()) {
|
|
138
|
+
if (row)
|
|
139
|
+
s.setRow(table, id, row);
|
|
140
|
+
else
|
|
141
|
+
s.delRow(table, id);
|
|
142
|
+
}
|
|
143
|
+
for (const [id, v] of values) {
|
|
144
|
+
if (v === undefined)
|
|
145
|
+
s.delValue(id);
|
|
146
|
+
else
|
|
147
|
+
s.setValue(id, v);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
this.shards = shards;
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
save() {
|
|
155
|
+
if (!this.persister)
|
|
156
|
+
return Promise.resolve();
|
|
157
|
+
const p = this.persister;
|
|
158
|
+
const next = this.saving.then(() => p.save());
|
|
159
|
+
this.saving = next.catch(() => undefined);
|
|
160
|
+
return next.then(() => undefined);
|
|
161
|
+
}
|
|
162
|
+
changed(table, rowId, change) {
|
|
163
|
+
if (!(table in this.schema.manifest.tables))
|
|
164
|
+
return;
|
|
165
|
+
this.docs.delete(table);
|
|
166
|
+
for (const k of [...this.sorted.keys()])
|
|
167
|
+
if (k.startsWith(`${table}\u0000`))
|
|
168
|
+
this.sorted.delete(k);
|
|
169
|
+
if (this.writing || !this.external.size)
|
|
170
|
+
return;
|
|
171
|
+
const row = this.store.hasRow(table, rowId) ? this.store.getRow(table, rowId) : null;
|
|
172
|
+
const before = change?.(table, rowId, SHARD_CELL)?.[1];
|
|
173
|
+
const shards = new Set();
|
|
174
|
+
for (const s of [row?.[SHARD_CELL], before])
|
|
175
|
+
shards.add(typeof s === "string" ? s : (this.shards[0] ?? ""));
|
|
176
|
+
for (const shard of shards)
|
|
177
|
+
for (const l of this.external)
|
|
178
|
+
l({ shard, tables: [table] });
|
|
179
|
+
}
|
|
180
|
+
onExternalChange(listener) {
|
|
181
|
+
this.external.add(listener);
|
|
182
|
+
return () => this.external.delete(listener);
|
|
183
|
+
}
|
|
184
|
+
shardCell(tx, shard) {
|
|
185
|
+
let i = this.shards.indexOf(shard);
|
|
186
|
+
if (i < 0) {
|
|
187
|
+
// A new array: a failed save puts the previous one back.
|
|
188
|
+
this.shards = [...this.shards, shard];
|
|
189
|
+
tx.setValue(SHARDS, JSON.stringify(this.shards));
|
|
190
|
+
i = this.shards.length - 1;
|
|
191
|
+
}
|
|
192
|
+
return i === 0 ? null : shard;
|
|
193
|
+
}
|
|
194
|
+
async setMeta(key, value) {
|
|
195
|
+
await this.writeSaved((tx) => (value === null ? tx.delValue(META + key) : tx.setValue(META + key, value)));
|
|
196
|
+
}
|
|
197
|
+
loadPending() {
|
|
198
|
+
return Object.values(this.store.getTable(PENDING))
|
|
199
|
+
.sort((a, b) => Number(a.seq) - Number(b.seq))
|
|
200
|
+
.map((r) => decodeJson(String(r.record)));
|
|
201
|
+
}
|
|
202
|
+
async putPending(r) {
|
|
203
|
+
await this.writeSaved((tx) => {
|
|
204
|
+
const cur = this.store.hasRow(PENDING, r.mid) ? this.store.getRow(PENDING, r.mid) : null;
|
|
205
|
+
const seq = cur ? Number(cur.seq) : Math.max(0, ...Object.values(this.store.getTable(PENDING)).map((x) => Number(x.seq))) + 1;
|
|
206
|
+
tx.setRow(PENDING, r.mid, { seq, record: encodeJson(r) });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async removePending(mid) {
|
|
210
|
+
await this.writeSaved((tx) => tx.delRow(PENDING, mid));
|
|
211
|
+
}
|
|
212
|
+
async commit(shard, c) {
|
|
213
|
+
const s = this.store;
|
|
214
|
+
const logIds = (key) => Object.keys(s.getTable(LOG)).filter((id) => id.startsWith(`${key}\u0000`));
|
|
215
|
+
await this.writeSaved((tx) => {
|
|
216
|
+
const cell = this.shardCell(tx, shard);
|
|
217
|
+
for (const r of c.put ?? []) {
|
|
218
|
+
tx.setRow(QUERIES, r.key, { record: encodeJson(r) });
|
|
219
|
+
for (const id of logIds(r.key))
|
|
220
|
+
tx.delRow(LOG, id);
|
|
221
|
+
}
|
|
222
|
+
for (const k of c.drop ?? []) {
|
|
223
|
+
tx.delRow(QUERIES, k);
|
|
224
|
+
for (const id of logIds(k))
|
|
225
|
+
tx.delRow(LOG, id);
|
|
226
|
+
}
|
|
227
|
+
for (const e of c.append ?? [])
|
|
228
|
+
tx.setRow(LOG, `${e.key}\u0000${String(e.seq).padStart(10, "0")}`, { entry: encodeJson(e) });
|
|
229
|
+
for (const { table, id } of c.delete ?? [])
|
|
230
|
+
tx.delRow(table, id);
|
|
231
|
+
for (const { table, doc } of c.upsert ?? [])
|
|
232
|
+
tx.setRow(table, doc._id, toRow(doc, cell));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/** `table`'s rows (all shards), decoded once per change of the table. */
|
|
236
|
+
rowsOf(table) {
|
|
237
|
+
let m = this.docs.get(table);
|
|
238
|
+
if (!m) {
|
|
239
|
+
m = new Map();
|
|
240
|
+
const first = this.shards[0] ?? "";
|
|
241
|
+
for (const [id, row] of Object.entries(this.store.getTable(table))) {
|
|
242
|
+
const shard = typeof row[SHARD_CELL] === "string" ? row[SHARD_CELL] : first;
|
|
243
|
+
try {
|
|
244
|
+
m.set(id, { shard, doc: toDoc(row) });
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* a row the app wrote in another shape: not a replicated document */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
this.docs.set(table, m);
|
|
251
|
+
}
|
|
252
|
+
return m;
|
|
253
|
+
}
|
|
254
|
+
reader(shard) {
|
|
255
|
+
const docsOf = (table) => [...this.rowsOf(table).values()].filter((r) => r.shard === shard).map((r) => r.doc);
|
|
256
|
+
const sortedRows = (table, index) => {
|
|
257
|
+
const k = `${table}\u0000${index}\u0000${shard}`;
|
|
258
|
+
let rows = this.sorted.get(k);
|
|
259
|
+
if (!rows) {
|
|
260
|
+
const fields = indexFields(this.schema.manifest.tables[table], index);
|
|
261
|
+
rows = docsOf(table)
|
|
262
|
+
.map((doc) => ({ key: docKey(doc, fields), doc }))
|
|
263
|
+
.sort((a, b) => compareBytes(a.key, b.key));
|
|
264
|
+
this.sorted.set(k, rows);
|
|
265
|
+
}
|
|
266
|
+
return rows;
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
scan: (table, index, lo, hi, asc, limit) => {
|
|
270
|
+
const inRange = sortedRows(table, index).filter((r) => inKeyRange(r.key, lo, hi));
|
|
271
|
+
return (asc ? inRange : inRange.reverse()).slice(0, limit);
|
|
272
|
+
},
|
|
273
|
+
get: (table, id) => {
|
|
274
|
+
const r = this.rowsOf(table).get(id);
|
|
275
|
+
return r && r.shard === shard ? r.doc : null;
|
|
276
|
+
},
|
|
277
|
+
pluginQuery: (table, kind, spec, arg) => {
|
|
278
|
+
if (kind.kind !== "search")
|
|
279
|
+
throw new Error(`index kind ${kind.kind} is not supported in TinyBase`);
|
|
280
|
+
return jsSearch(docsOf(table), spec, arg);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async close() {
|
|
285
|
+
if (this.listener !== null)
|
|
286
|
+
this.store.delListener(this.listener);
|
|
287
|
+
this.listener = null;
|
|
288
|
+
await this.saving;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Offline storage in a TinyBase store (reactive in the app's TinyBase bindings). */
|
|
292
|
+
export function tinybaseStorage(options) {
|
|
293
|
+
return createOfflineStorage(new TinybaseBackend(options.schema, options.store, options.persister), options);
|
|
294
|
+
}
|