@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
package/src/client.ts CHANGED
@@ -8,6 +8,7 @@ import { ComputerService } from "./computer.js";
8
8
  import { FilesService } from "./files.js";
9
9
  import { EmbeddingsService } from "./embeddings.js";
10
10
  import { BrowserAuth, type BrowserUser } from "./browser-auth.js";
11
+ import { FilesRemote, SyncEngine, type SyncOptions, type SyncState } from "./sync/index.js";
11
12
  import { SDKEvents } from "./events.js";
12
13
 
13
14
  // The platform, not the bare gateway: personal rk_ keys are checked and
@@ -94,6 +95,17 @@ export interface ClientOptions {
94
95
  baseURL?: string;
95
96
  /** Optional fetch override (e.g. node-fetch with custom agent). */
96
97
  fetch?: typeof fetch;
98
+ /**
99
+ * RunApps Sync: keep the data the app stores in IndexedDB on every
100
+ * device the user signs in on. **On by default** in an app on RunApps
101
+ * (browser sign-in mode): the SDK observes every database the app
102
+ * opens, pushes committed writes a few seconds later, pulls other
103
+ * devices' changes when the page is opened, focused or comes back
104
+ * online, and shows the state on the badge. Pass `false` to turn it
105
+ * off, or options to limit it to some databases / leave stores out.
106
+ * See `client.sync`.
107
+ */
108
+ sync?: boolean | SyncOptions;
97
109
  }
98
110
 
99
111
  /**
@@ -136,6 +148,13 @@ export class RunApps {
136
148
  * subscription, etc.).
137
149
  */
138
150
  readonly auth: BrowserAuth | null = null;
151
+ /**
152
+ * RunApps Sync (see `ClientOptions.sync`), or null when it
153
+ * is off. `status()` gives the state the badge shows; `now()` pushes
154
+ * and pulls immediately; `client.events.on("db:change", …)` tells the
155
+ * app which store received remote changes so it can re-read.
156
+ */
157
+ readonly sync: { status(): SyncState; now(): Promise<void>; pause(): void; resume(): void; dispose(): void } | null = null;
139
158
 
140
159
  /**
141
160
  * Runtime event bus for SDK-call telemetry. Every LLM-ish service
@@ -231,6 +250,24 @@ export class RunApps {
231
250
  // legacy unscoped /v1/files/... paths.
232
251
  this.files = new FilesService(transport, options.project);
233
252
  this.embeddings = new EmbeddingsService(transport, this.events);
253
+
254
+ // Sync: an app on RunApps gets it unless it says no. It
255
+ // stores under the signed-in user, so only browser sign-in mode has
256
+ // someone to sync for.
257
+ const wantSync = options.sync ?? this.auth !== null;
258
+ if (wantSync && typeof indexedDB !== "undefined" && this.auth) {
259
+ const auth = this.auth;
260
+ const engine = new SyncEngine(
261
+ {
262
+ remote: new FilesRemote(this.files),
263
+ events: this.events,
264
+ userId: () => auth.ownerId(),
265
+ onAuthChange: (cb) => auth.onTokenChange(() => cb()),
266
+ },
267
+ typeof wantSync === "object" ? wantSync : {},
268
+ );
269
+ (this as { sync: RunApps["sync"] }).sync = engine;
270
+ }
234
271
  }
235
272
 
236
273
  /** Force a redirect to the runapps.ai grant page. No-op in static
package/src/events.ts CHANGED
@@ -93,11 +93,33 @@ export interface RequestErrorEvent {
93
93
  /** Compile-time map of event name → payload shape. Drives handler
94
94
  * inference in `on()` / `emit()` so callers can't subscribe to a
95
95
  * typo'd name or read a non-existent field. */
