@runappsai/sdk 0.5.0 → 0.6.1
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 +30 -1
- package/dist/activity-tracker.d.ts +8 -0
- package/dist/activity-tracker.d.ts.map +1 -1
- package/dist/activity-tracker.js +3 -0
- package/dist/activity-tracker.js.map +1 -1
- package/dist/browser-auth.d.ts +7 -0
- package/dist/browser-auth.d.ts.map +1 -1
- package/dist/browser-auth.js +105 -15
- package/dist/browser-auth.js.map +1 -1
- package/dist/client.d.ts +24 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +22 -0
- package/dist/client.js.map +1 -1
- package/dist/events.d.ts +20 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/files.d.ts +3 -0
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +4 -1
- package/dist/files.js.map +1 -1
- package/dist/idb-sync/codec.d.ts +39 -0
- package/dist/idb-sync/codec.d.ts.map +1 -0
- package/dist/idb-sync/codec.js +364 -0
- package/dist/idb-sync/codec.js.map +1 -0
- package/dist/idb-sync/engine.d.ts +143 -0
- package/dist/idb-sync/engine.d.ts.map +1 -0
- package/dist/idb-sync/engine.js +727 -0
- package/dist/idb-sync/engine.js.map +1 -0
- package/dist/idb-sync/hook.d.ts +42 -0
- package/dist/idb-sync/hook.d.ts.map +1 -0
- package/dist/idb-sync/hook.js +426 -0
- package/dist/idb-sync/hook.js.map +1 -0
- package/dist/idb-sync/index.d.ts +6 -0
- package/dist/idb-sync/index.d.ts.map +1 -0
- package/dist/idb-sync/index.js +6 -0
- package/dist/idb-sync/index.js.map +1 -0
- package/dist/idb-sync/log.d.ts +41 -0
- package/dist/idb-sync/log.d.ts.map +1 -0
- package/dist/idb-sync/log.js +143 -0
- package/dist/idb-sync/log.js.map +1 -0
- package/dist/idb-sync/remote.d.ts +38 -0
- package/dist/idb-sync/remote.d.ts.map +1 -0
- package/dist/idb-sync/remote.js +61 -0
- package/dist/idb-sync/remote.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +1 -1
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +4 -2
- package/dist/models.js.map +1 -1
- package/dist/sdk.umd.js +2 -2
- package/dist/sdk.umd.js.map +1 -1
- package/dist/transport.d.ts +11 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +33 -6
- package/dist/transport.js.map +1 -1
- package/package.json +2 -1
- package/src/activity-tracker.ts +5 -0
- package/src/browser-auth.ts +103 -16
- package/src/client.ts +36 -0
- package/src/events.ts +22 -0
- package/src/files.ts +7 -5
- package/src/idb-sync/codec.ts +354 -0
- package/src/idb-sync/engine.ts +818 -0
- package/src/idb-sync/hook.ts +495 -0
- package/src/idb-sync/index.ts +5 -0
- package/src/idb-sync/log.ts +170 -0
- package/src/idb-sync/remote.ts +59 -0
- package/src/index.ts +13 -0
- package/src/models.ts +4 -2
- package/src/transport.ts +38 -8
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// idb-sync/hook.ts — observe an app's IndexedDB writes without the app
|
|
2
|
+
// knowing.
|
|
3
|
+
//
|
|
4
|
+
// There is no browser event for "an object store changed", so the SDK
|
|
5
|
+
// wraps the few prototype methods every write goes through — on the
|
|
6
|
+
// factory, the database, object stores and cursors. Dexie, idb,
|
|
7
|
+
// idb-keyval, zustand's IndexedDB storage and hand-written code all end
|
|
8
|
+
// up in these methods, so they are all observed.
|
|
9
|
+
//
|
|
10
|
+
// What the hook does:
|
|
11
|
+
// open() returns a request that forwards the real one's
|
|
12
|
+
// events, and delays `success` until `onOpen` (the
|
|
13
|
+
// engine's first pull) has run, so a new device sees
|
|
14
|
+
// its data on the first read.
|
|
15
|
+
// transaction() attaches a change collector to readwrite
|
|
16
|
+
// transactions on observed databases.
|
|
17
|
+
// put/add/delete/clear, cursor.update/delete
|
|
18
|
+
// record the committed change into the collector; the
|
|
19
|
+
// batch is handed to `onCommit` only when the
|
|
20
|
+
// transaction completes, so an aborted write is never
|
|
21
|
+
// synced. A put also reads the record's previous value
|
|
22
|
+
// first (same transaction), so the engine can tell which
|
|
23
|
+
// fields the write changed and merge field by field.
|
|
24
|
+
//
|
|
25
|
+
// Writes the engine itself makes while applying remote changes go
|
|
26
|
+
// through its own connection, which is never tagged, so they are not
|
|
27
|
+
// recorded again.
|
|
28
|
+
//
|
|
29
|
+
// Several subscribers (engines) can be installed at once; each sees the
|
|
30
|
+
// databases its `include` admits, on the factory it was bound to (tests
|
|
31
|
+
// run one "device" per factory; a page has one factory).
|
|
32
|
+
|
|
33
|
+
import { extractKey } from "./codec.js";
|
|
34
|
+
|
|
35
|
+
export type CommittedOp =
|
|
36
|
+
| { store: string; kind: "put"; key: IDBValidKey; value: unknown; prior?: unknown }
|
|
37
|
+
| { store: string; kind: "del"; key: IDBValidKey }
|
|
38
|
+
| { store: string; kind: "clear" };
|
|
39
|
+
|
|
40
|
+
export interface HookOptions {
|
|
41
|
+
/** Which databases (by name) to observe. */
|
|
42
|
+
include: (dbName: string) => boolean;
|
|
43
|
+
/** Stores to leave out of sync. */
|
|
44
|
+
excludeStore: (dbName: string, store: string) => boolean;
|
|
45
|
+
/** A readwrite transaction on an observed database committed. */
|
|
46
|
+
onCommit: (dbName: string, ops: CommittedOp[]) => void;
|
|
47
|
+
/** The app opened an observed database. The open request's success is
|
|
48
|
+
* held until this resolves (or `openTimeoutMs` passes). */
|
|
49
|
+
onOpen: (db: IDBDatabase, dbName: string) => Promise<void> | void;
|
|
50
|
+
/** The app deleted an observed database. */
|
|
51
|
+
onDeleteDatabase: (dbName: string) => void;
|
|
52
|
+
openTimeoutMs: number;
|
|
53
|
+
/** Only observe this factory's databases. Default: the global one at
|
|
54
|
+
* install time. */
|
|
55
|
+
factory?: IDBFactory;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface InstalledHook {
|
|
59
|
+
/** The factory's untouched open — for the SDK's own databases. */
|
|
60
|
+
originalOpen: (name: string, version?: number) => IDBOpenDBRequest;
|
|
61
|
+
uninstall(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Subscriber {
|
|
65
|
+
opts: HookOptions;
|
|
66
|
+
factory: IDBFactory | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TxState {
|
|
70
|
+
db: string;
|
|
71
|
+
subs: Subscriber[];
|
|
72
|
+
ops: CommittedOp[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface DbInfo {
|
|
76
|
+
name: string;
|
|
77
|
+
subs: Subscriber[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const txState = new WeakMap<IDBTransaction, TxState>();
|
|
81
|
+
/** IDBDatabase handles the app obtained through the hooked open. */
|
|
82
|
+
const observedDbs = new WeakMap<IDBDatabase, DbInfo>();
|
|
83
|
+
const subscribers = new Set<Subscriber>();
|
|
84
|
+
let originals: {
|
|
85
|
+
open: IDBFactory["open"];
|
|
86
|
+
deleteDatabase: IDBFactory["deleteDatabase"];
|
|
87
|
+
transaction: IDBDatabase["transaction"];
|
|
88
|
+
put: IDBObjectStore["put"];
|
|
89
|
+
add: IDBObjectStore["add"];
|
|
90
|
+
get: IDBObjectStore["get"];
|
|
91
|
+
delete: IDBObjectStore["delete"];
|
|
92
|
+
clear: IDBObjectStore["clear"];
|
|
93
|
+
getAllKeys: IDBObjectStore["getAllKeys"];
|
|
94
|
+
cursorUpdate: IDBCursor["update"];
|
|
95
|
+
cursorDelete: IDBCursor["delete"];
|
|
96
|
+
} | null = null;
|
|
97
|
+
|
|
98
|
+
function clone<T>(v: T): T {
|
|
99
|
+
try {
|
|
100
|
+
return typeof structuredClone === "function" ? structuredClone(v) : v;
|
|
101
|
+
} catch {
|
|
102
|
+
return v;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isPlainObject(v: unknown): boolean {
|
|
107
|
+
if (v === null || typeof v !== "object") return false;
|
|
108
|
+
const proto = Object.getPrototypeOf(v);
|
|
109
|
+
return proto === Object.prototype || proto === null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Minimal event with `target` pointing at the hooked request. */
|
|
113
|
+
function makeEvent(type: string, init?: { oldVersion?: number; newVersion?: number | null }, target?: object): Event {
|
|
114
|
+
let ev: Event;
|
|
115
|
+
if (init && typeof IDBVersionChangeEvent === "function") {
|
|
116
|
+
ev = new IDBVersionChangeEvent(type, { oldVersion: init.oldVersion, newVersion: init.newVersion ?? null });
|
|
117
|
+
} else {
|
|
118
|
+
ev = new Event(type, { bubbles: false, cancelable: type === "error" });
|
|
119
|
+
if (init) Object.assign(ev, init);
|
|
120
|
+
}
|
|
121
|
+
if (target) {
|
|
122
|
+
for (const p of ["target", "currentTarget", "srcElement"]) {
|
|
123
|
+
try {
|
|
124
|
+
Object.defineProperty(ev, p, { value: target, configurable: true });
|
|
125
|
+
} catch {
|
|
126
|
+
/* locked-down event implementation; listeners read request.result anyway */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return ev;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type Handler = ((ev: Event) => unknown) | null;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Stand-in for the IDBOpenDBRequest the app would have received. It has
|
|
137
|
+
* the same surface (result, error, readyState, transaction, source,
|
|
138
|
+
* the four on* handlers, add/removeEventListener) and an
|
|
139
|
+
* IDBOpenDBRequest prototype chain so `instanceof` holds.
|
|
140
|
+
*/
|
|
141
|
+
class HookedOpenRequest {
|
|
142
|
+
readyState: "pending" | "done" = "pending";
|
|
143
|
+
result: IDBDatabase | undefined = undefined;
|
|
144
|
+
error: DOMException | null = null;
|
|
145
|
+
transaction: IDBTransaction | null = null;
|
|
146
|
+
readonly source: null = null;
|
|
147
|
+
private listeners = new Map<string, Set<(ev: Event) => unknown>>();
|
|
148
|
+
private handlers: Record<string, Handler> = { success: null, error: null, blocked: null, upgradeneeded: null };
|
|
149
|
+
|
|
150
|
+
constructor(real: IDBOpenDBRequest, name: string, subs: Subscriber[]) {
|
|
151
|
+
real.addEventListener("upgradeneeded", (ev) => {
|
|
152
|
+
const e = ev as IDBVersionChangeEvent;
|
|
153
|
+
this.result = real.result;
|
|
154
|
+
this.transaction = real.transaction;
|
|
155
|
+
observedDbs.set(real.result, { name, subs });
|
|
156
|
+
this.dispatch(makeEvent("upgradeneeded", { oldVersion: e.oldVersion, newVersion: e.newVersion }, this));
|
|
157
|
+
});
|
|
158
|
+
real.addEventListener("blocked", (ev) => {
|
|
159
|
+
const e = ev as IDBVersionChangeEvent;
|
|
160
|
+
this.dispatch(makeEvent("blocked", { oldVersion: e.oldVersion, newVersion: e.newVersion }, this));
|
|
161
|
+
});
|
|
162
|
+
real.addEventListener("error", () => {
|
|
163
|
+
this.error = real.error;
|
|
164
|
+
this.readyState = "done";
|
|
165
|
+
this.transaction = null;
|
|
166
|
+
this.dispatch(makeEvent("error", undefined, this));
|
|
167
|
+
});
|
|
168
|
+
real.addEventListener("success", () => {
|
|
169
|
+
const db = real.result;
|
|
170
|
+
observedDbs.set(db, { name, subs });
|
|
171
|
+
this.transaction = null;
|
|
172
|
+
let settled = false;
|
|
173
|
+
const deliver = () => {
|
|
174
|
+
if (settled) return;
|
|
175
|
+
settled = true;
|
|
176
|
+
this.result = db;
|
|
177
|
+
this.readyState = "done";
|
|
178
|
+
this.dispatch(makeEvent("success", undefined, this));
|
|
179
|
+
};
|
|
180
|
+
// Hold success for the engines' first pull, bounded by a timeout
|
|
181
|
+
// so an offline device still opens.
|
|
182
|
+
const waits: Promise<void>[] = [];
|
|
183
|
+
let timeout = 0;
|
|
184
|
+
for (const s of subs) {
|
|
185
|
+
try {
|
|
186
|
+
const p = s.opts.onOpen(db, name);
|
|
187
|
+
if (p && typeof (p as Promise<void>).then === "function") {
|
|
188
|
+
waits.push((p as Promise<void>).catch(() => {}));
|
|
189
|
+
timeout = Math.max(timeout, s.opts.openTimeoutMs);
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
/* a failing onOpen must not keep the app from its database */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (waits.length === 0) {
|
|
196
|
+
deliver();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const timer = setTimeout(deliver, timeout);
|
|
200
|
+
Promise.all(waits).then(() => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
deliver();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
addEventListener(type: string, listener: ((ev: Event) => unknown) | { handleEvent(ev: Event): unknown } | null): void {
|
|
208
|
+
if (!listener) return;
|
|
209
|
+
const fn = typeof listener === "function" ? listener : (ev: Event) => listener.handleEvent(ev);
|
|
210
|
+
let set = this.listeners.get(type);
|
|
211
|
+
if (!set) this.listeners.set(type, (set = new Set()));
|
|
212
|
+
set.add(fn);
|
|
213
|
+
(listener as { __rtFn?: (ev: Event) => unknown }).__rtFn = fn;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
removeEventListener(type: string, listener: ((ev: Event) => unknown) | { handleEvent(ev: Event): unknown } | null): void {
|
|
217
|
+
if (!listener) return;
|
|
218
|
+
const fn = typeof listener === "function" ? listener : (listener as { __rtFn?: (ev: Event) => unknown }).__rtFn;
|
|
219
|
+
if (fn) this.listeners.get(type)?.delete(fn);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
dispatchEvent(ev: Event): boolean {
|
|
223
|
+
this.dispatch(ev);
|
|
224
|
+
return !ev.defaultPrevented;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private dispatch(ev: Event): void {
|
|
228
|
+
const h = this.handlers[ev.type];
|
|
229
|
+
if (h) {
|
|
230
|
+
try {
|
|
231
|
+
h.call(this, ev);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
reportListenerError(err);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const fn of this.listeners.get(ev.type) ?? []) {
|
|
237
|
+
try {
|
|
238
|
+
fn.call(this, ev);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
reportListenerError(err);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get onsuccess(): Handler { return this.handlers.success ?? null; }
|
|
246
|
+
set onsuccess(h: Handler) { this.handlers.success = h; }
|
|
247
|
+
get onerror(): Handler { return this.handlers.error ?? null; }
|
|
248
|
+
set onerror(h: Handler) { this.handlers.error = h; }
|
|
249
|
+
get onblocked(): Handler { return this.handlers.blocked ?? null; }
|
|
250
|
+
set onblocked(h: Handler) { this.handlers.blocked = h; }
|
|
251
|
+
get onupgradeneeded(): Handler { return this.handlers.upgradeneeded ?? null; }
|
|
252
|
+
set onupgradeneeded(h: Handler) { this.handlers.upgradeneeded = h; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function reportListenerError(err: unknown): void {
|
|
256
|
+
// Same as the browser: a throwing listener is reported, not swallowed
|
|
257
|
+
// silently, and does not stop the other listeners.
|
|
258
|
+
setTimeout(() => {
|
|
259
|
+
throw err;
|
|
260
|
+
}, 0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (typeof IDBOpenDBRequest === "function") {
|
|
264
|
+
Object.setPrototypeOf(HookedOpenRequest.prototype, IDBOpenDBRequest.prototype);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Object store of a cursor, whether it was opened on a store or an index. */
|
|
268
|
+
function cursorStore(cursor: IDBCursor): IDBObjectStore {
|
|
269
|
+
const src = cursor.source as IDBObjectStore | IDBIndex;
|
|
270
|
+
return typeof IDBIndex === "function" && src instanceof IDBIndex ? src.objectStore : (src as IDBObjectStore);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function subscribersFor(factory: IDBFactory, name: string): Subscriber[] {
|
|
274
|
+
if (name.startsWith("__runapps")) return [];
|
|
275
|
+
const out: Subscriber[] = [];
|
|
276
|
+
for (const s of subscribers) {
|
|
277
|
+
if ((s.factory === null || s.factory === factory) && s.opts.include(name)) out.push(s);
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function patchPrototypes(): void {
|
|
283
|
+
const F = IDBFactory.prototype;
|
|
284
|
+
const D = IDBDatabase.prototype;
|
|
285
|
+
const S = IDBObjectStore.prototype;
|
|
286
|
+
const C = IDBCursor.prototype;
|
|
287
|
+
const o = (originals = {
|
|
288
|
+
open: F.open,
|
|
289
|
+
deleteDatabase: F.deleteDatabase,
|
|
290
|
+
transaction: D.transaction,
|
|
291
|
+
put: S.put,
|
|
292
|
+
add: S.add,
|
|
293
|
+
get: S.get,
|
|
294
|
+
delete: S.delete,
|
|
295
|
+
clear: S.clear,
|
|
296
|
+
getAllKeys: S.getAllKeys,
|
|
297
|
+
cursorUpdate: C.update,
|
|
298
|
+
cursorDelete: C.delete,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const tracked = (store: IDBObjectStore): TxState | null => txState.get(store.transaction) ?? null;
|
|
302
|
+
|
|
303
|
+
F.open = function (this: IDBFactory, name: string, version?: number): IDBOpenDBRequest {
|
|
304
|
+
const real = version === undefined ? o.open.call(this, name) : o.open.call(this, name, version);
|
|
305
|
+
const subs = subscribersFor(this, name);
|
|
306
|
+
if (subs.length === 0) return real;
|
|
307
|
+
return new HookedOpenRequest(real, name, subs) as unknown as IDBOpenDBRequest;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
F.deleteDatabase = function (this: IDBFactory, name: string): IDBOpenDBRequest {
|
|
311
|
+
for (const s of subscribersFor(this, name)) {
|
|
312
|
+
try {
|
|
313
|
+
s.opts.onDeleteDatabase(name);
|
|
314
|
+
} catch {
|
|
315
|
+
/* never block the app's delete */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return o.deleteDatabase.call(this, name);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
D.transaction = function (
|
|
322
|
+
this: IDBDatabase,
|
|
323
|
+
storeNames: string | string[],
|
|
324
|
+
mode?: IDBTransactionMode,
|
|
325
|
+
options?: IDBTransactionOptions,
|
|
326
|
+
): IDBTransaction {
|
|
327
|
+
const tx =
|
|
328
|
+
options !== undefined
|
|
329
|
+
? o.transaction.call(this, storeNames, mode, options)
|
|
330
|
+
: mode !== undefined
|
|
331
|
+
? o.transaction.call(this, storeNames, mode)
|
|
332
|
+
: o.transaction.call(this, storeNames);
|
|
333
|
+
const info = observedDbs.get(this);
|
|
334
|
+
if (info && tx.mode === "readwrite") {
|
|
335
|
+
const st: TxState = { db: info.name, subs: info.subs, ops: [] };
|
|
336
|
+
txState.set(tx, st);
|
|
337
|
+
tx.addEventListener("complete", () => {
|
|
338
|
+
if (st.ops.length === 0) return;
|
|
339
|
+
const ops = st.ops;
|
|
340
|
+
st.ops = [];
|
|
341
|
+
for (const s of st.subs) {
|
|
342
|
+
const mine = ops.filter((op) => !s.opts.excludeStore(st.db, op.store));
|
|
343
|
+
if (mine.length === 0) continue;
|
|
344
|
+
try {
|
|
345
|
+
s.opts.onCommit(st.db, mine);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
reportListenerError(err);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return tx;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
S.put = function (this: IDBObjectStore, value: unknown, key?: IDBValidKey): IDBRequest<IDBValidKey> {
|
|
356
|
+
const st = tracked(this);
|
|
357
|
+
let prior: { value: unknown } | null = null;
|
|
358
|
+
if (st && isPlainObject(value)) {
|
|
359
|
+
// The record's previous value, read before the write in the same
|
|
360
|
+
// transaction, tells the engine which fields this write changed.
|
|
361
|
+
const k = key !== undefined ? key : extractKey(value, this.keyPath as string | string[] | null);
|
|
362
|
+
if (k !== undefined) {
|
|
363
|
+
try {
|
|
364
|
+
const g = o.get.call(this, k);
|
|
365
|
+
const slot = (prior = { value: undefined });
|
|
366
|
+
g.addEventListener("success", () => {
|
|
367
|
+
slot.value = g.result;
|
|
368
|
+
});
|
|
369
|
+
} catch {
|
|
370
|
+
prior = null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const r = key === undefined ? o.put.call(this, value) : o.put.call(this, value, key);
|
|
375
|
+
if (st) {
|
|
376
|
+
const store = this.name;
|
|
377
|
+
const snapshot = clone(value);
|
|
378
|
+
const priorSlot = prior;
|
|
379
|
+
r.addEventListener("success", () => {
|
|
380
|
+
st.ops.push({ store, kind: "put", key: r.result, value: snapshot, prior: priorSlot?.value });
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return r;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
S.add = function (this: IDBObjectStore, value: unknown, key?: IDBValidKey): IDBRequest<IDBValidKey> {
|
|
387
|
+
const r = key === undefined ? o.add.call(this, value) : o.add.call(this, value, key);
|
|
388
|
+
const st = tracked(this);
|
|
389
|
+
if (st) {
|
|
390
|
+
const store = this.name;
|
|
391
|
+
const snapshot = clone(value);
|
|
392
|
+
r.addEventListener("success", () => {
|
|
393
|
+
st.ops.push({ store, kind: "put", key: r.result, value: snapshot });
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return r;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
S.delete = function (this: IDBObjectStore, query: IDBValidKey | IDBKeyRange): IDBRequest<undefined> {
|
|
400
|
+
const st = tracked(this);
|
|
401
|
+
if (st) {
|
|
402
|
+
const store = this.name;
|
|
403
|
+
if (typeof IDBKeyRange === "function" && query instanceof IDBKeyRange) {
|
|
404
|
+
// Which keys a range covers is only knowable before the delete;
|
|
405
|
+
// getAllKeys runs first in the same transaction.
|
|
406
|
+
const kr = o.getAllKeys.call(this, query);
|
|
407
|
+
kr.addEventListener("success", () => {
|
|
408
|
+
for (const k of kr.result) st.ops.push({ store, kind: "del", key: k });
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
st.ops.push({ store, kind: "del", key: query as IDBValidKey });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return o.delete.call(this, query);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
S.clear = function (this: IDBObjectStore): IDBRequest<undefined> {
|
|
418
|
+
const st = tracked(this);
|
|
419
|
+
const r = o.clear.call(this);
|
|
420
|
+
if (st) {
|
|
421
|
+
const store = this.name;
|
|
422
|
+
r.addEventListener("success", () => {
|
|
423
|
+
st.ops.push({ store, kind: "clear" });
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return r;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
C.update = function (this: IDBCursor, value: unknown): IDBRequest<IDBValidKey> {
|
|
430
|
+
const store = cursorStore(this);
|
|
431
|
+
const st = tracked(store);
|
|
432
|
+
const prior = st ? clone((this as IDBCursorWithValue).value) : undefined;
|
|
433
|
+
const r = o.cursorUpdate.call(this, value);
|
|
434
|
+
if (st) {
|
|
435
|
+
const name = store.name;
|
|
436
|
+
const snapshot = clone(value);
|
|
437
|
+
const key = this.primaryKey;
|
|
438
|
+
r.addEventListener("success", () => {
|
|
439
|
+
st.ops.push({ store: name, kind: "put", key, value: snapshot, prior });
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return r;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
C.delete = function (this: IDBCursor): IDBRequest<undefined> {
|
|
446
|
+
const store = cursorStore(this);
|
|
447
|
+
const st = tracked(store);
|
|
448
|
+
if (st) st.ops.push({ store: store.name, kind: "del", key: this.primaryKey });
|
|
449
|
+
return o.cursorDelete.call(this);
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function restorePrototypes(): void {
|
|
454
|
+
if (!originals) return;
|
|
455
|
+
const o = originals;
|
|
456
|
+
IDBFactory.prototype.open = o.open;
|
|
457
|
+
IDBFactory.prototype.deleteDatabase = o.deleteDatabase;
|
|
458
|
+
IDBDatabase.prototype.transaction = o.transaction;
|
|
459
|
+
IDBObjectStore.prototype.put = o.put;
|
|
460
|
+
IDBObjectStore.prototype.add = o.add;
|
|
461
|
+
IDBObjectStore.prototype.delete = o.delete;
|
|
462
|
+
IDBObjectStore.prototype.clear = o.clear;
|
|
463
|
+
IDBCursor.prototype.update = o.cursorUpdate;
|
|
464
|
+
IDBCursor.prototype.delete = o.cursorDelete;
|
|
465
|
+
originals = null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Install the hook for one subscriber. The prototypes are patched on the
|
|
470
|
+
* first install and restored when the last subscriber uninstalls.
|
|
471
|
+
*/
|
|
472
|
+
export function installIndexedDBHook(opts: HookOptions): InstalledHook | null {
|
|
473
|
+
if (
|
|
474
|
+
typeof indexedDB === "undefined" ||
|
|
475
|
+
typeof IDBFactory !== "function" ||
|
|
476
|
+
typeof IDBDatabase !== "function" ||
|
|
477
|
+
typeof IDBObjectStore !== "function" ||
|
|
478
|
+
typeof IDBCursor !== "function"
|
|
479
|
+
) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
if (!originals) patchPrototypes();
|
|
483
|
+
const o = originals!;
|
|
484
|
+
const sub: Subscriber = { opts, factory: opts.factory ?? indexedDB };
|
|
485
|
+
subscribers.add(sub);
|
|
486
|
+
const factory = sub.factory as IDBFactory;
|
|
487
|
+
return {
|
|
488
|
+
originalOpen: (name, version) =>
|
|
489
|
+
version === undefined ? o.open.call(factory, name) : o.open.call(factory, name, version),
|
|
490
|
+
uninstall() {
|
|
491
|
+
subscribers.delete(sub);
|
|
492
|
+
if (subscribers.size === 0) restorePrototypes();
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SyncEngine, type SyncOptions, type SyncState, type DbChange, type SyncRemote, type SyncDeps } from "./engine.js";
|
|
2
|
+
export { FilesRemote } from "./remote.js";
|
|
3
|
+
export { installIndexedDBHook, type CommittedOp, type HookOptions, type InstalledHook } from "./hook.js";
|
|
4
|
+
export { encodeValue, decodeValue, encodeKey, decodeKey, keyId } from "./codec.js";
|
|
5
|
+
export { SyncLog, SYNC_DB_NAME } from "./log.js";
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// idb-sync/log.ts — the SDK's own IndexedDB database.
|
|
2
|
+
//
|
|
3
|
+
// Holds what the sync engine needs to survive a reload:
|
|
4
|
+
// ops — local changes not yet pushed, in commit order. Values are kept
|
|
5
|
+
// as the app wrote them (structured clone, Blobs included);
|
|
6
|
+
// they are encoded only when pushed, so logging works offline.
|
|
7
|
+
// meta — small key/value state: the owner (user id the local data
|
|
8
|
+
// belongs to), shard ETags seen last, store clear marks.
|
|
9
|
+
//
|
|
10
|
+
// Opened through the untouched `indexedDB.open`, never the hooked one,
|
|
11
|
+
// and its name starts with `__runapps` so the hook leaves it alone.
|
|
12
|
+
|
|
13
|
+
export const SYNC_DB_NAME = "__runapps_sync__";
|
|
14
|
+
const SYNC_DB_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
export type OpKind = "put" | "del" | "clear";
|
|
17
|
+
|
|
18
|
+
export interface LoggedOp {
|
|
19
|
+
id?: number;
|
|
20
|
+
db: string;
|
|
21
|
+
store: string;
|
|
22
|
+
kind: OpKind;
|
|
23
|
+
/** Canonical key id (codec.keyId); "" for clear. */
|
|
24
|
+
kid: string;
|
|
25
|
+
/** Encoded key (codec.encodeKey); undefined for clear. */
|
|
26
|
+
key?: unknown;
|
|
27
|
+
/** The value as written, for put. */
|
|
28
|
+
value?: unknown;
|
|
29
|
+
/** For a put of a plain object whose previous value was seen: the
|
|
30
|
+
* fields this write changed (added, modified or removed). Undefined
|
|
31
|
+
* means "treat every field as changed". */
|
|
32
|
+
changed?: string[];
|
|
33
|
+
/** Hybrid logical clock timestamp (ms, monotonic per device). */
|
|
34
|
+
ts: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function req<T>(r: IDBRequest<T>): Promise<T> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
r.onsuccess = () => resolve(r.result);
|
|
40
|
+
r.onerror = () => reject(r.error ?? new Error("IndexedDB request failed"));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function done(tx: IDBTransaction): Promise<void> {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
tx.oncomplete = () => resolve();
|
|
47
|
+
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
|
48
|
+
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class SyncLog {
|
|
53
|
+
private dbPromise: Promise<IDBDatabase> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(private readonly open: (name: string, version?: number) => IDBOpenDBRequest) {}
|
|
56
|
+
|
|
57
|
+
private db(): Promise<IDBDatabase> {
|
|
58
|
+
if (!this.dbPromise) {
|
|
59
|
+
this.dbPromise = new Promise((resolve, reject) => {
|
|
60
|
+
const r = this.open(SYNC_DB_NAME, SYNC_DB_VERSION);
|
|
61
|
+
r.onupgradeneeded = () => {
|
|
62
|
+
const db = r.result;
|
|
63
|
+
if (!db.objectStoreNames.contains("ops")) {
|
|
64
|
+
const ops = db.createObjectStore("ops", { keyPath: "id", autoIncrement: true });
|
|
65
|
+
ops.createIndex("store", ["db", "store"], { unique: false });
|
|
66
|
+
}
|
|
67
|
+
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
|
|
68
|
+
};
|
|
69
|
+
r.onsuccess = () => {
|
|
70
|
+
const db = r.result;
|
|
71
|
+
// Another tab upgrading (a future SDK) must not be blocked by us.
|
|
72
|
+
db.onversionchange = () => {
|
|
73
|
+
db.close();
|
|
74
|
+
this.dbPromise = null;
|
|
75
|
+
};
|
|
76
|
+
resolve(db);
|
|
77
|
+
};
|
|
78
|
+
r.onerror = () => reject(r.error ?? new Error("cannot open sync log"));
|
|
79
|
+
r.onblocked = () => reject(new Error("sync log open blocked"));
|
|
80
|
+
});
|
|
81
|
+
this.dbPromise.catch(() => {
|
|
82
|
+
this.dbPromise = null;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return this.dbPromise;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async append(ops: LoggedOp[]): Promise<void> {
|
|
89
|
+
if (ops.length === 0) return;
|
|
90
|
+
const db = await this.db();
|
|
91
|
+
const tx = db.transaction("ops", "readwrite");
|
|
92
|
+
const st = tx.objectStore("ops");
|
|
93
|
+
for (const op of ops) st.add(op);
|
|
94
|
+
await done(tx);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async pending(): Promise<LoggedOp[]> {
|
|
98
|
+
const db = await this.db();
|
|
99
|
+
const tx = db.transaction("ops", "readonly");
|
|
100
|
+
const all = (await req(tx.objectStore("ops").getAll())) as LoggedOp[];
|
|
101
|
+
return all;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async pendingCount(): Promise<number> {
|
|
105
|
+
const db = await this.db();
|
|
106
|
+
const tx = db.transaction("ops", "readonly");
|
|
107
|
+
return req(tx.objectStore("ops").count());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async remove(ids: number[]): Promise<void> {
|
|
111
|
+
if (ids.length === 0) return;
|
|
112
|
+
const db = await this.db();
|
|
113
|
+
const tx = db.transaction("ops", "readwrite");
|
|
114
|
+
const st = tx.objectStore("ops");
|
|
115
|
+
for (const id of ids) st.delete(id);
|
|
116
|
+
await done(tx);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async clearOps(): Promise<void> {
|
|
120
|
+
const db = await this.db();
|
|
121
|
+
const tx = db.transaction("ops", "readwrite");
|
|
122
|
+
tx.objectStore("ops").clear();
|
|
123
|
+
await done(tx);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async getMeta<T = unknown>(key: string): Promise<T | undefined> {
|
|
127
|
+
const db = await this.db();
|
|
128
|
+
const tx = db.transaction("meta", "readonly");
|
|
129
|
+
return (await req(tx.objectStore("meta").get(key))) as T | undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async setMeta(entries: Record<string, unknown>): Promise<void> {
|
|
133
|
+
const db = await this.db();
|
|
134
|
+
const tx = db.transaction("meta", "readwrite");
|
|
135
|
+
const st = tx.objectStore("meta");
|
|
136
|
+
for (const k of Object.keys(entries)) {
|
|
137
|
+
if (entries[k] === undefined) st.delete(k);
|
|
138
|
+
else st.put(entries[k], k);
|
|
139
|
+
}
|
|
140
|
+
await done(tx);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every meta entry whose key starts with `prefix`, as a map. */
|
|
144
|
+
async metaWithPrefix(prefix: string): Promise<Map<string, unknown>> {
|
|
145
|
+
const db = await this.db();
|
|
146
|
+
const tx = db.transaction("meta", "readonly");
|
|
147
|
+
const st = tx.objectStore("meta");
|
|
148
|
+
const range = IDBKeyRange.bound(prefix, prefix + "");
|
|
149
|
+
const keys = (await req(st.getAllKeys(range))) as string[];
|
|
150
|
+
const vals = await req(st.getAll(range));
|
|
151
|
+
const out = new Map<string, unknown>();
|
|
152
|
+
keys.forEach((k, i) => out.set(k, vals[i]));
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async clearAll(): Promise<void> {
|
|
157
|
+
const db = await this.db();
|
|
158
|
+
const tx = db.transaction(["ops", "meta"], "readwrite");
|
|
159
|
+
tx.objectStore("ops").clear();
|
|
160
|
+
tx.objectStore("meta").clear();
|
|
161
|
+
await done(tx);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
close(): void {
|
|
165
|
+
this.dbPromise?.then((db) => db.close()).catch(() => {});
|
|
166
|
+
this.dbPromise = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { req as idbRequest, done as idbDone };
|