@runappsai/sdk 0.5.1 → 0.6.2

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 (68) hide show
  1. package/README.md +29 -0
  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 +25 -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/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/sdk.umd.js +2 -2
  26. package/dist/sdk.umd.js.map +1 -1
  27. package/dist/sync/codec.d.ts +39 -0
  28. package/dist/sync/codec.d.ts.map +1 -0
  29. package/dist/sync/codec.js +364 -0
  30. package/dist/sync/codec.js.map +1 -0
  31. package/dist/sync/engine.d.ts +143 -0
  32. package/dist/sync/engine.d.ts.map +1 -0
  33. package/dist/sync/engine.js +727 -0
  34. package/dist/sync/engine.js.map +1 -0
  35. package/dist/sync/hook.d.ts +42 -0
  36. package/dist/sync/hook.d.ts.map +1 -0
  37. package/dist/sync/hook.js +426 -0
  38. package/dist/sync/hook.js.map +1 -0
  39. package/dist/sync/index.d.ts +6 -0
  40. package/dist/sync/index.d.ts.map +1 -0
  41. package/dist/sync/index.js +6 -0
  42. package/dist/sync/index.js.map +1 -0
  43. package/dist/sync/log.d.ts +41 -0
  44. package/dist/sync/log.d.ts.map +1 -0
  45. package/dist/sync/log.js +143 -0
  46. package/dist/sync/log.js.map +1 -0
  47. package/dist/sync/remote.d.ts +38 -0
  48. package/dist/sync/remote.d.ts.map +1 -0
  49. package/dist/sync/remote.js +61 -0
  50. package/dist/sync/remote.js.map +1 -0
  51. package/dist/transport.d.ts +5 -0
  52. package/dist/transport.d.ts.map +1 -1
  53. package/dist/transport.js +19 -6
  54. package/dist/transport.js.map +1 -1
  55. package/package.json +2 -1
  56. package/src/activity-tracker.ts +5 -0
  57. package/src/browser-auth.ts +103 -16
  58. package/src/client.ts +37 -0
  59. package/src/events.ts +22 -0
  60. package/src/files.ts +7 -5
  61. package/src/index.ts +13 -0
  62. package/src/sync/codec.ts +354 -0
  63. package/src/sync/engine.ts +818 -0
  64. package/src/sync/hook.ts +495 -0
  65. package/src/sync/index.ts +5 -0
  66. package/src/sync/log.ts +170 -0
  67. package/src/sync/remote.ts +59 -0
  68. package/src/transport.ts +20 -8
@@ -0,0 +1,495 @@
1
+ // 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
+ // 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 };