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