@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.
Files changed (73) hide show
  1. package/README.md +30 -1
  2. package/dist/activity-tracker.d.ts +8 -0
  3. package/dist/activity-tracker.d.ts.map +1 -1
  4. package/dist/activity-tracker.js +3 -0
  5. package/dist/activity-tracker.js.map +1 -1
  6. package/dist/browser-auth.d.ts +7 -0
  7. package/dist/browser-auth.d.ts.map +1 -1
  8. package/dist/browser-auth.js +105 -15
  9. package/dist/browser-auth.js.map +1 -1
  10. package/dist/client.d.ts +24 -0
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +22 -0
  13. package/dist/client.js.map +1 -1
  14. package/dist/events.d.ts +20 -0
  15. package/dist/events.d.ts.map +1 -1
  16. package/dist/events.js.map +1 -1
  17. package/dist/files.d.ts +3 -0
  18. package/dist/files.d.ts.map +1 -1
  19. package/dist/files.js +4 -1
  20. package/dist/files.js.map +1 -1
  21. package/dist/idb-sync/codec.d.ts +39 -0
  22. package/dist/idb-sync/codec.d.ts.map +1 -0
  23. package/dist/idb-sync/codec.js +364 -0
  24. package/dist/idb-sync/codec.js.map +1 -0
  25. package/dist/idb-sync/engine.d.ts +143 -0
  26. package/dist/idb-sync/engine.d.ts.map +1 -0
  27. package/dist/idb-sync/engine.js +727 -0
  28. package/dist/idb-sync/engine.js.map +1 -0
  29. package/dist/idb-sync/hook.d.ts +42 -0
  30. package/dist/idb-sync/hook.d.ts.map +1 -0
  31. package/dist/idb-sync/hook.js +426 -0
  32. package/dist/idb-sync/hook.js.map +1 -0
  33. package/dist/idb-sync/index.d.ts +6 -0
  34. package/dist/idb-sync/index.d.ts.map +1 -0
  35. package/dist/idb-sync/index.js +6 -0
  36. package/dist/idb-sync/index.js.map +1 -0
  37. package/dist/idb-sync/log.d.ts +41 -0
  38. package/dist/idb-sync/log.d.ts.map +1 -0
  39. package/dist/idb-sync/log.js +143 -0
  40. package/dist/idb-sync/log.js.map +1 -0
  41. package/dist/idb-sync/remote.d.ts +38 -0
  42. package/dist/idb-sync/remote.d.ts.map +1 -0
  43. package/dist/idb-sync/remote.js +61 -0
  44. package/dist/idb-sync/remote.js.map +1 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/models.d.ts +1 -1
  50. package/dist/models.d.ts.map +1 -1
  51. package/dist/models.js +4 -2
  52. package/dist/models.js.map +1 -1
  53. package/dist/sdk.umd.js +2 -2
  54. package/dist/sdk.umd.js.map +1 -1
  55. package/dist/transport.d.ts +11 -0
  56. package/dist/transport.d.ts.map +1 -1
  57. package/dist/transport.js +33 -6
  58. package/dist/transport.js.map +1 -1
  59. package/package.json +2 -1
  60. package/src/activity-tracker.ts +5 -0
  61. package/src/browser-auth.ts +103 -16
  62. package/src/client.ts +36 -0
  63. package/src/events.ts +22 -0
  64. package/src/files.ts +7 -5
  65. package/src/idb-sync/codec.ts +354 -0
  66. package/src/idb-sync/engine.ts +818 -0
  67. package/src/idb-sync/hook.ts +495 -0
  68. package/src/idb-sync/index.ts +5 -0
  69. package/src/idb-sync/log.ts +170 -0
  70. package/src/idb-sync/remote.ts +59 -0
  71. package/src/index.ts +13 -0
  72. package/src/models.ts +4 -2
  73. package/src/transport.ts +38 -8