96
+ /** Fired whenever Sync's state, pending count or last
97
+ * sync time changes. See `client.sync`. */
98
+ export interface SyncStateEvent {
99
+ state: "off" | "idle" | "syncing" | "offline" | "error";
100
+ pending: number;
101
+ lastSyncedAt?: number;
102
+ error?: string;
103
+ }
104
+
105
+ /** Fired after the sync wrote remote changes into a local object store,
106
+ * in this tab and (through BroadcastChannel) in the app's other tabs.
107
+ * Re-read the store to show the new data. */
108
+ export interface DbChangeEvent {
109
+ db: string;
110
+ store: string;
111
+ keys: IDBValidKey[];
112
+ cleared: boolean;
113
+ source: "remote";
114
+ }
115
+
96
116
  export interface SDKEventMap {
97
117
  "request:start": RequestStartEvent;
98
118
  "request:streamDelta": RequestStreamDeltaEvent;
99
119
  "request:end": RequestEndEvent;
100
120
  "request:error": RequestErrorEvent;
121
+ "sync:state": SyncStateEvent;
122
+ "db:change": DbChangeEvent;
101
123
  }
102
124
 
103
125
  /** Disposer returned by `on()` — call to unsubscribe. */
package/src/files.ts CHANGED
@@ -177,12 +177,13 @@ export class FilesService {
177
177
  * `put(path, body, { ifMatch: etag })` writes only if the file is
178
178
  * still the version that was read.
179
179
  */
180
- async read(path: string, init?: { signal?: AbortSignal }): Promise<FileRead> {
181
- const { data, contentType, etag } = await this.transport.getRaw(pathToURL(this.base, path), init);
180
+ async read(path: string, init?: { signal?: AbortSignal; ifNoneMatch?: string }): Promise<FileRead> {
181
+ const { data, contentType, etag, notModified } = await this.transport.getRaw(pathToURL(this.base, path), init);
182
182
  return {
183
183
  blob: new Blob([new Uint8Array(data)], { type: contentType || "application/octet-stream" }),
184
184
  contentType,
185
185
  etag,
186
+ ...(notModified ? { notModified: true } : {}),
186
187
  };
187
188
  }
188
189
 
@@ -193,9 +194,9 @@ export class FilesService {
193
194
  */
194
195
  async readJSON<T = unknown>(
195
196
  path: string,
196
- init?: { signal?: AbortSignal },
197
- ): Promise<{ value: T | undefined; etag?: string }> {
198
- let r: FileRead;
197
+ init?: { signal?: AbortSignal; ifNoneMatch?: string },
198
+ ): Promise<{ value: T | undefined; etag?: string; notModified?: boolean }> {
199
+ let r: FileRead & { notModified?: boolean };
199
200
  try {
200
201
  r = await this.read(path, init);
201
202
  } catch (e) {
@@ -205,6 +206,7 @@ export class FilesService {
205
206
  }
206
207
  throw e;
207
208
  }
209
+ if (r.notModified) return { value: undefined, etag: r.etag, notModified: true };
208
210
  return { value: JSON.parse(await r.blob.text()) as T, etag: r.etag };
209
211
  }
210
212
 
package/src/index.ts CHANGED
@@ -34,6 +34,19 @@ export {
34
34
  type ActivitySnapshot,
35
35
  } from "./activity-tracker.js";
36
36
  export { BrowserAuth, type BrowserAuthOptions, type BrowserUser } from "./browser-auth.js";
37
+ // RunApps Sync — on by default in apps; exported for status UIs and
38
+ // for tests that drive the engine with their own storage.
39
+ export {
40
+ SyncEngine,
41
+ FilesRemote,
42
+ installIndexedDBHook,
43
+ type SyncOptions,
44
+ type SyncState,
45
+ type DbChange,
46
+ type SyncRemote,
47
+ type SyncDeps,
48
+ } from "./sync/index.js";
49
+ export type { SyncStateEvent, DbChangeEvent } from "./events.js";
37
50
  export { APIError } from "./errors.js";
38
51
  export type { Usage } from "./types.js";
39
52
 
@@ -0,0 +1,354 @@
1
+ // sync/codec.ts — JSON encoding for IndexedDB values and keys.
2
+ //
3
+ // IndexedDB stores anything the structured-clone algorithm accepts:
4
+ // Dates, Maps, Sets, binary, Blobs, nested objects, undefined. The sync
5
+ // snapshots on the server are JSON, so every such value is wrapped in a
6
+ // small tagged object on the way out and rebuilt on the way in. Plain
7
+ // JSON values pass through untouched, which keeps the common case (a
8
+ // record of strings and numbers) byte-for-byte readable on the server.
9
+ //
10
+ // Blobs are not inlined: `encodeValue` hands them to a `BlobSink` which
11
+ // stores the bytes elsewhere (the engine uploads them as files) and
12
+ // returns a reference; `decodeValue` asks a `BlobSource` for the bytes.
13
+
14
+ const TAG = "$rt";
15
+
16
+ /** How an encoded value refers to a Blob stored out of band. */
17
+ export interface BlobRef {
18
+ ref: string;
19
+ type: string;
20
+ size: number;
21
+ }
22
+
23
+ export interface BlobSink {
24
+ /** Store the blob and return a stable reference for it. */
25
+ (blob: Blob): Promise<BlobRef>;
26
+ }
27
+
28
+ export interface BlobSource {
29
+ (ref: BlobRef): Promise<Blob>;
30
+ }
31
+
32
+ type Tagged =
33
+ | { [TAG]: "date"; v: number | string }
34
+ | { [TAG]: "undef" }
35
+ | { [TAG]: "num"; v: "NaN" | "Infinity" | "-Infinity" | "-0" }
36
+ | { [TAG]: "bigint"; v: string }
37
+ | { [TAG]: "map"; v: unknown[] }
38
+ | { [TAG]: "set"; v: unknown[] }
39
+ | { [TAG]: "bin"; c: string; v: string }
40
+ | { [TAG]: "blob"; r: string; t: string; s: number; n?: string; m?: number }
41
+ | { [TAG]: "re"; s: string; f: string }
42
+ | { [TAG]: "obj"; v: Record<string, unknown> };
43
+
44
+ const BIN_CTORS: Record<string, (buf: ArrayBuffer) => ArrayBufferView | ArrayBuffer> = {
45
+ ArrayBuffer: (b) => b,
46
+ Uint8Array: (b) => new Uint8Array(b),
47
+ Int8Array: (b) => new Int8Array(b),
48
+ Uint8ClampedArray: (b) => new Uint8ClampedArray(b),
49
+ Int16Array: (b) => new Int16Array(b),
50
+ Uint16Array: (b) => new Uint16Array(b),
51
+ Int32Array: (b) => new Int32Array(b),
52
+ Uint32Array: (b) => new Uint32Array(b),
53
+ Float32Array: (b) => new Float32Array(b),
54
+ Float64Array: (b) => new Float64Array(b),
55
+ BigInt64Array: (b) => new BigInt64Array(b),
56
+ BigUint64Array: (b) => new BigUint64Array(b),
57
+ DataView: (b) => new DataView(b),
58
+ };
59
+
60
+ function bytesToBase64(bytes: Uint8Array): string {
61
+ let s = "";
62
+ for (let i = 0; i < bytes.length; i += 0x8000) {
63
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
64
+ }
65
+ return btoa(s);
66
+ }
67
+
68
+ function base64ToBytes(b64: string): Uint8Array {
69
+ const s = atob(b64);
70
+ const out = new Uint8Array(s.length);
71
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
72
+ return out;
73
+ }
74
+
75
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
76
+ if (v === null || typeof v !== "object") return false;
77
+ const proto = Object.getPrototypeOf(v);
78
+ return proto === Object.prototype || proto === null;
79
+ }
80
+
81
+ /**
82
+ * Encode a structured-clone value into a JSON-safe one. Async only
83
+ * because Blobs have to be read.
84
+ */
85
+ export async function encodeValue(value: unknown, sink?: BlobSink): Promise<unknown> {
86
+ if (value === undefined) return { [TAG]: "undef" } satisfies Tagged;
87
+ if (value === null) return null;
88
+ switch (typeof value) {
89
+ case "string":
90
+ case "boolean":
91
+ return value;
92
+ case "number":
93
+ if (Number.isNaN(value)) return { [TAG]: "num", v: "NaN" };
94
+ if (value === Infinity) return { [TAG]: "num", v: "Infinity" };
95
+ if (value === -Infinity) return { [TAG]: "num", v: "-Infinity" };
96
+ if (Object.is(value, -0)) return { [TAG]: "num", v: "-0" };
97
+ return value;
98
+ case "bigint":
99
+ return { [TAG]: "bigint", v: value.toString() };
100
+ case "function":
101
+ case "symbol":
102
+ throw new TypeError(`runapps sync: ${typeof value} values cannot be stored`);
103
+ }
104
+ if (value instanceof Date) {
105
+ const t = value.getTime();
106
+ return { [TAG]: "date", v: Number.isNaN(t) ? "invalid" : t };
107
+ }
108
+ if (value instanceof RegExp) return { [TAG]: "re", s: value.source, f: value.flags };
109
+ if (typeof Blob !== "undefined" && value instanceof Blob) {
110
+ if (!sink) throw new TypeError("runapps sync: Blob values need a blob sink");
111
+ const ref = await sink(value);
112
+ const out: Tagged = { [TAG]: "blob", r: ref.ref, t: ref.type, s: ref.size };
113
+ const f = value as Blob & { name?: string; lastModified?: number };
114
+ if (typeof File !== "undefined" && value instanceof File) {
115
+ out.n = f.name;
116
+ out.m = f.lastModified;
117
+ }
118
+ return out;
119
+ }
120
+ if (value instanceof ArrayBuffer) {
121
+ return { [TAG]: "bin", c: "ArrayBuffer", v: bytesToBase64(new Uint8Array(value)) };
122
+ }
123
+ if (ArrayBuffer.isView(value)) {
124
+ const name = value.constructor.name;
125
+ if (!(name in BIN_CTORS)) throw new TypeError(`runapps sync: unsupported binary type ${name}`);
126
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
127
+ return { [TAG]: "bin", c: name, v: bytesToBase64(bytes) };
128
+ }
129
+ if (value instanceof Map) {
130
+ const v: unknown[] = [];
131
+ for (const [k, x] of value) v.push([await encodeValue(k, sink), await encodeValue(x, sink)]);
132
+ return { [TAG]: "map", v };
133
+ }
134
+ if (value instanceof Set) {
135
+ const v: unknown[] = [];
136
+ for (const x of value) v.push(await encodeValue(x, sink));
137
+ return { [TAG]: "set", v };
138
+ }
139
+ if (Array.isArray(value)) {
140
+ const out: unknown[] = new Array(value.length);
141
+ for (let i = 0; i < value.length; i++) out[i] = await encodeValue(value[i], sink);
142
+ return out;
143
+ }
144
+ if (isPlainObject(value)) {
145
+ // A plain object that happens to carry our tag key is wrapped so it
146
+ // cannot be mistaken for an encoding marker.
147
+ const needsWrap = Object.prototype.hasOwnProperty.call(value, TAG);
148
+ const out: Record<string, unknown> = {};
149
+ for (const k of Object.keys(value)) out[k] = await encodeValue(value[k], sink);
150
+ return needsWrap ? { [TAG]: "obj", v: out } : out;
151
+ }
152
+ // Class instances: structured clone keeps own enumerable properties
153
+ // only, so do the same.
154
+ const out: Record<string, unknown> = {};
155
+ for (const k of Object.keys(value as object)) out[k] = await encodeValue((value as Record<string, unknown>)[k], sink);
156
+ return out;
157
+ }
158
+
159
+ /** Inverse of `encodeValue`. */
160
+ export async function decodeValue(value: unknown, source?: BlobSource): Promise<unknown> {
161
+ if (value === null || typeof value !== "object") return value;
162
+ if (Array.isArray(value)) {
163
+ const out: unknown[] = new Array(value.length);
164
+ for (let i = 0; i < value.length; i++) out[i] = await decodeValue(value[i], source);
165
+ return out;
166
+ }
167
+ const obj = value as Record<string, unknown>;
168
+ const tag = obj[TAG];
169
+ if (typeof tag === "string") {
170
+ const t = obj as unknown as Tagged;
171
+ switch (t[TAG]) {
172
+ case "undef":
173
+ return undefined;
174
+ case "date":
175
+ return t.v === "invalid" ? new Date(NaN) : new Date(t.v);
176
+ case "num":
177
+ return t.v === "NaN" ? NaN : t.v === "Infinity" ? Infinity : t.v === "-Infinity" ? -Infinity : -0;
178
+ case "bigint":
179
+ return BigInt(t.v);
180
+ case "re":
181
+ return new RegExp(t.s, t.f);
182
+ case "bin": {
183
+ const bytes = base64ToBytes(t.v);
184
+ const buf = new ArrayBuffer(bytes.byteLength);
185
+ new Uint8Array(buf).set(bytes);
186
+ const make = BIN_CTORS[t.c];
187
+ if (!make) throw new TypeError(`runapps sync: unsupported binary type ${t.c}`);
188
+ return make(buf);
189
+ }
190
+ case "map": {
191
+ const m = new Map<unknown, unknown>();
192
+ for (const pair of t.v as [unknown, unknown][]) {
193
+ m.set(await decodeValue(pair[0], source), await decodeValue(pair[1], source));
194
+ }
195
+ return m;
196
+ }
197
+ case "set": {
198
+ const s = new Set<unknown>();
199
+ for (const x of t.v) s.add(await decodeValue(x, source));
200
+ return s;
201
+ }
202
+ case "blob": {
203
+ if (!source) throw new TypeError("runapps sync: Blob values need a blob source");
204
+ const blob = await source({ ref: t.r, type: t.t, size: t.s });
205
+ if (t.n !== undefined && typeof File !== "undefined") {
206
+ return new File([blob], t.n, { type: t.t, lastModified: t.m ?? Date.now() });
207
+ }
208
+ return blob;
209
+ }
210
+ case "obj":
211
+ return decodeValue(t.v, source);
212
+ }
213
+ }
214
+ const out: Record<string, unknown> = {};
215
+ for (const k of Object.keys(obj)) out[k] = await decodeValue(obj[k], source);
216
+ return out;
217
+ }
218
+
219
+ // ── Keys ─────────────────────────────────────────────────────────────
220
+ //
221
+ // A valid IndexedDB key is a number, a string, a Date, binary, or an
222
+ // array of those. Keys are encoded synchronously (no Blobs possible)
223
+ // and also rendered to a canonical string that serves as the record id
224
+ // inside a shard and as the input to the shard hash.
225
+
226
+ export function encodeKey(key: IDBValidKey): unknown {
227
+ if (typeof key === "number") {
228
+ if (Number.isNaN(key)) throw new TypeError("runapps sync: NaN is not a valid key");
229
+ return key;
230
+ }
231
+ if (typeof key === "string") return key;
232
+ if (key instanceof Date) return { [TAG]: "date", v: key.getTime() };
233
+ if (key instanceof ArrayBuffer) return { [TAG]: "bin", c: "ArrayBuffer", v: bytesToBase64(new Uint8Array(key)) };
234
+ if (ArrayBuffer.isView(key)) {
235
+ return {
236
+ [TAG]: "bin",
237
+ c: "ArrayBuffer",
238
+ v: bytesToBase64(new Uint8Array(key.buffer, key.byteOffset, key.byteLength)),
239
+ };
240
+ }
241
+ if (Array.isArray(key)) return key.map((k) => encodeKey(k as IDBValidKey));
242
+ throw new TypeError("runapps sync: invalid IndexedDB key");
243
+ }
244
+
245
+ export function decodeKey(encoded: unknown): IDBValidKey {
246
+ if (typeof encoded === "number" || typeof encoded === "string") return encoded;
247
+ if (Array.isArray(encoded)) return encoded.map(decodeKey);
248
+ if (encoded && typeof encoded === "object") {
249
+ const t = encoded as { [TAG]?: string; v?: unknown };
250
+ if (t[TAG] === "date") return new Date(t.v as number);
251
+ if (t[TAG] === "bin") {
252
+ const bytes = base64ToBytes(t.v as string);
253
+ const buf = new ArrayBuffer(bytes.byteLength);
254
+ new Uint8Array(buf).set(bytes);
255
+ return buf;
256
+ }
257
+ }
258
+ throw new TypeError("runapps sync: invalid encoded key");
259
+ }
260
+
261
+ /** Canonical string id for a key: equal keys give equal ids. */
262
+ export function keyId(key: IDBValidKey): string {
263
+ return JSON.stringify(encodeKey(key));
264
+ }
265
+
266
+ /** Deterministic 32-bit hash of a string (FNV-1a). */
267
+ export function hash32(s: string): number {
268
+ let h = 0x811c9dc5;
269
+ for (let i = 0; i < s.length; i++) {
270
+ h ^= s.charCodeAt(i);
271
+ h = Math.imul(h, 0x01000193);
272
+ }
273
+ return h >>> 0;
274
+ }
275
+
276
+ /**
277
+ * Structural equality for structured-clone values: Dates by time,
278
+ * binary by bytes, Map/Set by entries, Blobs by identity (two different
279
+ * Blob objects count as a change). Used to find which fields a write
280
+ * touched.
281
+ */
282
+ export function deepEqual(a: unknown, b: unknown): boolean {
283
+ if (Object.is(a, b)) return true;
284
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
285
+ if (a instanceof Date || b instanceof Date) {
286
+ return a instanceof Date && b instanceof Date && Object.is(a.getTime(), b.getTime());
287
+ }
288
+ if (typeof Blob !== "undefined" && (a instanceof Blob || b instanceof Blob)) return false;
289
+ if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer || ArrayBuffer.isView(b) || b instanceof ArrayBuffer) {
290
+ const toBytes = (x: unknown): Uint8Array | null =>
291
+ x instanceof ArrayBuffer ? new Uint8Array(x) : ArrayBuffer.isView(x) ? new Uint8Array(x.buffer, x.byteOffset, x.byteLength) : null;
292
+ const ba = toBytes(a);
293
+ const bb = toBytes(b);
294
+ if (!ba || !bb || ba.length !== bb.length || a.constructor !== b.constructor) return false;
295
+ for (let i = 0; i < ba.length; i++) if (ba[i] !== bb[i]) return false;
296
+ return true;
297
+ }
298
+ if (a instanceof Map || b instanceof Map) {
299
+ if (!(a instanceof Map && b instanceof Map) || a.size !== b.size) return false;
300
+ for (const [k, v] of a) if (!b.has(k) || !deepEqual(v, b.get(k))) return false;
301
+ return true;
302
+ }
303
+ if (a instanceof Set || b instanceof Set) {
304
+ if (!(a instanceof Set && b instanceof Set) || a.size !== b.size) return false;
305
+ for (const v of a) if (!b.has(v)) return false;
306
+ return true;
307
+ }
308
+ if (Array.isArray(a) || Array.isArray(b)) {
309
+ if (!(Array.isArray(a) && Array.isArray(b)) || a.length !== b.length) return false;
310
+ for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
311
+ return true;
312
+ }
313
+ const ka = Object.keys(a as object);
314
+ const kb = Object.keys(b as object);
315
+ if (ka.length !== kb.length) return false;
316
+ for (const k of ka) {
317
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
318
+ if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
319
+ }
320
+ return true;
321
+ }
322
+
323
+ /** The fields of `value` that differ from `prior` (added, changed or
324
+ * removed). `undefined` when there is no prior: everything is new. */
325
+ export function changedFields(prior: unknown, value: unknown): string[] | undefined {
326
+ if (!isPlainObject(prior) || !isPlainObject(value)) return undefined;
327
+ const out: string[] = [];
328
+ for (const k of new Set([...Object.keys(prior), ...Object.keys(value)])) {
329
+ const inP = Object.prototype.hasOwnProperty.call(prior, k);
330
+ const inV = Object.prototype.hasOwnProperty.call(value, k);
331
+ if (inP !== inV || !deepEqual(prior[k], value[k])) out.push(k);
332
+ }
333
+ return out;
334
+ }
335
+
336
+ /** Read an inline key out of a value by IndexedDB keyPath rules. */
337
+ export function extractKey(value: unknown, keyPath: string | string[] | null): IDBValidKey | undefined {
338
+ if (keyPath === null || keyPath === undefined) return undefined;
339
+ const one = (path: string): unknown => {
340
+ let cur: unknown = value;
341
+ if (path === "") return cur;
342
+ for (const part of path.split(".")) {
343
+ if (cur === null || typeof cur !== "object") return undefined;
344
+ cur = (cur as Record<string, unknown>)[part];
345
+ }
346
+ return cur;
347
+ };
348
+ if (Array.isArray(keyPath)) {
349
+ const parts = keyPath.map(one);
350
+ return parts.some((p) => p === undefined) ? undefined : (parts as IDBValidKey[]);
351
+ }
352
+ const k = one(keyPath);
353
+ return k === undefined ? undefined : (k as IDBValidKey);
354
+ }