@@ -0,0 +1,818 @@
1
+ // idb-sync/engine.ts — keeps an app's IndexedDB and the user's cloud copy
2
+ // the same.
3
+ //
4
+ // Storage model (one object per shard, under the user's file space):
5
+ //
6
+ // db/<database>/<store>/<shard>.json
7
+ // { v: 1, cleared: <ts>, r: { <keyId>: { k, v, t, d? } } }
8
+ //
9
+ // A store's records are spread over a fixed number of shards by key
10
+ // hash, so a change rewrites one small object, and a device that has
11
+ // nothing new to see pays one conditional read per shard (304). Every
12
+ // record carries the timestamp of its last write; the newer write wins,
13
+ // a deletion is a record with `d: 1`, and a store `clear` raises the
14
+ // shard's `cleared` mark, which drops everything older. Shards are
15
+ // written with If-Match, so two devices never overwrite each other: the
16
+ // loser re-reads, merges and writes again.
17
+ //
18
+ // Locally the engine owns a change log (log.ts) fed by the hook
19
+ // (hook.ts); pushing turns pending ops into shard writes, pulling turns
20
+ // changed shards into writes on the app's database through the engine's
21
+ // own connection, which the hook does not observe.
22
+
23
+ import { changedFields, decodeKey, decodeValue, encodeKey, encodeValue, hash32, keyId, type BlobRef } from "./codec.js";
24
+ import { type CommittedOp, type InstalledHook, installIndexedDBHook } from "./hook.js";
25
+ import { idbDone, idbRequest, SyncLog, type LoggedOp } from "./log.js";
26
+ import type { SDKEvents } from "../events.js";
27
+
28
+ export interface SyncOptions {
29
+ /** Observe only these databases (by name). Default: every database the
30
+ * app opens, except the SDK's own. */
31
+ databases?: string[];
32
+ /** Leave out a whole database (`"cache"`) or one store (`"app/videos"`). */
33
+ exclude?: string[];
34
+ /** Shards per store. Default 8. Changing it later splits data: pick once. */
35
+ shards?: number;
36
+ /** How long to gather local writes before pushing. Default 2500 ms. */
37
+ pushDelayMs?: number;
38
+ /** Background pull interval while the page is visible. Default 60 s. */
39
+ pullIntervalMs?: number;
40
+ /** How long a database open waits for the first pull. Default 5 s. */
41
+ openTimeoutMs?: number;
42
+ /** Path prefix in the user's file space. Default "db". */
43
+ prefix?: string;
44
+ }
45
+
46
+ export interface SyncState {
47
+ /** off: no signed-in user; idle: everything pushed; syncing: a push or
48
+ * pull is running; offline: changes waiting for the network; error:
49
+ * the last attempt failed, will retry. */
50
+ state: "off" | "idle" | "syncing" | "offline" | "error";
51
+ /** Local changes not yet on the server. */
52
+ pending: number;
53
+ lastSyncedAt?: number;
54
+ error?: string;
55
+ }
56
+
57
+ export interface DbChange {
58
+ db: string;
59
+ store: string;
60
+ /** Primary keys that changed; empty when the whole store was cleared. */
61
+ keys: IDBValidKey[];
62
+ cleared: boolean;
63
+ source: "remote";
64
+ }
65
+
66
+ /** What the engine needs from the rest of the SDK. */
67
+ export interface SyncDeps {
68
+ remote: SyncRemote;
69
+ events: SDKEvents;
70
+ /** Signed-in user id, or null. */
71
+ userId: () => string | null;
72
+ /** Subscribe to sign-in changes. */
73
+ onAuthChange: (cb: () => void) => () => void;
74
+ /** The IndexedDB factory to observe. Default: the global one. */
75
+ factory?: IDBFactory;
76
+ }
77
+
78
+ /** The storage side. Implemented over FilesService; tests use memory. */
79
+ export interface SyncRemote {
80
+ list(prefix: string): Promise<{ path: string; etag: string }[]>;
81
+ getJSON(path: string, ifNoneMatch?: string): Promise<{ status: "ok"; value: unknown; etag: string; serverTime?: number } | { status: "missing" } | { status: "unchanged" }>;
82
+ putJSON(path: string, value: unknown, cond: { ifMatch?: string; ifNoneMatch?: boolean }): Promise<{ status: "ok"; etag: string; serverTime?: number } | { status: "conflict" }>;
83
+ putBlob(path: string, blob: Blob): Promise<void>;
84
+ getBlob(path: string): Promise<Blob>;
85
+ }
86
+
87
+ interface ShardRecord {
88
+ k: unknown;
89
+ v: unknown;
90
+ t: number;
91
+ d?: 1;
92
+ /** Per-field write times, kept for plain-object values so two devices
93
+ * editing different fields of one record both keep their edits. */
94
+ f?: Record<string, number>;
95
+ }
96
+
97
+ function isPlainRecord(v: unknown): v is Record<string, unknown> {
98
+ return v !== null && typeof v === "object" && !Array.isArray(v) && !("$rt" in (v as object));
99
+ }
100
+
101
+ function sameJSON(a: unknown, b: unknown): boolean {
102
+ return JSON.stringify(a) === JSON.stringify(b);
103
+ }
104
+
105
+ /**
106
+ * Fold an incoming write into the record already in the shard.
107
+ *
108
+ * Both plain objects: field by field. `changed` names the fields the
109
+ * incoming write touched (the hook compared the record with its previous
110
+ * value in the same transaction); every other field keeps the stored
111
+ * value, which may have been written by another device meanwhile. A
112
+ * changed field takes the incoming value unless the stored field was
113
+ * written later by someone else; a field the incoming write dropped is
114
+ * removed on the same condition. Without `changed` (an `add`, or a prior
115
+ * value the hook could not read), a field is taken as changed when its
116
+ * value differs. Anything else: the later write wins whole.
117
+ */
118
+ function mergeRecord(cur: ShardRecord | undefined, inc: ShardRecord, changed?: string[]): ShardRecord {
119
+ if (!cur) return inc;
120
+ if (inc.d || cur.d || !isPlainRecord(cur.v) || !isPlainRecord(inc.v)) {
121
+ return inc.t >= cur.t ? inc : cur;
122
+ }
123
+ const curF = cur.f ?? {};
124
+ const touched = changed ? new Set(changed) : null;
125
+ const out: Record<string, unknown> = {};
126
+ const f: Record<string, number> = {};
127
+ const fields = new Set([...Object.keys(cur.v), ...Object.keys(inc.v)]);
128
+ for (const name of fields) {
129
+ const inCur = name in cur.v;
130
+ const inInc = name in inc.v;
131
+ const curT = curF[name] ?? cur.t;
132
+ const untouched = touched ? !touched.has(name) : inCur && inInc && sameJSON(cur.v[name], inc.v[name]);
133
+ if (untouched) {
134
+ if (inCur) {
135
+ out[name] = cur.v[name];
136
+ if (curF[name] !== undefined) f[name] = curF[name];
137
+ }
138
+ continue;
139
+ }
140
+ const incomingWins = inc.t >= curT;
141
+ if (incomingWins) {
142
+ if (inInc) {
143
+ out[name] = inc.v[name];
144
+ f[name] = inc.t;
145
+ }
146
+ // dropped by the incoming write: leave it out
147
+ } else if (inCur) {
148
+ out[name] = cur.v[name];
149
+ f[name] = curT;
150
+ }
151
+ }
152
+ return { k: inc.k, v: out, t: Math.max(cur.t, inc.t), f };
153
+ }
154
+
155
+ interface ShardOp {
156
+ kid: string;
157
+ rec: ShardRecord;
158
+ changed?: string[];
159
+ }
160
+
161
+ interface Shard {
162
+ v: 1;
163
+ cleared: number;
164
+ r: Record<string, ShardRecord>;
165
+ }
166
+
167
+ interface StoreSchema {
168
+ keyPath: string | string[] | null;
169
+ autoIncrement: boolean;
170
+ }
171
+
172
+ const TOMBSTONE_TTL_MS = 30 * 24 * 3600 * 1000;
173
+ const MAX_PUSH_DELAY_MS = 10_000;
174
+ const BACKOFF_MIN_MS = 5_000;
175
+ const BACKOFF_MAX_MS = 5 * 60_000;
176
+
177
+ /** Hybrid logical clock: wall time, never going backwards, with the
178
+ * server's clock offset applied once learned. */
179
+ class Clock {
180
+ private last = 0;
181
+ private offset = 0;
182
+ now(): number {
183
+ const t = Date.now() + this.offset;
184
+ this.last = t > this.last ? t : this.last + 1;
185
+ return this.last;
186
+ }
187
+ observe(serverTime: number, localBefore: number, localAfter: number): void {
188
+ const mid = (localBefore + localAfter) / 2;
189
+ const off = serverTime - mid;
190
+ if (Math.abs(off - this.offset) > 2000) this.offset = off;
191
+ }
192
+ }
193
+
194
+ function pathSeg(s: string): string {
195
+ return encodeURIComponent(s).replace(/%2F/gi, "%2F");
196
+ }
197
+
198
+ export class SyncEngine {
199
+ readonly options: Required<SyncOptions>;
200
+ private readonly log: SyncLog;
201
+ private readonly hook: InstalledHook | null;
202
+ private readonly clock = new Clock();
203
+ private readonly schemas = new Map<string, Map<string, StoreSchema>>();
204
+ private readonly etags = new Map<string, string>();
205
+ private readonly conns = new Map<string, Promise<IDBDatabase>>();
206
+ private state: SyncState = { state: "off", pending: 0 };
207
+ private paused = false;
208
+ private disposed = false;
209
+ private leader = true;
210
+ private pushTimer: ReturnType<typeof setTimeout> | null = null;
211
+ private pushDeadline = 0;
212
+ private pullTimer: ReturnType<typeof setInterval> | null = null;
213
+ private backoffMs = 0;
214
+ private backoffUntil = 0;
215
+ private chain: Promise<void> = Promise.resolve();
216
+ private readonly cleanup: (() => void)[] = [];
217
+ private channel: BroadcastChannel | null = null;
218
+ private metaLoaded: Promise<void> | null = null;
219
+
220
+ constructor(
221
+ private readonly deps: SyncDeps,
222
+ options: SyncOptions = {},
223
+ ) {
224
+ this.options = {
225
+ databases: options.databases ?? [],
226
+ exclude: options.exclude ?? [],
227
+ shards: Math.max(1, Math.min(64, options.shards ?? 8)),
228
+ pushDelayMs: options.pushDelayMs ?? 2500,
229
+ pullIntervalMs: options.pullIntervalMs ?? 60_000,
230
+ openTimeoutMs: options.openTimeoutMs ?? 5000,
231
+ prefix: (options.prefix ?? "db").replace(/^\/+|\/+$/g, ""),
232
+ };
233
+ const excludedDbs = new Set(this.options.exclude.filter((e) => !e.includes("/")));
234
+ const excludedStores = new Set(this.options.exclude.filter((e) => e.includes("/")));
235
+ const only = new Set(this.options.databases);
236
+
237
+ this.hook = installIndexedDBHook({
238
+ include: (name) => (only.size === 0 || only.has(name)) && !excludedDbs.has(name),
239
+ excludeStore: (db, store) => excludedStores.has(db + "/" + store),
240
+ onCommit: (db, ops) => this.onCommit(db, ops),
241
+ onOpen: (db, name) => this.onOpen(db, name),
242
+ onDeleteDatabase: (name) => this.onDeleteDatabase(name),
243
+ openTimeoutMs: this.options.openTimeoutMs,
244
+ factory: deps.factory,
245
+ });
246
+ this.log = new SyncLog((name, version) =>
247
+ this.hook ? this.hook.originalOpen(name, version) : indexedDB.open(name, version),
248
+ );
249
+
250
+ if (typeof window !== "undefined") this.attachBrowserTriggers();
251
+ this.cleanup.push(deps.onAuthChange(() => this.schedule("auth")));
252
+ void this.refreshPending();
253
+ void this.schedule("start");
254
+ }
255
+
256
+ // ── Public surface ───────────────────────────────────────────────
257
+
258
+ status(): SyncState {
259
+ return { ...this.state };
260
+ }
261
+
262
+ /** Push pending changes and pull remote ones now. */
263
+ now(): Promise<void> {
264
+ return this.enqueue(() => this.sync());
265
+ }
266
+
267
+ pause(): void {
268
+ this.paused = true;
269
+ }
270
+
271
+ resume(): void {
272
+ this.paused = false;
273
+ void this.schedule("resume");
274
+ }
275
+
276
+ dispose(): void {
277
+ this.disposed = true;
278
+ for (const c of this.cleanup) c();
279
+ if (this.pushTimer) clearTimeout(this.pushTimer);
280
+ if (this.pullTimer) clearInterval(this.pullTimer);
281
+ this.channel?.close();
282
+ for (const p of this.conns.values()) p.then((db) => db.close()).catch(() => {});
283
+ this.conns.clear();
284
+ this.log.close();
285
+ this.hook?.uninstall();
286
+ }
287
+
288
+ // ── Hook callbacks ───────────────────────────────────────────────
289
+
290
+ private onCommit(db: string, ops: CommittedOp[]): void {
291
+ const logged: LoggedOp[] = [];
292
+ for (const op of ops) {
293
+ const ts = this.clock.now();
294
+ if (op.kind === "clear") logged.push({ db, store: op.store, kind: "clear", kid: "", ts });
295
+ else if (op.kind === "del") logged.push({ db, store: op.store, kind: "del", kid: keyId(op.key), key: encodeKey(op.key), ts });
296
+ else {
297
+ logged.push({
298
+ db,
299
+ store: op.store,
300
+ kind: "put",
301
+ kid: keyId(op.key),
302
+ key: encodeKey(op.key),
303
+ value: op.value,
304
+ changed: op.prior === undefined ? undefined : changedFields(op.prior, op.value),
305
+ ts,
306
+ });
307
+ }
308
+ }
309
+ this.enqueueLog(logged);
310
+ }
311
+
312
+ private logChain: Promise<void> = Promise.resolve();
313
+ private enqueueLog(ops: LoggedOp[]): void {
314
+ this.logChain = this.logChain
315
+ .then(() => this.log.append(ops))
316
+ .then(() => this.refreshPending())
317
+ .catch((err) => this.fail(err));
318
+ this.schedulePush();
319
+ }
320
+
321
+ private async onOpen(db: IDBDatabase, name: string): Promise<void> {
322
+ const schema = new Map<string, StoreSchema>();
323
+ const names = Array.from(db.objectStoreNames);
324
+ if (names.length > 0) {
325
+ const tx = db.transaction(names, "readonly");
326
+ for (const n of names) {
327
+ const st = tx.objectStore(n);
328
+ schema.set(n, { keyPath: st.keyPath as string | string[] | null, autoIncrement: st.autoIncrement });
329
+ if (st.autoIncrement && typeof console !== "undefined") {
330
+ console.warn(
331
+ `[runapps sync] "${name}/${n}" uses autoIncrement keys: records created on two devices while offline can get the same key, and only one survives. Prefer client-generated ids (e.g. crypto.randomUUID()).`,
332
+ );
333
+ }
334
+ }
335
+ }
336
+ this.schemas.set(name, schema);
337
+ void this.log.setMeta({ ["schema:" + name]: Object.fromEntries(schema) }).catch(() => {});
338
+ if (!this.deps.userId() || this.paused || !this.leader) return;
339
+ await this.enqueue(() => this.pullDatabase(name)).catch(() => {});
340
+ }
341
+
342
+ private onDeleteDatabase(name: string): void {
343
+ const schema = this.schemas.get(name);
344
+ const stores = schema ? Array.from(schema.keys()) : [];
345
+ this.schemas.delete(name);
346
+ this.conns.get(name)?.then((db) => db.close()).catch(() => {});
347
+ this.conns.delete(name);
348
+ const ts = this.clock.now();
349
+ this.enqueueLog(stores.map((store) => ({ db: name, store, kind: "clear" as const, kid: "", ts })));
350
+ }
351
+
352
+ // ── Scheduling ───────────────────────────────────────────────────
353
+
354
+ private attachBrowserTriggers(): void {
355
+ const on = (target: EventTarget, type: string, fn: () => void) => {
356
+ target.addEventListener(type, fn);
357
+ this.cleanup.push(() => target.removeEventListener(type, fn));
358
+ };
359
+ on(window, "online", () => {
360
+ this.backoffUntil = 0;
361
+ void this.schedule("online");
362
+ });
363
+ on(window, "focus", () => void this.schedule("focus"));
364
+ if (typeof document !== "undefined") {
365
+ on(document, "visibilitychange", () => {
366
+ if (document.visibilityState === "visible") void this.schedule("visible");
367
+ this.updatePullTimer();
368
+ });
369
+ }
370
+ this.updatePullTimer();
371
+ if (typeof BroadcastChannel === "function") {
372
+ this.channel = new BroadcastChannel("__runapps_db_change__");
373
+ this.channel.onmessage = (ev) => {
374
+ const d = ev.data as DbChange | undefined;
375
+ if (d && d.source === "remote") this.deps.events.emit("db:change", d);
376
+ };
377
+ }
378
+ // One tab syncs at a time; the others feed the shared log and wait
379
+ // for the lock, taking over when the leader closes.
380
+ const locks = (navigator as Navigator & { locks?: LockManager }).locks;
381
+ if (locks && typeof locks.request === "function") {
382
+ this.leader = false;
383
+ void locks
384
+ .request("__runapps_sync__", () => {
385
+ this.leader = true;
386
+ void this.schedule("leader");
387
+ return new Promise<void>(() => {}); // hold until the tab dies
388
+ })
389
+ .catch(() => {
390
+ this.leader = true;
391
+ });
392
+ }
393
+ }
394
+
395
+ private updatePullTimer(): void {
396
+ const visible = typeof document === "undefined" || document.visibilityState !== "hidden";
397
+ if (visible && !this.pullTimer && this.options.pullIntervalMs > 0) {
398
+ this.pullTimer = setInterval(() => void this.schedule("interval"), this.options.pullIntervalMs);
399
+ (this.pullTimer as { unref?: () => void }).unref?.();
400
+ } else if (!visible && this.pullTimer) {
401
+ clearInterval(this.pullTimer);
402
+ this.pullTimer = null;
403
+ }
404
+ }
405
+
406
+ private schedulePush(): void {
407
+ if (this.disposed) return;
408
+ const now = Date.now();
409
+ if (!this.pushDeadline) this.pushDeadline = now + MAX_PUSH_DELAY_MS;
410
+ const delay = Math.max(0, Math.min(this.options.pushDelayMs, this.pushDeadline - now));
411
+ if (this.pushTimer) clearTimeout(this.pushTimer);
412
+ this.pushTimer = setTimeout(() => {
413
+ this.pushTimer = null;
414
+ this.pushDeadline = 0;
415
+ void this.schedule("push");
416
+ }, delay);
417
+ (this.pushTimer as { unref?: () => void }).unref?.();
418
+ }
419
+
420
+ private schedule(_reason: string): Promise<void> {
421
+ if (this.disposed || this.paused || !this.leader) return Promise.resolve();
422
+ if (!this.deps.userId()) {
423
+ this.setState({ state: "off" });
424
+ return Promise.resolve();
425
+ }
426
+ if (Date.now() < this.backoffUntil) return Promise.resolve();
427
+ return this.enqueue(() => this.sync());
428
+ }
429
+
430
+ private enqueue(fn: () => Promise<void>): Promise<void> {
431
+ const run = this.chain.then(fn, fn);
432
+ this.chain = run.catch(() => {});
433
+ return run;
434
+ }
435
+
436
+ // ── State ────────────────────────────────────────────────────────
437
+
438
+ private setState(patch: Partial<SyncState>): void {
439
+ const next = { ...this.state, ...patch };
440
+ if (patch.state && patch.state !== "error") delete next.error;
441
+ const changed =
442
+ next.state !== this.state.state ||
443
+ next.pending !== this.state.pending ||
444
+ next.lastSyncedAt !== this.state.lastSyncedAt ||
445
+ next.error !== this.state.error;
446
+ this.state = next;
447
+ if (changed) this.deps.events.emit("sync:state", { ...next });
448
+ }
449
+
450
+ private async refreshPending(): Promise<void> {
451
+ try {
452
+ const n = await this.log.pendingCount();
453
+ this.setState({ pending: n });
454
+ } catch {
455
+ /* log unavailable (private mode): stay with the last count */
456
+ }
457
+ }
458
+
459
+ private fail(err: unknown): void {
460
+ const offline = typeof navigator !== "undefined" && navigator.onLine === false;
461
+ const msg = err instanceof Error ? err.message : String(err);
462
+ this.backoffMs = this.backoffMs ? Math.min(this.backoffMs * 2, BACKOFF_MAX_MS) : BACKOFF_MIN_MS;
463
+ this.backoffUntil = Date.now() + this.backoffMs;
464
+ this.setState({ state: offline ? "offline" : "error", error: msg });
465
+ if (!offline && typeof console !== "undefined") console.warn("[runapps sync]", msg);
466
+ }
467
+
468
+ // ── Sync ─────────────────────────────────────────────────────────
469
+
470
+ private async loadMeta(): Promise<void> {
471
+ if (!this.metaLoaded) {
472
+ this.metaLoaded = (async () => {
473
+ for (const [k, v] of await this.log.metaWithPrefix("etag:")) this.etags.set(k.slice(5), v as string);
474
+ for (const [k, v] of await this.log.metaWithPrefix("schema:")) {
475
+ const name = k.slice(7);
476
+ if (!this.schemas.has(name)) {
477
+ this.schemas.set(name, new Map(Object.entries(v as Record<string, StoreSchema>)));
478
+ }
479
+ }
480
+ })();
481
+ this.metaLoaded.catch(() => {
482
+ this.metaLoaded = null;
483
+ });
484
+ }
485
+ return this.metaLoaded;
486
+ }
487
+
488
+ /** The local copy belongs to one user. Another user signing in on the
489
+ * same browser starts from the server's copy, never from theirs. */
490
+ private async ensureOwner(userId: string): Promise<void> {
491
+ const owner = await this.log.getMeta<string>("owner");
492
+ if (owner === userId) return;
493
+ if (owner !== undefined) {
494
+ for (const [db, schema] of this.schemas) {
495
+ for (const store of schema.keys()) {
496
+ if (this.storeExcluded(db, store)) continue;
497
+ await this.withStore(db, store, "readwrite", (st) => {
498
+ st.clear();
499
+ }).catch(() => {});
500
+ this.emitChange({ db, store, keys: [], cleared: true, source: "remote" });
501
+ }
502
+ }
503
+ await this.log.clearAll();
504
+ this.etags.clear();
505
+ this.metaLoaded = null;
506
+ await this.loadMeta();
507
+ }
508
+ await this.log.setMeta({ owner: userId });
509
+ }
510
+
511
+ private async sync(): Promise<void> {
512
+ if (this.disposed || this.paused || !this.leader) return;
513
+ const userId = this.deps.userId();
514
+ if (!userId) {
515
+ this.setState({ state: "off" });
516
+ return;
517
+ }
518
+ this.setState({ state: "syncing" });
519
+ try {
520
+ await this.loadMeta();
521
+ await this.ensureOwner(userId);
522
+ await this.logChain;
523
+ await this.push();
524
+ await this.pull();
525
+ this.backoffMs = 0;
526
+ this.backoffUntil = 0;
527
+ await this.refreshPending();
528
+ this.setState({ state: "idle", lastSyncedAt: Date.now() });
529
+ } catch (err) {
530
+ await this.refreshPending();
531
+ this.fail(err);
532
+ }
533
+ }
534
+
535
+ private storeExcluded(db: string, store: string): boolean {
536
+ return this.options.exclude.includes(db + "/" + store) || this.options.exclude.includes(db);
537
+ }
538
+
539
+ private shardPath(db: string, store: string, shard: number): string {
540
+ return `${this.options.prefix}/${pathSeg(db)}/${pathSeg(store)}/${shard}.json`;
541
+ }
542
+
543
+ private shardOf(kid: string): number {
544
+ return hash32(kid) % this.options.shards;
545
+ }
546
+
547
+ private async readShard(path: string): Promise<{ shard: Shard; etag?: string }> {
548
+ const before = Date.now();
549
+ const r = await this.deps.remote.getJSON(path);
550
+ if (r.status === "ok") {
551
+ if (r.serverTime) this.clock.observe(r.serverTime, before, Date.now());
552
+ const s = r.value as Partial<Shard> | null;
553
+ const shard: Shard = { v: 1, cleared: s?.cleared ?? 0, r: s?.r ?? {} };
554
+ this.etags.set(path, r.etag);
555
+ return { shard, etag: r.etag };
556
+ }
557
+ this.etags.delete(path);
558
+ return { shard: { v: 1, cleared: 0, r: {} } };
559
+ }
560
+
561
+ // ── Push ─────────────────────────────────────────────────────────
562
+
563
+ private async push(): Promise<void> {
564
+ const ops = await this.log.pending();
565
+ if (ops.length === 0) return;
566
+ // Group by store, keeping commit order inside a store.
567
+ const groups = new Map<string, LoggedOp[]>();
568
+ for (const op of ops) {
569
+ const k = op.db + "\u0000" + op.store;
570
+ let g = groups.get(k);
571
+ if (!g) groups.set(k, (g = []));
572
+ g.push(op);
573
+ }
574
+ const done: number[] = [];
575
+ const etagUpdates: Record<string, unknown> = {};
576
+ for (const [, g] of groups) {
577
+ const db = g[0]!.db;
578
+ const store = g[0]!.store;
579
+ if (this.storeExcluded(db, store)) {
580
+ for (const op of g) if (op.id !== undefined) done.push(op.id);
581
+ continue;
582
+ }
583
+ // A clear drops everything written before it; ops after the last
584
+ // clear are the ones that still matter.
585
+ let clearedAt = 0;
586
+ let lastClear = -1;
587
+ g.forEach((op, i) => {
588
+ if (op.kind === "clear") {
589
+ clearedAt = Math.max(clearedAt, op.ts);
590
+ lastClear = i;
591
+ }
592
+ });
593
+ const live = g.slice(lastClear + 1);
594
+ const byShard = new Map<number, LoggedOp[]>();
595
+ for (const op of live) {
596
+ const s = this.shardOf(op.kid);
597
+ let list = byShard.get(s);
598
+ if (!list) byShard.set(s, (list = []));
599
+ list.push(op);
600
+ }
601
+ const shards = clearedAt > 0 ? Array.from({ length: this.options.shards }, (_, i) => i) : Array.from(byShard.keys());
602
+ for (const n of shards) {
603
+ const path = this.shardPath(db, store, n);
604
+ const shardOps = byShard.get(n) ?? [];
605
+ const encoded = await Promise.all(shardOps.map((op) => this.encodeOp(op)));
606
+ const etag = await this.writeShard(path, clearedAt, encoded);
607
+ etagUpdates["etag:" + path] = etag;
608
+ }
609
+ for (const op of g) if (op.id !== undefined) done.push(op.id);
610
+ }
611
+ await this.log.setMeta(etagUpdates);
612
+ await this.log.remove(done);
613
+ await this.refreshPending();
614
+ }
615
+
616
+ private async encodeOp(op: LoggedOp): Promise<ShardOp> {
617
+ if (op.kind === "del") return { kid: op.kid, rec: { k: op.key, v: null, t: op.ts, d: 1 } };
618
+ const v = await encodeValue(op.value, (blob) => this.storeBlob(blob));
619
+ return { kid: op.kid, rec: { k: op.key, v, t: op.ts }, changed: op.changed };
620
+ }
621
+
622
+ /** Merge ops into one shard and write it; retried on a 412. */
623
+ private async writeShard(path: string, clearedAt: number, ops: ShardOp[]): Promise<string> {
624
+ for (let attempt = 0; attempt < 5; attempt++) {
625
+ const { shard, etag } = await this.readShard(path);
626
+ const now = this.clock.now();
627
+ if (clearedAt > shard.cleared) {
628
+ shard.cleared = clearedAt;
629
+ for (const kid of Object.keys(shard.r)) {
630
+ if (shard.r[kid]!.t <= clearedAt) delete shard.r[kid];
631
+ }
632
+ }
633
+ for (const { kid, rec, changed } of ops) {
634
+ if (rec.t <= shard.cleared) continue;
635
+ shard.r[kid] = mergeRecord(shard.r[kid], rec, changed);
636
+ }
637
+ // Tombstones only matter until every device has seen them.
638
+ for (const kid of Object.keys(shard.r)) {
639
+ const rec = shard.r[kid]!;
640
+ if (rec.d && now - rec.t > TOMBSTONE_TTL_MS) delete shard.r[kid];
641
+ }
642
+ const before = Date.now();
643
+ const r = await this.deps.remote.putJSON(path, shard, etag ? { ifMatch: etag } : { ifNoneMatch: true });
644
+ if (r.status === "ok") {
645
+ if (r.serverTime) this.clock.observe(r.serverTime, before, Date.now());
646
+ this.etags.set(path, r.etag);
647
+ return r.etag;
648
+ }
649
+ this.etags.delete(path);
650
+ }
651
+ throw new Error(`could not write ${path}: too many concurrent writers`);
652
+ }
653
+
654
+ private async storeBlob(blob: Blob): Promise<BlobRef> {
655
+ const bytes = new Uint8Array(await blob.arrayBuffer());
656
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
657
+ const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
658
+ const ref = `${this.options.prefix}/_blobs/${hex}`;
659
+ await this.deps.remote.putBlob(ref, blob);
660
+ return { ref, type: blob.type, size: blob.size };
661
+ }
662
+
663
+ // ── Pull ─────────────────────────────────────────────────────────
664
+
665
+ private async pull(): Promise<void> {
666
+ const listing = await this.deps.remote.list(this.options.prefix + "/");
667
+ await this.applyListing(listing);
668
+ }
669
+
670
+ private async pullDatabase(name: string): Promise<void> {
671
+ if (!this.deps.userId()) return;
672
+ await this.loadMeta();
673
+ const listing = await this.deps.remote.list(`${this.options.prefix}/${pathSeg(name)}/`);
674
+ await this.applyListing(listing);
675
+ }
676
+
677
+ private async applyListing(listing: { path: string; etag: string }[]): Promise<void> {
678
+ const re = new RegExp("^" + this.options.prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/([^/]+)/([^/]+)/(\\d+)\\.json$");
679
+ const pending = await this.log.pending();
680
+ const etagUpdates: Record<string, unknown> = {};
681
+ for (const entry of listing) {
682
+ const m = re.exec(entry.path);
683
+ if (!m) continue;
684
+ const db = decodeURIComponent(m[1]!);
685
+ const store = decodeURIComponent(m[2]!);
686
+ if (this.etags.get(entry.path) === entry.etag) continue;
687
+ if (this.storeExcluded(db, store)) continue;
688
+ const schema = this.schemas.get(db);
689
+ if (!schema || !schema.has(store)) continue; // the app has not created it (yet)
690
+ const r = await this.deps.remote.getJSON(entry.path, this.etags.get(entry.path));
691
+ if (r.status === "unchanged") {
692
+ continue;
693
+ }
694
+ if (r.status === "missing") {
695
+ this.etags.delete(entry.path);
696
+ etagUpdates["etag:" + entry.path] = undefined;
697
+ continue;
698
+ }
699
+ const s = r.value as Partial<Shard> | null;
700
+ const shard: Shard = { v: 1, cleared: s?.cleared ?? 0, r: s?.r ?? {} };
701
+ await this.applyShard(db, store, shard, pending);
702
+ this.etags.set(entry.path, r.etag);
703
+ etagUpdates["etag:" + entry.path] = r.etag;
704
+ }
705
+ if (Object.keys(etagUpdates).length) await this.log.setMeta(etagUpdates);
706
+ }
707
+
708
+ private async applyShard(db: string, store: string, shard: Shard, pending: LoggedOp[]): Promise<void> {
709
+ // A local write newer than the remote record wins until it is pushed.
710
+ const localNewer = new Map<string, number>();
711
+ let localClear = 0;
712
+ for (const op of pending) {
713
+ if (op.db !== db || op.store !== store) continue;
714
+ if (op.kind === "clear") localClear = Math.max(localClear, op.ts);
715
+ else localNewer.set(op.kid, Math.max(localNewer.get(op.kid) ?? 0, op.ts));
716
+ }
717
+ const clearKey = `cleared:${db}/${store}`;
718
+ const appliedClear = (await this.log.getMeta<number>(clearKey)) ?? 0;
719
+ const schema = this.schemas.get(db)?.get(store);
720
+ const keys: IDBValidKey[] = [];
721
+ let clearedLocally = false;
722
+ await this.withStore(db, store, "readwrite", (st) => {
723
+ if (shard.cleared > appliedClear && shard.cleared > localClear) {
724
+ // Another device cleared this store after our last pull: drop the
725
+ // local rows, keep what is still unpushed (replayed below).
726
+ st.clear();
727
+ clearedLocally = true;
728
+ }
729
+ for (const kid of Object.keys(shard.r)) {
730
+ const rec = shard.r[kid]!;
731
+ if ((localNewer.get(kid) ?? 0) >= rec.t) continue;
732
+ if (rec.t <= localClear) continue;
733
+ const key = decodeKey(rec.k);
734
+ keys.push(key);
735
+ if (rec.d) {
736
+ st.delete(key);
737
+ } else {
738
+ // Values are decoded before the transaction (Blobs need the
739
+ // network); see the pre-pass below.
740
+ const value = (rec as ShardRecord & { _decoded?: unknown })._decoded;
741
+ if (schema && schema.keyPath !== null) st.put(value);
742
+ else st.put(value, key);
743
+ }
744
+ }
745
+ if (clearedLocally) {
746
+ for (const op of pending) {
747
+ if (op.db !== db || op.store !== store || op.kind !== "put" || op.ts <= shard.cleared) continue;
748
+ if (schema && schema.keyPath !== null) st.put(op.value);
749
+ else st.put(op.value, decodeKey(op.key));
750
+ }
751
+ }
752
+ }, async () => {
753
+ for (const kid of Object.keys(shard.r)) {
754
+ const rec = shard.r[kid]!;
755
+ if (rec.d || (localNewer.get(kid) ?? 0) >= rec.t || rec.t <= localClear) continue;
756
+ (rec as ShardRecord & { _decoded?: unknown })._decoded = await decodeValue(rec.v, (ref) =>
757
+ this.deps.remote.getBlob(ref.ref),
758
+ );
759
+ }
760
+ });
761
+ if (shard.cleared > appliedClear) await this.log.setMeta({ [clearKey]: shard.cleared });
762
+ if (keys.length > 0 || clearedLocally) {
763
+ this.emitChange({ db, store, keys, cleared: clearedLocally, source: "remote" });
764
+ }
765
+ }
766
+
767
+ private emitChange(change: DbChange): void {
768
+ this.deps.events.emit("db:change", change);
769
+ try {
770
+ this.channel?.postMessage(change);
771
+ } catch {
772
+ /* keys that cannot be cloned across tabs: the local event already fired */
773
+ }
774
+ }
775
+
776
+ // ── The engine's own connections ─────────────────────────────────
777
+
778
+ private conn(db: string): Promise<IDBDatabase> {
779
+ let p = this.conns.get(db);
780
+ if (!p) {
781
+ p = new Promise<IDBDatabase>((resolve, reject) => {
782
+ const open = this.hook ? this.hook.originalOpen : (n: string) => indexedDB.open(n);
783
+ const r = open(db);
784
+ r.onsuccess = () => {
785
+ const h = r.result;
786
+ // Step aside when the app upgrades its schema.
787
+ h.onversionchange = () => {
788
+ h.close();
789
+ this.conns.delete(db);
790
+ };
791
+ resolve(h);
792
+ };
793
+ r.onerror = () => reject(r.error ?? new Error(`cannot open ${db}`));
794
+ r.onblocked = () => reject(new Error(`open of ${db} blocked`));
795
+ });
796
+ this.conns.set(db, p);
797
+ p.catch(() => this.conns.delete(db));
798
+ }
799
+ return p;
800
+ }
801
+
802
+ private async withStore(
803
+ db: string,
804
+ store: string,
805
+ mode: IDBTransactionMode,
806
+ fn: (st: IDBObjectStore) => void,
807
+ prepare?: () => Promise<void>,
808
+ ): Promise<void> {
809
+ if (prepare) await prepare();
810
+ const h = await this.conn(db);
811
+ if (!h.objectStoreNames.contains(store)) return;
812
+ const tx = h.transaction(store, mode);
813
+ fn(tx.objectStore(store));
814
+ await idbDone(tx);
815
+ }
816
+ }
817
+
818
+ export { idbRequest };