@rebasepro/client 0.10.0 → 0.10.1-canary.14e53ae

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/offline.ts ADDED
@@ -0,0 +1,1687 @@
1
+ import { buildQueryString, FindParams, RebaseApiError } from "./transport";
2
+ import { FindResult, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue } from "@rebasepro/types";
3
+ import { CollectionClient, LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
4
+ import { SDKQueryBuilder } from "./sdk_query_builder";
5
+ import { dehydrateRow, hydrateRow } from "./offline-codec";
6
+ import { ConnectivityMonitor, isNetworkError, isRetryableError } from "./offline-connectivity";
7
+ import {
8
+ IndexedDBOfflineStore,
9
+ MemoryOfflineStore,
10
+ OfflineStore,
11
+ PendingMutation,
12
+ createMutationId
13
+ } from "./offline-store";
14
+ import {
15
+ isExactlyEvaluable,
16
+ matchesParams,
17
+ runLocalQuery,
18
+ sortRows
19
+ } from "./offline-query";
20
+
21
+ /**
22
+ * The SDK's local-first sync engine.
23
+ *
24
+ * The design goal is that the network is never in the way of the interface.
25
+ * That comes from three properties, and everything in this file exists to
26
+ * serve one of them:
27
+ *
28
+ * 1. **A local database, not a response cache.** Rows are stored normalized,
29
+ * by id, and queries are answered by evaluating them
30
+ * ({@link ./offline-query}) against those rows. A row written offline
31
+ * therefore appears in *every* list it belongs to, a row edited in one view
32
+ * updates in all of them, and `findById` answers for a row only ever seen
33
+ * inside a `find`. Server responses are merged into this database rather
34
+ * than replacing it, and a row with unsynced local writes keeps them: the
35
+ * user's own change never flickers away underneath them.
36
+ *
37
+ * 2. **Writes are decided locally.** A write made while offline is applied to
38
+ * the local database and queued — with the state it replaced, so a server
39
+ * rejection can be undone — and the call returns immediately. When
40
+ * connectivity is known to be gone the request is not even attempted, so
41
+ * an offline write costs nothing instead of a timeout.
42
+ *
43
+ * 3. **Reads are reactive.** {@link OfflineManager.observe} emits from the
44
+ * local database synchronously-ish, revalidates in the background, and
45
+ * re-emits whenever anything touches the rows it covers — a local write,
46
+ * a replay landing, a rollback, a realtime event, or another browser tab.
47
+ *
48
+ * What it deliberately is not: a full replica. Only rows the app has actually
49
+ * read or written are local, so a query the cache cannot fully answer is
50
+ * flagged `partial` rather than silently reported as complete.
51
+ */
52
+
53
+ export interface OfflineConfig {
54
+ /**
55
+ * Persistence backend. Defaults to IndexedDB in the browser and an
56
+ * in-memory store elsewhere; pass a custom implementation (e.g. backed by
57
+ * AsyncStorage in React Native) to persist in other environments.
58
+ */
59
+ store?: OfflineStore;
60
+ /**
61
+ * Cached query snapshots kept per collection; the least recently written
62
+ * are evicted beyond this. Defaults to 50.
63
+ */
64
+ maxCachedQueriesPerCollection?: number;
65
+ /**
66
+ * Cached rows kept per collection. Rows with unsynced local writes are
67
+ * never evicted. Defaults to 5 000.
68
+ */
69
+ maxCachedRowsPerCollection?: number;
70
+ /**
71
+ * Ceiling for the exponential retry backoff, in milliseconds. Replay
72
+ * retries start at one second and double up to this. `0` disables
73
+ * automatic retries entirely — `client.offline.sync()`, a sign-in, and the
74
+ * browser's `online` event still trigger one. Defaults to 60 000.
75
+ */
76
+ syncIntervalMs?: number;
77
+ /**
78
+ * Keep several tabs of the same app in step over a `BroadcastChannel`: a
79
+ * write in one appears in the others, and only one of them replays the
80
+ * shared queue. Defaults to on for the IndexedDB store (a real shared
81
+ * database) and off for the in-memory one, which no other tab can see.
82
+ */
83
+ crossTab?: boolean;
84
+ /**
85
+ * How many times a mutation rejected with a *retryable* status (429, 503,
86
+ * …) is replayed before it is given up on and rolled back. Network
87
+ * failures do not count against this: being offline is not an attempt.
88
+ * Defaults to 5.
89
+ */
90
+ maxRetries?: number;
91
+ /**
92
+ * Called when the server *rejects* a queued mutation (a 4xx/5xx that will
93
+ * not resolve on its own — validation, RLS, a since-deleted row). The
94
+ * local rows it wrote are rolled back to the state they had before it, and
95
+ * any later queued writes to the same rows are discarded with it — they
96
+ * were built on a change that never happened. Each discarded mutation is
97
+ * reported here.
98
+ *
99
+ * Network failures are not errors: those mutations stay queued.
100
+ */
101
+ onSyncError?: (error: Error, mutation: PendingMutation) => void;
102
+ }
103
+
104
+ /** A snapshot of the engine's state, for a status indicator. */
105
+ export interface OfflineStatus {
106
+ /** False once a request has failed to reach the server, until one does. */
107
+ online: boolean;
108
+ /** True while the queue is being replayed. */
109
+ syncing: boolean;
110
+ /** Local writes not yet accepted by the server. */
111
+ pending: number;
112
+ /** When the queue was last fully drained. */
113
+ lastSyncedAt?: number;
114
+ /** The last replay rejection, if any. */
115
+ lastError?: string;
116
+ }
117
+
118
+ export type { LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
119
+
120
+ /** What `client.offline` exposes to the app. */
121
+ export interface OfflineApi {
122
+ /** Replay the queue now. Resolves with what was flushed and what remains. */
123
+ sync(): Promise<{ flushed: number; remaining: number }>;
124
+ /** The queued mutations for the current user, oldest first. */
125
+ pending(): Promise<PendingMutation[]>;
126
+ /** The current engine state — connectivity, queue depth, last sync. */
127
+ status(): OfflineStatus;
128
+ /** Subscribe to {@link OfflineStatus} changes (for a sync indicator). */
129
+ onStatusChange(listener: (status: OfflineStatus) => void): () => void;
130
+ /**
131
+ * Drop the current user's queued mutations AND their local rows.
132
+ * Destructive: queued writes are lost, not replayed. For "discard my
133
+ * offline changes" flows, not for sign-out (scoping already isolates
134
+ * users).
135
+ */
136
+ clear(): Promise<void>;
137
+ /** Subscribe to queue-size changes (for a "pending changes" badge). */
138
+ onQueueChange(listener: (count: number) => void): () => void;
139
+ }
140
+
141
+ /** True when a read failed because there was neither network nor local data. */
142
+ export function isOfflineError(error: unknown): boolean {
143
+ return error instanceof RebaseApiError && error.code === "offline";
144
+ }
145
+
146
+ function offlineError(message: string): RebaseApiError {
147
+ return new RebaseApiError(message, { status: 0, code: "offline" });
148
+ }
149
+
150
+ function generateOfflineId(): string {
151
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
152
+ return crypto.randomUUID();
153
+ }
154
+ // Non-cryptographic fallback for exotic runtimes; collision odds are
155
+ // irrelevant at offline-queue scale.
156
+ return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
157
+ }
158
+
159
+ type AnyRow = Record<string, unknown>;
160
+ type InnerFactory = (slug: string) => SDKCollectionClient<AnyRow>;
161
+
162
+ /** What the server said about one query, as ids into the local row database. */
163
+ interface QuerySnapshot {
164
+ ids: (string | number)[];
165
+ total: number;
166
+ limit: number;
167
+ offset: number;
168
+ hasMore: boolean;
169
+ }
170
+
171
+ interface RowEntry {
172
+ row: AnyRow;
173
+ cachedAt: number;
174
+ /** Bumped on every local change, so observers can diff cheaply. */
175
+ rev: number;
176
+ }
177
+
178
+ interface CollectionState {
179
+ rows: Map<string, RowEntry>;
180
+ snapshots: Map<string, QuerySnapshot>;
181
+ /**
182
+ * Query keys whose snapshot came from a request that completed in this
183
+ * session. Deliberately not persisted: a snapshot read back off disk is
184
+ * exactly what "from the cache" means, however recent it looks.
185
+ */
186
+ fresh: Set<string>;
187
+ /** The same, per row id, for `observeById`. */
188
+ freshRows: Set<string>;
189
+ /** Ids the server has confirmed do not exist — a negative cache. */
190
+ absent: Set<string>;
191
+ loaded?: Promise<void>;
192
+ /**
193
+ * True once the persisted rows are in memory. Observers must not emit
194
+ * before this: an empty map during the load is not an empty collection,
195
+ * and emitting it would flash an empty list over real data.
196
+ */
197
+ ready: boolean;
198
+ }
199
+
200
+ interface Observer {
201
+ slug: string;
202
+ params?: FindParams;
203
+ /** Set for observeById; then `params` is unused. */
204
+ id?: string | number;
205
+ emit: () => void;
206
+ /** Re-run this observer's query against the server. */
207
+ refresh: () => Promise<unknown>;
208
+ signature?: string;
209
+ error?: Error;
210
+ settled: boolean;
211
+ }
212
+
213
+ const MISSING = "missing";
214
+
215
+ export class OfflineManager {
216
+ private readonly store: OfflineStore;
217
+ private readonly maxCachedQueries: number;
218
+ private readonly maxCachedRows: number;
219
+ private readonly maxRetries: number;
220
+ private readonly onSyncError?: OfflineConfig["onSyncError"];
221
+ private readonly createInner: InnerFactory;
222
+ private readonly inners = new Map<string, SDKCollectionClient<AnyRow>>();
223
+ private readonly connectivity: ConnectivityMonitor;
224
+
225
+ private scope = "anon";
226
+ /** The local database: normalized rows and query snapshots per collection. */
227
+ private collections = new Map<string, CollectionState>();
228
+ /** In-memory mirror of the current scope's queue, in replay order. */
229
+ private queue: PendingMutation[] = [];
230
+ private queueLoad?: Promise<void>;
231
+ /** Serializes enqueues so concurrent writes keep the order the app made them. */
232
+ private enqueueChain: Promise<unknown> = Promise.resolve();
233
+ private flushPromise?: Promise<{ flushed: number; remaining: number }>;
234
+ private queueListeners = new Set<(count: number) => void>();
235
+ private statusListeners = new Set<(status: OfflineStatus) => void>();
236
+ private observers = new Map<string, Set<Observer>>();
237
+ private refreshPending = new Set<string>();
238
+ private revCounter = 0;
239
+ private disposed = false;
240
+ private currentStatus: OfflineStatus = { online: true, syncing: false, pending: 0 };
241
+ private readonly channel?: BroadcastChannel;
242
+ private readonly tabId = createMutationId();
243
+
244
+ readonly api: OfflineApi;
245
+
246
+ constructor(config: OfflineConfig, createInner: InnerFactory) {
247
+ this.store = config.store
248
+ ?? (typeof indexedDB !== "undefined" ? new IndexedDBOfflineStore() : new MemoryOfflineStore());
249
+ this.maxCachedQueries = config.maxCachedQueriesPerCollection ?? 50;
250
+ this.maxCachedRows = config.maxCachedRowsPerCollection ?? 5_000;
251
+ this.maxRetries = config.maxRetries ?? 5;
252
+ this.onSyncError = config.onSyncError;
253
+ this.createInner = createInner;
254
+
255
+ const maxBackoffMs = config.syncIntervalMs ?? 60_000;
256
+ this.connectivity = new ConnectivityMonitor({
257
+ maxBackoffMs: Math.max(1_000, maxBackoffMs),
258
+ // With no retry timer nothing would ever reopen the window, so a
259
+ // single failure would strand the client offline forever.
260
+ respectBackoff: maxBackoffMs > 0
261
+ });
262
+ if (maxBackoffMs > 0) {
263
+ this.connectivity.onRetryDue = () => { void this.sync().catch(() => undefined); };
264
+ }
265
+ this.connectivity.onChange((online) => {
266
+ this.patchStatus({ online });
267
+ if (online) this.revalidateAll();
268
+ });
269
+ this.currentStatus.online = this.connectivity.isOnline();
270
+
271
+ // Other tabs share the same IndexedDB. Without this they would each
272
+ // hold a stale copy of the row database and quietly diverge — one tab
273
+ // showing an edit the other never learns about. A memory store is not
274
+ // shared with anyone, so there is nothing to reconcile and the channel
275
+ // would only relay writes between unrelated clients.
276
+ const crossTab = config.crossTab ?? this.store instanceof IndexedDBOfflineStore;
277
+ if (crossTab && typeof BroadcastChannel !== "undefined") {
278
+ try {
279
+ this.channel = new BroadcastChannel("rebase-offline");
280
+ this.channel.onmessage = (event: MessageEvent) => this.onBroadcast(event.data);
281
+ // Node's BroadcastChannel is ref'd, and a script that opened a
282
+ // client should still be able to exit.
283
+ (this.channel as unknown as { unref?: () => void }).unref?.();
284
+ } catch {
285
+ // Not fatal: a browser that refuses the channel just loses
286
+ // cross-tab propagation.
287
+ }
288
+ }
289
+
290
+ this.api = {
291
+ sync: () => this.sync(),
292
+ pending: async () => {
293
+ await this.ensureQueueLoaded();
294
+ // Deep-copied: these are live queue entries (tail coalescing
295
+ // mutates them in place), and a caller must not be able to
296
+ // edit what will be replayed.
297
+ return this.queue.map((m) => structuredClone(m));
298
+ },
299
+ status: () => ({ ...this.currentStatus }),
300
+ onStatusChange: (listener) => {
301
+ this.statusListeners.add(listener);
302
+ return () => this.statusListeners.delete(listener);
303
+ },
304
+ clear: async () => {
305
+ await this.store.clear(`${this.scope}|`);
306
+ this.queue = [];
307
+ this.resetCollections();
308
+ this.patchStatus({ pending: 0, lastError: undefined });
309
+ this.notifyQueue();
310
+ for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
311
+ },
312
+ onQueueChange: (listener) => {
313
+ this.queueListeners.add(listener);
314
+ return () => this.queueListeners.delete(listener);
315
+ }
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Cache and queue are partitioned per signed-in user: cached rows are
321
+ * RLS-filtered for the user who fetched them, and queued writes must
322
+ * replay under the credentials that made them — so neither may ever leak
323
+ * across a sign-out/sign-in on a shared browser.
324
+ */
325
+ setScope(uid: string | undefined): void {
326
+ const next = uid || "anon";
327
+ if (next === this.scope) return;
328
+ this.scope = next;
329
+ this.queueLoad = undefined;
330
+ this.queue = [];
331
+ this.resetCollections();
332
+ this.patchStatus({ pending: 0, lastError: undefined });
333
+ this.notifyQueue();
334
+ // Everything on screen belongs to the previous user.
335
+ for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
336
+ this.revalidateAll();
337
+ // The returning user's queue may hold writes from a previous session.
338
+ void this.sync().catch(() => undefined);
339
+ }
340
+
341
+ /**
342
+ * Throw away every local row, for a scope change or an explicit clear.
343
+ *
344
+ * The state objects are replaced rather than emptied, so a load still in
345
+ * flight for the previous user fails its identity check and discards what
346
+ * it read instead of grafting it onto the new one. The replacements are
347
+ * marked ready: nothing needs loading until something asks, and observers
348
+ * have to be told *now* that the rows they are showing are gone.
349
+ */
350
+ private resetCollections(): void {
351
+ const slugs = [...this.collections.keys()];
352
+ this.collections = new Map();
353
+ for (const slug of slugs) {
354
+ this.collections.set(slug, {
355
+ rows: new Map(),
356
+ snapshots: new Map(),
357
+ fresh: new Set(),
358
+ freshRows: new Set(),
359
+ absent: new Set(),
360
+ ready: true
361
+ });
362
+ }
363
+ }
364
+
365
+ /** Release listeners, timers and the cross-tab channel (client.close()). */
366
+ dispose(): void {
367
+ this.disposed = true;
368
+ this.connectivity.dispose();
369
+ try {
370
+ this.channel?.close();
371
+ } catch {
372
+ // A channel that is already closed is not a problem.
373
+ }
374
+ this.observers.clear();
375
+ this.queueListeners.clear();
376
+ this.statusListeners.clear();
377
+ }
378
+
379
+ // ─── Collection wrapping ─────────────────────────────────────────────────
380
+
381
+ wrap<M extends AnyRow>(slug: string, inner: CollectionClient<M>): CollectionClient<M> {
382
+ this.inners.set(slug, inner as SDKCollectionClient<AnyRow>);
383
+
384
+ const wrapped: CollectionClient<M> = {
385
+ find: async (params?: FindParams): Promise<FindResult<M>> => {
386
+ const state = await this.ensureCollection(slug);
387
+ if (this.connectivity.shouldAttempt()) {
388
+ try {
389
+ const res = await inner.find(params);
390
+ this.connectivity.markSuccess();
391
+ await this.ingest(slug, res.data ?? []);
392
+ const snapshot = this.recordSnapshot(slug, params, res);
393
+ const answer = this.answer<M>(slug, params, snapshot);
394
+ this.notifyCollection(slug, false);
395
+ return { data: answer.data, meta: answer.meta };
396
+ } catch (error) {
397
+ if (!isNetworkError(error)) {
398
+ // A 5xx or a rate limit still deserves the cached
399
+ // answer rather than an exception the app has to
400
+ // special-case, but only when we have one.
401
+ if (isRetryableError(error) && this.hasLocalAnswer(state, slug, params)) {
402
+ const answer = this.answer<M>(slug, params, this.snapshotFor(slug, params));
403
+ return { data: answer.data, meta: answer.meta };
404
+ }
405
+ throw error;
406
+ }
407
+ this.connectivity.markFailure();
408
+ }
409
+ }
410
+ const answer = this.localFind<M>(slug, params);
411
+ // Falling back is a state change even when the rows are the
412
+ // same — it is how a "showing cached data" badge lights up.
413
+ this.notifyCollection(slug, false);
414
+ return { data: answer.data, meta: answer.meta };
415
+ },
416
+
417
+ findById: async (id: string | number) => {
418
+ await this.ensureCollection(slug);
419
+ if (this.connectivity.shouldAttempt()) {
420
+ try {
421
+ const row = await inner.findById(id);
422
+ this.connectivity.markSuccess();
423
+ if (row !== undefined) {
424
+ await this.ingest(slug, [row]);
425
+ } else if (!this.hasPending(slug, id)) {
426
+ // The server is authoritative that it is gone, and
427
+ // nothing local is waiting to recreate it.
428
+ this.removeLocalRow(slug, id, true);
429
+ }
430
+ this.notifyCollection(slug, false);
431
+ return this.localRow<M>(slug, id);
432
+ } catch (error) {
433
+ if (!isNetworkError(error)) throw error;
434
+ this.connectivity.markFailure();
435
+ }
436
+ }
437
+ const local = this.localRow<M>(slug, id);
438
+ if (local !== undefined || this.hasPending(slug, id)) return local;
439
+ // "Not there" is an answer, and one we may already have.
440
+ if (this.collections.get(slug)?.absent.has(String(id))) return undefined;
441
+ throw offlineError(
442
+ `Offline: "${slug}" row ${String(id)} is not in the local database.`
443
+ );
444
+ },
445
+
446
+ create: async (data: Partial<M>, id?: string | number) => {
447
+ await this.ensureCollection(slug);
448
+ if (this.connectivity.shouldAttempt()) {
449
+ try {
450
+ const row = await inner.create(data, id);
451
+ this.connectivity.markSuccess();
452
+ await this.ingest(slug, [row]);
453
+ this.notifyCollection(slug);
454
+ this.scheduleRefresh(slug);
455
+ return row;
456
+ } catch (error) {
457
+ if (!isNetworkError(error)) throw error;
458
+ this.connectivity.markFailure();
459
+ }
460
+ }
461
+ const providedId = id ?? (data as AnyRow).id as string | number | undefined;
462
+ const rowId = providedId ?? generateOfflineId();
463
+ const row = { ...(data as AnyRow), id: rowId } as unknown as M;
464
+ await this.enqueue({
465
+ collection: slug,
466
+ type: "create",
467
+ id: rowId,
468
+ data: row,
469
+ generatedId: providedId === undefined,
470
+ rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
471
+ });
472
+ this.setLocalRow(slug, rowId, row);
473
+ this.notifyCollection(slug);
474
+ return row;
475
+ },
476
+
477
+ createMany: async (data: Partial<M>[], options?: { upsert?: boolean }) => {
478
+ await this.ensureCollection(slug);
479
+ if (!Array.isArray(data)) {
480
+ throw new TypeError("createMany expects an array of records.");
481
+ }
482
+ if (data.length === 0) return [];
483
+ if (this.connectivity.shouldAttempt()) {
484
+ try {
485
+ const rows = await inner.createMany(data, options);
486
+ this.connectivity.markSuccess();
487
+ await this.ingest(slug, rows);
488
+ this.notifyCollection(slug);
489
+ this.scheduleRefresh(slug);
490
+ return rows;
491
+ } catch (error) {
492
+ if (!isNetworkError(error)) throw error;
493
+ this.connectivity.markFailure();
494
+ }
495
+ }
496
+ const rows = data.map((r) => ({
497
+ ...(r as AnyRow),
498
+ id: (r as AnyRow).id ?? generateOfflineId()
499
+ })) as unknown as M[];
500
+ const rollback: Record<string, AnyRow | null> = {};
501
+ for (const row of rows) {
502
+ const key = String(row.id);
503
+ rollback[key] = this.rawLocalRow(slug, row.id as string | number) ?? null;
504
+ }
505
+ await this.enqueue({
506
+ collection: slug,
507
+ type: "createMany",
508
+ data: rows,
509
+ upsert: options?.upsert,
510
+ rollback: { rows: rollback }
511
+ });
512
+ for (const row of rows) this.setLocalRow(slug, row.id as string | number, row);
513
+ this.notifyCollection(slug);
514
+ return rows;
515
+ },
516
+
517
+ update: async (id: string | number, data: Partial<M>) => {
518
+ await this.ensureCollection(slug);
519
+ if (this.connectivity.shouldAttempt()) {
520
+ try {
521
+ const row = await inner.update(id, data);
522
+ this.connectivity.markSuccess();
523
+ await this.ingest(slug, [row]);
524
+ this.notifyCollection(slug);
525
+ return row;
526
+ } catch (error) {
527
+ if (!isNetworkError(error)) throw error;
528
+ this.connectivity.markFailure();
529
+ }
530
+ }
531
+ const base = this.rawLocalRow(slug, id);
532
+ await this.enqueue({
533
+ collection: slug,
534
+ type: "update",
535
+ id,
536
+ data: data as AnyRow,
537
+ rollback: { rows: { [String(id)]: base ?? null } }
538
+ });
539
+ const optimistic = { ...(base ?? {}), ...(data as AnyRow), id } as unknown as M;
540
+ this.setLocalRow(slug, id, optimistic);
541
+ this.notifyCollection(slug);
542
+ return optimistic;
543
+ },
544
+
545
+ delete: async (id: string | number) => {
546
+ await this.ensureCollection(slug);
547
+ if (this.connectivity.shouldAttempt()) {
548
+ try {
549
+ await inner.delete(id);
550
+ this.connectivity.markSuccess();
551
+ this.removeLocalRow(slug, id, true);
552
+ this.notifyCollection(slug);
553
+ this.scheduleRefresh(slug);
554
+ return;
555
+ } catch (error) {
556
+ if (!isNetworkError(error)) throw error;
557
+ this.connectivity.markFailure();
558
+ }
559
+ }
560
+ await this.enqueue({
561
+ collection: slug,
562
+ type: "delete",
563
+ id,
564
+ rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
565
+ });
566
+ this.removeLocalRow(slug, id);
567
+ this.notifyCollection(slug);
568
+ },
569
+
570
+ count: async (params?: FindParams): Promise<number> => {
571
+ await this.ensureCollection(slug);
572
+ if (this.connectivity.shouldAttempt()) {
573
+ try {
574
+ const n = await inner.count(params);
575
+ this.connectivity.markSuccess();
576
+ void this.writeCache(this.countKey(slug, params), n);
577
+ return Math.max(0, n + this.pendingDelta(slug, params));
578
+ } catch (error) {
579
+ if (!isNetworkError(error)) throw error;
580
+ this.connectivity.markFailure();
581
+ }
582
+ }
583
+ const cached = await this.readCache<number>(this.countKey(slug, params));
584
+ if (cached !== undefined) return Math.max(0, cached + this.pendingDelta(slug, params));
585
+ const state = this.collections.get(slug);
586
+ if (state && state.rows.size > 0) {
587
+ return runLocalQuery([...state.rows.values()].map((e) => e.row), params).meta.total;
588
+ }
589
+ throw offlineError(`Offline: no cached count for "${slug}".`);
590
+ },
591
+
592
+ observe: (
593
+ params: FindParams | undefined,
594
+ onResult: (result: LiveResult<M>) => void,
595
+ onError?: (error: Error) => void,
596
+ options?: ObserveOptions
597
+ ) => this.observe<M>(slug, wrapped, inner, params, onResult, onError, options),
598
+
599
+ observeById: (
600
+ id: string | number,
601
+ onResult: (row: M | undefined, meta: RowSnapshotMeta) => void,
602
+ onError?: (error: Error) => void,
603
+ options?: ObserveOptions
604
+ ) => this.observeById<M>(slug, wrapped, inner, id, onResult, onError, options),
605
+
606
+ // The builder calls back into `wrapped.find(...)`, so fluent
607
+ // queries go through the local database like direct calls.
608
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
609
+ const builder = new SDKQueryBuilder<M>(wrapped);
610
+ if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
611
+ return builder.where(
612
+ columnOrCondition as keyof M & string,
613
+ operator!,
614
+ value as WhereValue<M[keyof M & string]>
615
+ );
616
+ },
617
+ orderBy: (column, direction) => new SDKQueryBuilder<M>(wrapped).orderBy(column, direction),
618
+ limit: (count) => new SDKQueryBuilder<M>(wrapped).limit(count),
619
+ offset: (count) => new SDKQueryBuilder<M>(wrapped).offset(count),
620
+ search: (searchString) => new SDKQueryBuilder<M>(wrapped).search(searchString),
621
+ include: (...relations) => new SDKQueryBuilder<M>(wrapped).include(...relations)
622
+ };
623
+
624
+ // Realtime stays a live server stream — but everything it delivers is
625
+ // worth keeping, so it feeds the local database on its way past.
626
+ if (inner.listen) {
627
+ wrapped.listen = (params, onUpdate, onError) => inner.listen!(
628
+ params,
629
+ (response) => {
630
+ void this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
631
+ onUpdate(response);
632
+ },
633
+ onError
634
+ );
635
+ }
636
+ if (inner.listenById) {
637
+ wrapped.listenById = (id, onUpdate, onError) => inner.listenById!(
638
+ id,
639
+ (row) => {
640
+ if (row) void this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
641
+ onUpdate(row);
642
+ },
643
+ onError
644
+ );
645
+ }
646
+
647
+ return wrapped;
648
+ }
649
+
650
+ // ─── Live queries ────────────────────────────────────────────────────────
651
+
652
+ private observe<M extends AnyRow>(
653
+ slug: string,
654
+ wrapped: CollectionClient<M>,
655
+ inner: CollectionClient<M>,
656
+ params: FindParams | undefined,
657
+ onResult: (result: LiveResult<M>) => void,
658
+ onError?: (error: Error) => void,
659
+ options?: ObserveOptions
660
+ ): () => void {
661
+ let closed = false;
662
+ let unlisten: (() => void) | undefined;
663
+
664
+ const observer: Observer = {
665
+ slug,
666
+ params,
667
+ settled: false,
668
+ refresh: () => wrapped.find(params).catch(() => undefined),
669
+ emit: () => {
670
+ if (closed || !this.collections.get(slug)?.ready) return;
671
+ const result = this.answer<M>(slug, params, this.snapshotFor(slug, params));
672
+ // Every field the callback receives has to be in the
673
+ // signature, or a change to one of them is deduplicated away —
674
+ // a row settling from "saving" to saved is exactly that.
675
+ const signature = `${result.fromCache ? "c" : "s"}${result.hasPendingWrites ? "p" : "-"}`
676
+ + this.signature(slug, result.data, result.meta.total);
677
+ if (observer.settled && signature === observer.signature) return;
678
+ observer.signature = signature;
679
+ observer.settled = true;
680
+ onResult(observer.error ? { ...result, error: observer.error } : result);
681
+ }
682
+ };
683
+ this.observersFor(slug).add(observer);
684
+
685
+ void (async () => {
686
+ await this.ensureCollection(slug);
687
+ if (closed) return;
688
+ // Emit whatever is already local before touching the network. An
689
+ // app that has run this query before renders instantly.
690
+ if (this.hasLocalAnswer(this.collections.get(slug), slug, params)) observer.emit();
691
+ try {
692
+ await wrapped.find(params);
693
+ observer.error = undefined;
694
+ } catch (error) {
695
+ observer.error = error as Error;
696
+ if (closed) return;
697
+ // A read that found nothing locally has nothing to emit, so the
698
+ // failure is all the app gets.
699
+ if (!observer.settled) {
700
+ onError?.(error as Error);
701
+ return;
702
+ }
703
+ }
704
+ if (!closed) observer.emit();
705
+ })();
706
+
707
+ if (options?.realtime !== false && inner.listen) {
708
+ unlisten = inner.listen(params, (response) => {
709
+ void this.ingest(slug, response.data ?? []).then(() => {
710
+ this.recordSnapshot(slug, params, response);
711
+ this.notifyCollection(slug, false);
712
+ });
713
+ }, onError);
714
+ }
715
+
716
+ return () => {
717
+ closed = true;
718
+ this.observersFor(slug).delete(observer);
719
+ unlisten?.();
720
+ };
721
+ }
722
+
723
+ private observeById<M extends AnyRow>(
724
+ slug: string,
725
+ wrapped: CollectionClient<M>,
726
+ inner: CollectionClient<M>,
727
+ id: string | number,
728
+ onResult: (row: M | undefined, meta: RowSnapshotMeta) => void,
729
+ onError?: (error: Error) => void,
730
+ options?: ObserveOptions
731
+ ): () => void {
732
+ let closed = false;
733
+ let unlisten: (() => void) | undefined;
734
+ const observer: Observer = {
735
+ slug,
736
+ id,
737
+ settled: false,
738
+ refresh: () => wrapped.findById(id).catch(() => undefined),
739
+ emit: () => {
740
+ if (closed || !this.collections.get(slug)?.ready) return;
741
+ const row = this.localRow<M>(slug, id);
742
+ const entry = this.collections.get(slug)?.rows.get(String(id));
743
+ const fromCache = !this.collections.get(slug)?.freshRows.has(String(id));
744
+ const hasPendingWrites = this.hasPending(slug, id);
745
+ const signature = `${fromCache ? "c" : "s"}${hasPendingWrites ? "p" : "-"}|`
746
+ + (row === undefined ? MISSING : `${String(id)}:${entry?.rev ?? 0}`);
747
+ if (observer.settled && signature === observer.signature) return;
748
+ observer.signature = signature;
749
+ observer.settled = true;
750
+ onResult(row, { fromCache, hasPendingWrites });
751
+ }
752
+ };
753
+ this.observersFor(slug).add(observer);
754
+
755
+ void (async () => {
756
+ await this.ensureCollection(slug);
757
+ if (closed) return;
758
+ if (this.localRow<M>(slug, id) !== undefined) observer.emit();
759
+ try {
760
+ await wrapped.findById(id);
761
+ } catch (error) {
762
+ if (closed) return;
763
+ if (!observer.settled) {
764
+ onError?.(error as Error);
765
+ return;
766
+ }
767
+ }
768
+ if (!closed) observer.emit();
769
+ })();
770
+
771
+ if (options?.realtime !== false && inner.listenById) {
772
+ unlisten = inner.listenById(id, (row) => {
773
+ if (!row) {
774
+ if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
775
+ this.notifyCollection(slug, false);
776
+ return;
777
+ }
778
+ void this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
779
+ }, onError);
780
+ }
781
+
782
+ return () => {
783
+ closed = true;
784
+ this.observersFor(slug).delete(observer);
785
+ unlisten?.();
786
+ };
787
+ }
788
+
789
+ private observersFor(slug: string): Set<Observer> {
790
+ let set = this.observers.get(slug);
791
+ if (!set) {
792
+ set = new Set();
793
+ this.observers.set(slug, set);
794
+ }
795
+ return set;
796
+ }
797
+
798
+ /** Cheap change detection: which rows, in what order, at which revision. */
799
+ private signature(slug: string, rows: AnyRow[], total: number): string {
800
+ const state = this.collections.get(slug);
801
+ const parts = rows.map((row) => {
802
+ const key = String(row.id);
803
+ return `${key}:${state?.rows.get(key)?.rev ?? 0}`;
804
+ });
805
+ return `${total}|${parts.join(",")}`;
806
+ }
807
+
808
+ private notifyCollection(slug: string, broadcast = true): void {
809
+ const set = this.observers.get(slug);
810
+ if (set) for (const observer of [...set]) observer.emit();
811
+ if (broadcast) this.broadcast({ type: "rows", slugs: [slug] });
812
+ }
813
+
814
+ /** Connectivity came back (or the user changed): re-read everything live. */
815
+ private revalidateAll(): void {
816
+ for (const slug of this.observers.keys()) {
817
+ this.notifyCollection(slug, false);
818
+ this.scheduleRefresh(slug);
819
+ }
820
+ }
821
+
822
+ // ─── Reading the local database ──────────────────────────────────────────
823
+
824
+ private collectionState(slug: string): CollectionState {
825
+ let state = this.collections.get(slug);
826
+ if (!state) {
827
+ state = {
828
+ rows: new Map(),
829
+ snapshots: new Map(),
830
+ fresh: new Set(),
831
+ freshRows: new Set(),
832
+ absent: new Set(),
833
+ ready: false
834
+ };
835
+ this.collections.set(slug, state);
836
+ }
837
+ return state;
838
+ }
839
+
840
+ private ensureCollection(slug: string): Promise<CollectionState> {
841
+ const state = this.collectionState(slug);
842
+ if (!state.loaded) {
843
+ const scope = this.scope;
844
+ state.loaded = (async () => {
845
+ await this.ensureQueueLoaded();
846
+ const [rows, snapshots, absent] = await Promise.all([
847
+ this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
848
+ this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
849
+ this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
850
+ ]);
851
+ // A scope switch mid-load must not graft the previous user's
852
+ // rows onto the new one.
853
+ if (this.scope !== scope || this.collections.get(slug) !== state) return;
854
+ for (const entry of rows) {
855
+ const row = entry.value as AnyRow | undefined;
856
+ if (!row || row.id === undefined || row.id === null) continue;
857
+ state.rows.set(String(row.id), {
858
+ row: hydrateRow(row),
859
+ cachedAt: entry.cachedAt,
860
+ rev: ++this.revCounter
861
+ });
862
+ }
863
+ for (const entry of snapshots) {
864
+ const key = entry.key.slice(`${scope}|q|${slug}|`.length);
865
+ if (entry.value) state.snapshots.set(key, entry.value as QuerySnapshot);
866
+ }
867
+ for (const entry of absent) {
868
+ state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
869
+ }
870
+ })().catch(() => undefined).finally(() => { state.ready = true; });
871
+ }
872
+ return state.loaded.then(() => state);
873
+ }
874
+
875
+ private snapshotFor(slug: string, params?: FindParams): QuerySnapshot | undefined {
876
+ return this.collections.get(slug)?.snapshots.get(buildQueryString(params));
877
+ }
878
+
879
+ private hasLocalAnswer(state: CollectionState | undefined, slug: string, params?: FindParams): boolean {
880
+ if (!state) return false;
881
+ return state.snapshots.has(buildQueryString(params)) || state.rows.size > 0;
882
+ }
883
+
884
+ /**
885
+ * Answer a query from the local database.
886
+ *
887
+ * With a snapshot, the server's own page — its ids, order and total — is
888
+ * the skeleton, and the local rows fill it in: rows deleted locally drop
889
+ * out, rows edited locally show the edit, and rows *created* locally join
890
+ * the first page if they match. Without one, the query is evaluated
891
+ * outright over every cached row, which is the best that can be done for a
892
+ * query the server has never answered here.
893
+ */
894
+ private answer<M extends AnyRow>(
895
+ slug: string,
896
+ params: FindParams | undefined,
897
+ snapshot: QuerySnapshot | undefined
898
+ ): LiveResult<M> {
899
+ const state = this.collections.get(slug);
900
+ const exact = isExactlyEvaluable(params);
901
+ const fromCache = !state?.fresh.has(buildQueryString(params));
902
+ if (!state) {
903
+ return {
904
+ data: [],
905
+ meta: { total: 0, limit: params?.limit ?? 20, offset: params?.offset ?? 0, hasMore: false },
906
+ fromCache: true,
907
+ hasPendingWrites: false,
908
+ partial: true
909
+ };
910
+ }
911
+
912
+ if (!snapshot) {
913
+ const local = runLocalQuery<M>([...state.rows.values()].map((e) => e.row) as M[], params);
914
+ return {
915
+ ...local,
916
+ fromCache,
917
+ hasPendingWrites: local.data.some((row) => this.hasPending(slug, row.id as string | number)),
918
+ partial: true
919
+ };
920
+ }
921
+
922
+ const rows: M[] = [];
923
+ const seen = new Set<string>();
924
+ /** Rows the server counted that we know are no longer in the result. */
925
+ let removed = 0;
926
+ for (const id of snapshot.ids) {
927
+ const key = String(id);
928
+ const entry = state.rows.get(key);
929
+ if (!entry) {
930
+ // Gone for a reason (deleted here, or confirmed gone by the
931
+ // server) versus merely evicted to stay under the cache cap:
932
+ // only the former should move the total the server gave us.
933
+ if (state.absent.has(key) || this.hasPending(slug, key)) removed++;
934
+ continue;
935
+ }
936
+ // A local edit that moves a row out of its own filter should take
937
+ // it off the list, exactly as a refetch would.
938
+ if (exact && this.hasPending(slug, key) && !matchesParams(entry.row, params)) {
939
+ removed++;
940
+ continue;
941
+ }
942
+ rows.push(entry.row as M);
943
+ seen.add(key);
944
+ }
945
+
946
+ // Rows the server has never seen belong on the first page of a
947
+ // matching query. Injecting them into *every* page would show the same
948
+ // new row once per page.
949
+ let added = 0;
950
+ const offset = snapshot.offset ?? 0;
951
+ if (exact && offset === 0) {
952
+ for (const [key, entry] of state.rows) {
953
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
954
+ if (!this.isLocallyCreated(slug, key)) continue;
955
+ if (!matchesParams(entry.row, params)) continue;
956
+ rows.push(entry.row as M);
957
+ added++;
958
+ }
959
+ if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
960
+ }
961
+
962
+ const total = Math.max(rows.length, snapshot.total - removed + added);
963
+ return {
964
+ data: rows,
965
+ meta: {
966
+ total,
967
+ limit: snapshot.limit,
968
+ offset,
969
+ hasMore: snapshot.hasMore
970
+ },
971
+ fromCache,
972
+ hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id as string | number)),
973
+ partial: !exact
974
+ };
975
+ }
976
+
977
+ private localFind<M extends AnyRow>(slug: string, params?: FindParams): LiveResult<M> {
978
+ const state = this.collections.get(slug);
979
+ const snapshot = this.snapshotFor(slug, params);
980
+ // However recent it looks, this answer did not come from the server.
981
+ state?.fresh.delete(buildQueryString(params));
982
+ if (!snapshot && (!state || state.rows.size === 0)) {
983
+ throw offlineError(`Offline: no cached data for "${slug}".`);
984
+ }
985
+ const answer = this.answer<M>(slug, params, snapshot);
986
+ return snapshot ? answer : { ...answer, partial: true };
987
+ }
988
+
989
+ private rawLocalRow(slug: string, id: string | number): AnyRow | undefined {
990
+ const entry = this.collections.get(slug)?.rows.get(String(id));
991
+ return entry ? { ...entry.row } : undefined;
992
+ }
993
+
994
+ private localRow<M extends AnyRow>(slug: string, id: string | number): M | undefined {
995
+ return this.collections.get(slug)?.rows.get(String(id))?.row as M | undefined;
996
+ }
997
+
998
+ // ─── Writing the local database ──────────────────────────────────────────
999
+
1000
+ private setLocalRow(slug: string, id: string | number, row: AnyRow): void {
1001
+ const state = this.collectionState(slug);
1002
+ const key = String(id);
1003
+ const cachedAt = Date.now();
1004
+ state.rows.set(key, { row: { ...row }, cachedAt, rev: ++this.revCounter });
1005
+ state.freshRows.delete(key);
1006
+ this.forgetTombstone(slug, key);
1007
+ void this.writeCache(this.rowKey(slug, key), dehydrateRow(row), cachedAt);
1008
+ this.evictRows(slug);
1009
+ }
1010
+
1011
+ /**
1012
+ * Drop a row and, when the server is the one saying it is gone, remember
1013
+ * that. "I looked it up and it does not exist" is real knowledge: without
1014
+ * it, opening a deleted row while offline would report a missing local
1015
+ * database instead of a missing row.
1016
+ */
1017
+ private removeLocalRow(slug: string, id: string | number, known = false): void {
1018
+ const state = this.collectionState(slug);
1019
+ const key = String(id);
1020
+ const existed = state.rows.delete(key);
1021
+ if (known) {
1022
+ state.absent.add(key);
1023
+ state.freshRows.add(key);
1024
+ void this.writeCache(this.absentKey(slug, key), true);
1025
+ } else {
1026
+ state.freshRows.delete(key);
1027
+ }
1028
+ if (existed) void this.deleteCache([this.rowKey(slug, key)]);
1029
+ }
1030
+
1031
+ private forgetTombstone(slug: string, key: string): void {
1032
+ const state = this.collectionState(slug);
1033
+ if (!state.absent.delete(key)) return;
1034
+ void this.deleteCache([this.absentKey(slug, key)]);
1035
+ }
1036
+
1037
+ /**
1038
+ * Merge server rows into the local database. A row with unsynced local
1039
+ * writes keeps them: the server's copy is the base the queued mutations
1040
+ * are re-applied to, not a replacement for what the user did.
1041
+ *
1042
+ * Rows that came back unchanged keep their identity and revision, so a
1043
+ * refetch that changed nothing does not re-render every live query that
1044
+ * touches them — or rewrite them all to disk.
1045
+ */
1046
+ private async ingest(slug: string, rows: AnyRow[]): Promise<void> {
1047
+ if (rows.length === 0) return;
1048
+ const state = await this.ensureCollection(slug);
1049
+ const cachedAt = Date.now();
1050
+ const writes: { key: string; entry: { value: unknown; cachedAt: number } }[] = [];
1051
+ const deletes: string[] = [];
1052
+ for (const raw of rows) {
1053
+ if (!raw || raw.id === undefined || raw.id === null) continue;
1054
+ const key = String(raw.id);
1055
+ const merged = this.hasPending(slug, key)
1056
+ ? this.applyPendingToRow(slug, key, { ...raw })
1057
+ : { ...raw };
1058
+ if (merged === undefined) {
1059
+ // A queued delete says this row is gone; do not resurrect it.
1060
+ state.rows.delete(key);
1061
+ deletes.push(this.rowKey(slug, key));
1062
+ continue;
1063
+ }
1064
+ this.forgetTombstone(slug, key);
1065
+ state.freshRows.add(key);
1066
+ const existing = state.rows.get(key);
1067
+ if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
1068
+ existing.cachedAt = cachedAt;
1069
+ continue;
1070
+ }
1071
+ state.rows.set(key, { row: merged, cachedAt, rev: ++this.revCounter });
1072
+ writes.push({ key: this.rowKey(slug, key), entry: { value: dehydrateRow(merged), cachedAt } });
1073
+ }
1074
+ if (writes.length > 0) void this.store.setCacheMany(writes).catch(() => undefined);
1075
+ if (deletes.length > 0) void this.deleteCache(deletes);
1076
+ this.evictRows(slug);
1077
+ }
1078
+
1079
+ /**
1080
+ * Fold the queued mutations for one row over a base, newest last.
1081
+ * `afterMutationId` skips everything up to and including that mutation,
1082
+ * which is how a just-replayed write avoids being applied on top of the
1083
+ * server's response to it.
1084
+ */
1085
+ private applyPendingToRow(
1086
+ slug: string,
1087
+ idKey: string,
1088
+ base: AnyRow | undefined,
1089
+ afterMutationId?: string
1090
+ ): AnyRow | undefined {
1091
+ let row = base;
1092
+ let skipping = afterMutationId !== undefined;
1093
+ for (const op of this.queue) {
1094
+ if (skipping) {
1095
+ if (op.mutationId === afterMutationId) skipping = false;
1096
+ continue;
1097
+ }
1098
+ if (op.collection !== slug) continue;
1099
+ if (op.type === "createMany") {
1100
+ const match = (op.data as AnyRow[] | undefined)?.find((r) => String(r.id) === idKey);
1101
+ if (match) row = { ...match };
1102
+ continue;
1103
+ }
1104
+ if (op.id === undefined || String(op.id) !== idKey) continue;
1105
+ if (op.type === "create") row = { ...(op.data as AnyRow) };
1106
+ else if (op.type === "update") row = { ...(row ?? {}), ...(op.data as AnyRow), id: op.id };
1107
+ else if (op.type === "delete") row = undefined;
1108
+ }
1109
+ return row;
1110
+ }
1111
+
1112
+ private recordSnapshot(slug: string, params: FindParams | undefined, result: FindResult<AnyRow>): QuerySnapshot {
1113
+ const meta = result.meta ?? { total: result.data?.length ?? 0, limit: 20, offset: 0, hasMore: false };
1114
+ const snapshot: QuerySnapshot = {
1115
+ ids: (result.data ?? []).map((row) => row.id as string | number).filter((id) => id !== undefined),
1116
+ total: meta.total ?? result.data?.length ?? 0,
1117
+ limit: meta.limit ?? params?.limit ?? 20,
1118
+ offset: meta.offset ?? params?.offset ?? 0,
1119
+ hasMore: meta.hasMore ?? false
1120
+ };
1121
+ const state = this.collectionState(slug);
1122
+ const key = buildQueryString(params);
1123
+ state.snapshots.set(key, snapshot);
1124
+ state.fresh.add(key);
1125
+ void this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot);
1126
+ this.evictSnapshots(slug);
1127
+ return snapshot;
1128
+ }
1129
+
1130
+ /**
1131
+ * A write changed which rows belong in a list, and only the server can say
1132
+ * how — a row it generated is in no cached page, and the totals moved.
1133
+ * Re-run every live query on the collection; queries nobody is watching
1134
+ * are corrected by their next `find`.
1135
+ *
1136
+ * Coalesced per microtask so a burst of writes costs one round trip, and
1137
+ * skipped entirely while offline, where the local database is already the
1138
+ * best answer available.
1139
+ */
1140
+ private scheduleRefresh(slug: string): void {
1141
+ if (this.refreshPending.has(slug)) return;
1142
+ const observers = this.observers.get(slug);
1143
+ if (!observers || observers.size === 0) return;
1144
+ this.refreshPending.add(slug);
1145
+ void Promise.resolve().then(() => {
1146
+ this.refreshPending.delete(slug);
1147
+ if (this.disposed || !this.connectivity.shouldAttempt()) return;
1148
+ for (const observer of [...(this.observers.get(slug) ?? [])]) void observer.refresh();
1149
+ });
1150
+ }
1151
+
1152
+ private evictRows(slug: string): void {
1153
+ const state = this.collections.get(slug);
1154
+ if (!state || state.rows.size <= this.maxCachedRows) return;
1155
+ const evictable = [...state.rows.entries()]
1156
+ .filter(([key]) => !this.hasPending(slug, key))
1157
+ .sort((a, b) => a[1].cachedAt - b[1].cachedAt);
1158
+ const excess = state.rows.size - this.maxCachedRows;
1159
+ const doomed = evictable.slice(0, excess);
1160
+ for (const [key] of doomed) state.rows.delete(key);
1161
+ if (doomed.length > 0) void this.deleteCache(doomed.map(([key]) => this.rowKey(slug, key)));
1162
+
1163
+ // Tombstones are tiny but unbounded — every row the app ever asked for
1164
+ // and did not find leaves one. Cap them against the same budget.
1165
+ if (state.absent.size > this.maxCachedRows) {
1166
+ const stale = [...state.absent].slice(0, state.absent.size - this.maxCachedRows);
1167
+ for (const key of stale) state.absent.delete(key);
1168
+ void this.deleteCache(stale.map((key) => this.absentKey(slug, key)));
1169
+ }
1170
+ }
1171
+
1172
+ private evictSnapshots(slug: string): void {
1173
+ const state = this.collections.get(slug);
1174
+ if (!state || state.snapshots.size <= this.maxCachedQueries) return;
1175
+ // Insertion order is recency order for a Map that re-sets on write.
1176
+ const excess = state.snapshots.size - this.maxCachedQueries;
1177
+ const doomed = [...state.snapshots.keys()].slice(0, excess);
1178
+ for (const key of doomed) state.snapshots.delete(key);
1179
+ void this.deleteCache(doomed.map((key) => `${this.scope}|q|${slug}|${key}`));
1180
+ }
1181
+
1182
+ // ─── Queue ───────────────────────────────────────────────────────────────
1183
+
1184
+ private ensureQueueLoaded(): Promise<void> {
1185
+ if (!this.queueLoad) {
1186
+ const scope = this.scope;
1187
+ this.queueLoad = this.store.listQueue(`${scope}|`).then((queue) => {
1188
+ // A scope switch during the load must not graft the old
1189
+ // user's queue onto the new one.
1190
+ if (this.scope !== scope) return;
1191
+ this.queue = queue;
1192
+ this.patchStatus({ pending: queue.length });
1193
+ this.notifyQueue();
1194
+ }).catch(() => undefined);
1195
+ }
1196
+ return this.queueLoad;
1197
+ }
1198
+
1199
+ private enqueue(mutation: Omit<PendingMutation, "mutationId" | "queuedAt">): Promise<void> {
1200
+ const result = this.enqueueChain.then(async () => {
1201
+ await this.ensureQueueLoaded();
1202
+
1203
+ // Tail coalescing: repeated edits to the most recently written row
1204
+ // (typing in a form) collapse into the queued op instead of
1205
+ // growing the queue. Only the queue *tail* may absorb an update —
1206
+ // merging into an earlier op would move this write across ops
1207
+ // queued after it, silently reordering what the app did.
1208
+ if (mutation.type === "update") {
1209
+ const tail = this.queue[this.queue.length - 1];
1210
+ if (tail
1211
+ && tail.collection === mutation.collection
1212
+ && (tail.type === "create" || tail.type === "update")
1213
+ && tail.id === mutation.id) {
1214
+ // The id must survive the merge: a queued create carries
1215
+ // the client-generated id inside its data. The rollback
1216
+ // stays the tail's — the state before the *first* of the
1217
+ // merged writes, which is what undoing them all restores.
1218
+ tail.data = { ...(tail.data as AnyRow), ...(mutation.data as AnyRow), id: tail.id };
1219
+ await this.store.enqueue(this.queueKey(tail), tail);
1220
+ return;
1221
+ }
1222
+ }
1223
+
1224
+ // Cancel-out: deleting a row whose create is still queued — and
1225
+ // whose id the SDK generated, so the server cannot already have a
1226
+ // row under it — means the server never saw the row. Remove every
1227
+ // queued op for it and queue nothing. Creates with caller-supplied
1228
+ // ids do NOT cancel (the id may name an existing server row, which
1229
+ // the delete must still remove), and neither do rows queued inside
1230
+ // a createMany (the bulk op replays first, then the delete).
1231
+ if (mutation.type === "delete") {
1232
+ const hasPendingCreate = this.queue.some((m) =>
1233
+ m.collection === mutation.collection && m.type === "create"
1234
+ && m.id === mutation.id && m.generatedId === true);
1235
+ if (hasPendingCreate) {
1236
+ const doomed = this.queue.filter((m) =>
1237
+ m.collection === mutation.collection
1238
+ && m.id === mutation.id
1239
+ && (m.type === "create" || m.type === "update"));
1240
+ for (const op of doomed) await this.store.dequeue(this.queueKey(op));
1241
+ this.queue = this.queue.filter((m) => !doomed.includes(m));
1242
+ this.afterQueueChange();
1243
+ return;
1244
+ }
1245
+ }
1246
+
1247
+ const full: PendingMutation = {
1248
+ ...mutation,
1249
+ mutationId: createMutationId(),
1250
+ queuedAt: Date.now()
1251
+ };
1252
+ await this.store.enqueue(this.queueKey(full), full);
1253
+ this.queue.push(full);
1254
+ this.afterQueueChange();
1255
+ });
1256
+ // The chain must survive a failed enqueue, or every later write dies
1257
+ // on the same stale rejection.
1258
+ this.enqueueChain = result.catch(() => undefined);
1259
+ return result;
1260
+ }
1261
+
1262
+ private hasPending(slug: string, id: string | number): boolean {
1263
+ const key = String(id);
1264
+ return this.queue.some((op) => {
1265
+ if (op.collection !== slug) return false;
1266
+ if (op.type === "createMany") {
1267
+ return (op.data as AnyRow[] | undefined)?.some((r) => String(r.id) === key) ?? false;
1268
+ }
1269
+ return op.id !== undefined && String(op.id) === key;
1270
+ });
1271
+ }
1272
+
1273
+ /** Is this row one the server has never been told about? */
1274
+ private isLocallyCreated(slug: string, idKey: string): boolean {
1275
+ return this.queue.some((op) => {
1276
+ if (op.collection !== slug) return false;
1277
+ if (op.type === "create") return op.id !== undefined && String(op.id) === idKey;
1278
+ if (op.type === "createMany") {
1279
+ return (op.data as AnyRow[] | undefined)?.some((r) => String(r.id) === idKey) ?? false;
1280
+ }
1281
+ return false;
1282
+ });
1283
+ }
1284
+
1285
+ /** How many rows the queue adds to (or removes from) a server-side count. */
1286
+ private pendingDelta(slug: string, params?: FindParams): number {
1287
+ if (!isExactlyEvaluable(params)) return 0;
1288
+ let delta = 0;
1289
+ for (const op of this.queue) {
1290
+ if (op.collection !== slug) continue;
1291
+ if (op.type === "create") {
1292
+ if (matchesParams(op.data as AnyRow, params)) delta++;
1293
+ } else if (op.type === "createMany") {
1294
+ for (const row of (op.data as AnyRow[] | undefined) ?? []) {
1295
+ if (matchesParams(row, params)) delta++;
1296
+ }
1297
+ } else if (op.type === "delete") {
1298
+ const before = op.rollback?.rows?.[String(op.id)];
1299
+ if (before && matchesParams(before, params)) delta--;
1300
+ }
1301
+ }
1302
+ return delta;
1303
+ }
1304
+
1305
+ // ─── Replay ──────────────────────────────────────────────────────────────
1306
+
1307
+ sync(): Promise<{ flushed: number; remaining: number }> {
1308
+ if (this.flushPromise) return this.flushPromise;
1309
+ this.flushPromise = this.withLock(() => this.flush())
1310
+ .finally(() => { this.flushPromise = undefined; });
1311
+ return this.flushPromise;
1312
+ }
1313
+
1314
+ private async flush(): Promise<{ flushed: number; remaining: number }> {
1315
+ await this.ensureQueueLoaded();
1316
+ // Another tab may have queued or drained work since we last looked.
1317
+ await this.reloadQueue();
1318
+ if (this.queue.length === 0) return { flushed: 0, remaining: 0 };
1319
+ // No `shouldAttempt` guard: every caller of `sync` — the app, the
1320
+ // retry timer, an `online` event, a sign-in — is asking for a real
1321
+ // attempt, and its outcome is what reopens the connection.
1322
+
1323
+ this.patchStatus({ syncing: true });
1324
+ const touched = new Set<string>();
1325
+ const queuedAtStart = this.queue.length;
1326
+ let flushed = 0;
1327
+ try {
1328
+ while (this.queue.length > 0 && !this.disposed) {
1329
+ const op = this.queue[0];
1330
+ touched.add(op.collection);
1331
+ try {
1332
+ await this.replay(op);
1333
+ } catch (error) {
1334
+ if (isNetworkError(error)) {
1335
+ // Still offline — keep the op and everything behind it.
1336
+ this.connectivity.markFailure();
1337
+ break;
1338
+ }
1339
+ op.attempts = (op.attempts ?? 0) + 1;
1340
+ op.lastError = (error as Error)?.message ?? String(error);
1341
+ if (isRetryableError(error) && op.attempts < this.maxRetries) {
1342
+ // The server is busy, not unhappy. Keep the op — and
1343
+ // its place in line, since later writes may depend on
1344
+ // it — and come back after a backoff.
1345
+ await this.store.enqueue(this.queueKey(op), op).catch(() => undefined);
1346
+ this.connectivity.deferRetry();
1347
+ this.patchStatus({ lastError: op.lastError });
1348
+ break;
1349
+ }
1350
+ await this.rejectMutation(op, error as Error);
1351
+ continue;
1352
+ }
1353
+ this.connectivity.markSuccess();
1354
+ await this.drop(op);
1355
+ flushed++;
1356
+ }
1357
+ } finally {
1358
+ this.patchStatus({ syncing: false });
1359
+ }
1360
+
1361
+ if (this.queue.length !== queuedAtStart) {
1362
+ for (const slug of touched) {
1363
+ this.notifyCollection(slug);
1364
+ // The server has now seen these writes, and its page
1365
+ // composition and totals moved with them.
1366
+ this.scheduleRefresh(slug);
1367
+ }
1368
+ // One message for the whole drain — including a drain that only
1369
+ // rolled writes back, which other tabs need to hear about just as
1370
+ // much as one that succeeded.
1371
+ this.broadcast({ type: "queue" });
1372
+ }
1373
+ if (this.queue.length === 0) this.patchStatus({ lastSyncedAt: Date.now() });
1374
+ return { flushed, remaining: this.queue.length };
1375
+ }
1376
+
1377
+ private async replay(op: PendingMutation): Promise<void> {
1378
+ const inner = this.innerFor(op.collection);
1379
+ if (op.type === "create") {
1380
+ // The queued row already carries its (client-generated) id.
1381
+ const row = await inner.create(op.data as AnyRow);
1382
+ await this.adoptServerRow(op, op.id, row);
1383
+ } else if (op.type === "createMany") {
1384
+ const queued = (op.data as AnyRow[]) ?? [];
1385
+ const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : undefined);
1386
+ for (let i = 0; i < rows.length; i++) {
1387
+ await this.adoptServerRow(op, queued[i]?.id as string | number | undefined, rows[i]);
1388
+ }
1389
+ } else if (op.type === "update") {
1390
+ const row = await inner.update(op.id!, op.data as AnyRow);
1391
+ await this.ingestReplaced(op, op.id!, row);
1392
+ } else if (op.type === "delete") {
1393
+ await inner.delete(op.id!);
1394
+ this.removeLocalRow(op.collection, op.id!, true);
1395
+ }
1396
+ }
1397
+
1398
+ /**
1399
+ * Take the server's version of a row the client created offline.
1400
+ *
1401
+ * The server may have assigned a different id — a serial column ignores
1402
+ * the id we invented — in which case every local trace of the temporary id
1403
+ * has to move with it, including queued writes that were made against it
1404
+ * before it was ever sent.
1405
+ */
1406
+ private async adoptServerRow(
1407
+ op: PendingMutation,
1408
+ localId: string | number | undefined,
1409
+ row: AnyRow | undefined
1410
+ ): Promise<void> {
1411
+ if (!row) return;
1412
+ const slug = op.collection;
1413
+ const serverId = row.id as string | number | undefined;
1414
+ if (localId !== undefined && serverId !== undefined && String(serverId) !== String(localId)) {
1415
+ const oldKey = String(localId);
1416
+ this.removeLocalRow(slug, localId);
1417
+ for (const queued of this.queue) {
1418
+ if (queued.collection !== slug) continue;
1419
+ let dirty = false;
1420
+ if (queued.id !== undefined && String(queued.id) === oldKey) {
1421
+ queued.id = serverId;
1422
+ if (queued.data && !Array.isArray(queued.data)) {
1423
+ (queued.data as AnyRow).id = serverId;
1424
+ }
1425
+ dirty = true;
1426
+ }
1427
+ // The rollback map is keyed by row id too, and restoring it
1428
+ // under a name the server never had would resurrect a ghost.
1429
+ const rollbackRows = queued.rollback?.rows;
1430
+ if (rollbackRows && oldKey in rollbackRows) {
1431
+ rollbackRows[String(serverId)] = rollbackRows[oldKey];
1432
+ delete rollbackRows[oldKey];
1433
+ dirty = true;
1434
+ }
1435
+ if (dirty) await this.store.enqueue(this.queueKey(queued), queued).catch(() => undefined);
1436
+ }
1437
+ }
1438
+ await this.ingestReplaced(op, serverId ?? localId!, row);
1439
+ }
1440
+
1441
+ /**
1442
+ * Write a server row over the local one, ignoring the mutation that just
1443
+ * produced it — re-applying that would put the pre-server values back on
1444
+ * top of the server's answer — but keeping every write queued *after* it.
1445
+ * Those are still unsent, and dropping them here would make the row snap
1446
+ * back to the server's version in front of the user, only to change again
1447
+ * when they replay a moment later.
1448
+ */
1449
+ private async ingestReplaced(op: PendingMutation, id: string | number, row: AnyRow): Promise<void> {
1450
+ const slug = op.collection;
1451
+ const state = await this.ensureCollection(slug);
1452
+ const key = String(id);
1453
+ const merged = this.applyPendingToRow(slug, key, { ...row }, op.mutationId);
1454
+ if (merged === undefined) {
1455
+ // A queued delete is still waiting behind this write.
1456
+ this.removeLocalRow(slug, key);
1457
+ return;
1458
+ }
1459
+ const cachedAt = Date.now();
1460
+ state.rows.set(key, { row: merged, cachedAt, rev: ++this.revCounter });
1461
+ if (this.applyPendingToRow(slug, key, undefined, op.mutationId) === undefined) {
1462
+ // Nothing local is left on top of it, so this *is* the server's row.
1463
+ state.freshRows.add(key);
1464
+ }
1465
+ void this.writeCache(this.rowKey(slug, key), dehydrateRow(merged), cachedAt);
1466
+ }
1467
+
1468
+ /**
1469
+ * The server refused a mutation. Put back what it changed, and discard the
1470
+ * queued writes that were built on top of it: an edit to a row whose
1471
+ * creation was rejected can only fail the same way, and applying it would
1472
+ * leave the local database claiming a row the server does not have.
1473
+ *
1474
+ * The cascade stops the moment a later write stops *depending* on the
1475
+ * rejected one. An `update` reads the row it edits, so it is doomed with
1476
+ * it; a `create` overwrites the row outright and a `delete` needs nothing
1477
+ * of it, so both stand on their own and are kept — dropping them would
1478
+ * silently lose writes the server would have accepted.
1479
+ */
1480
+ private async rejectMutation(op: PendingMutation, error: Error): Promise<void> {
1481
+ const ids = new Set(Object.keys(op.rollback?.rows ?? {}));
1482
+ if (op.id !== undefined) ids.add(String(op.id));
1483
+
1484
+ const doomed: PendingMutation[] = [op];
1485
+ const orphaned = new Set(ids);
1486
+ const position = this.queue.indexOf(op);
1487
+ for (const later of this.queue.slice(position + 1)) {
1488
+ if (later.collection !== op.collection) continue;
1489
+ const hit = this.idsOf(later).filter((id) => orphaned.has(id));
1490
+ if (hit.length === 0) continue;
1491
+ if (later.type === "update") doomed.push(later);
1492
+ else for (const id of hit) orphaned.delete(id);
1493
+ }
1494
+
1495
+ for (const dropped of doomed) await this.drop(dropped);
1496
+
1497
+ for (const [idKey, previous] of Object.entries(op.rollback?.rows ?? {})) {
1498
+ // With the doomed writes gone, whatever survives in the queue is
1499
+ // what the row should still look like on top of the restored base.
1500
+ const restored = this.applyPendingToRow(op.collection, idKey, previous ?? undefined);
1501
+ if (restored === undefined) this.removeLocalRow(op.collection, idKey);
1502
+ else this.setLocalRow(op.collection, idKey, restored);
1503
+ }
1504
+
1505
+ this.patchStatus({ lastError: error.message });
1506
+ this.notifyCollection(op.collection);
1507
+ this.scheduleRefresh(op.collection);
1508
+ for (const dropped of doomed) this.onSyncError?.(error, dropped);
1509
+ }
1510
+
1511
+ /** Every row id a mutation writes to. */
1512
+ private idsOf(op: PendingMutation): string[] {
1513
+ if (op.type === "createMany") {
1514
+ return ((op.data as AnyRow[] | undefined) ?? []).map((r) => String(r.id));
1515
+ }
1516
+ return op.id === undefined ? [] : [String(op.id)];
1517
+ }
1518
+
1519
+ private async drop(op: PendingMutation): Promise<void> {
1520
+ await this.store.dequeue(this.queueKey(op)).catch(() => undefined);
1521
+ this.queue = this.queue.filter((m) => m.mutationId !== op.mutationId);
1522
+ // No broadcast per item: draining a queue of fifty would be fifty
1523
+ // messages to every other tab. The flush announces itself once, at the end.
1524
+ this.afterQueueChange(false);
1525
+ }
1526
+
1527
+ /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
1528
+ private innerFor(slug: string): SDKCollectionClient<AnyRow> {
1529
+ let inner = this.inners.get(slug);
1530
+ if (!inner) {
1531
+ inner = this.createInner(slug);
1532
+ this.inners.set(slug, inner);
1533
+ }
1534
+ return inner;
1535
+ }
1536
+
1537
+ private async withLock<T>(fn: () => Promise<T>): Promise<T> {
1538
+ const locks = (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks;
1539
+ // Two tabs replaying the same queue would each send every mutation.
1540
+ if (!locks?.request) return fn();
1541
+ try {
1542
+ return await locks.request(`rebase-offline-sync:${this.scope}`, fn) as T;
1543
+ } catch {
1544
+ // A browser that denies the lock (or a policy that blocks it) must
1545
+ // not stop the queue from draining at all.
1546
+ return fn();
1547
+ }
1548
+ }
1549
+
1550
+ // ─── Cross-tab ───────────────────────────────────────────────────────────
1551
+
1552
+ private broadcast(message: { type: "rows"; slugs: string[] } | { type: "queue" }): void {
1553
+ if (!this.channel) return;
1554
+ try {
1555
+ this.channel.postMessage({ ...message, scope: this.scope, sender: this.tabId });
1556
+ } catch {
1557
+ // Structured-clone failures here would only cost cross-tab freshness.
1558
+ }
1559
+ }
1560
+
1561
+ private onBroadcast(message: unknown): void {
1562
+ if (this.disposed || !message || typeof message !== "object") return;
1563
+ const msg = message as { type?: string; scope?: string; sender?: string; slugs?: string[] };
1564
+ if (msg.sender === this.tabId || msg.scope !== this.scope) return;
1565
+ if (msg.type === "rows") {
1566
+ for (const slug of msg.slugs ?? []) void this.reloadCollection(slug);
1567
+ } else if (msg.type === "queue") {
1568
+ void this.reloadQueue();
1569
+ }
1570
+ }
1571
+
1572
+ /** Re-read one collection from the store, replacing what is in memory. */
1573
+ private async reloadCollection(slug: string): Promise<void> {
1574
+ const state = this.collections.get(slug);
1575
+ if (!state?.loaded) return; // never loaded here — nothing to keep fresh
1576
+ await this.reloadQueue();
1577
+ const scope = this.scope;
1578
+ const [rows, snapshots, absent] = await Promise.all([
1579
+ this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
1580
+ this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
1581
+ this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
1582
+ ]);
1583
+ if (this.scope !== scope || this.collections.get(slug) !== state) return;
1584
+ const next = new Map<string, RowEntry>();
1585
+ for (const entry of rows) {
1586
+ const row = entry.value as AnyRow | undefined;
1587
+ if (!row || row.id === undefined || row.id === null) continue;
1588
+ const key = String(row.id);
1589
+ const existing = state.rows.get(key);
1590
+ const hydrated = hydrateRow(row);
1591
+ // Keep the previous revision when nothing actually changed, so a
1592
+ // cross-tab ping does not re-render every observer.
1593
+ const unchanged = existing && JSON.stringify(existing.row) === JSON.stringify(hydrated);
1594
+ next.set(key, {
1595
+ row: hydrated,
1596
+ cachedAt: entry.cachedAt,
1597
+ rev: unchanged ? existing!.rev : ++this.revCounter
1598
+ });
1599
+ }
1600
+ state.rows = next;
1601
+ state.snapshots = new Map();
1602
+ for (const entry of snapshots) {
1603
+ const key = entry.key.slice(`${scope}|q|${slug}|`.length);
1604
+ if (entry.value) state.snapshots.set(key, entry.value as QuerySnapshot);
1605
+ }
1606
+ state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
1607
+ this.notifyCollection(slug, false);
1608
+ }
1609
+
1610
+ private async reloadQueue(): Promise<void> {
1611
+ const scope = this.scope;
1612
+ const queue = await this.store.listQueue(`${scope}|`).catch(() => undefined);
1613
+ if (!queue || this.scope !== scope) return;
1614
+ this.queue = queue;
1615
+ this.afterQueueChange(false);
1616
+ }
1617
+
1618
+ // ─── Notifications ───────────────────────────────────────────────────────
1619
+
1620
+ private afterQueueChange(broadcast = true): void {
1621
+ this.patchStatus({ pending: this.queue.length });
1622
+ this.notifyQueue();
1623
+ if (broadcast) this.broadcast({ type: "queue" });
1624
+ }
1625
+
1626
+ private notifyQueue(): void {
1627
+ for (const listener of this.queueListeners) listener(this.queue.length);
1628
+ }
1629
+
1630
+ private patchStatus(patch: Partial<OfflineStatus>): void {
1631
+ let changed = false;
1632
+ for (const [key, value] of Object.entries(patch) as [keyof OfflineStatus, never][]) {
1633
+ if (this.currentStatus[key] !== value) {
1634
+ this.currentStatus[key] = value;
1635
+ changed = true;
1636
+ }
1637
+ }
1638
+ if (!changed) return;
1639
+ const snapshot = { ...this.currentStatus };
1640
+ for (const listener of this.statusListeners) listener(snapshot);
1641
+ }
1642
+
1643
+ // ─── Store keys and access ───────────────────────────────────────────────
1644
+
1645
+ private countKey(slug: string, params?: FindParams): string {
1646
+ return `${this.scope}|count|${slug}|${buildQueryString(params)}`;
1647
+ }
1648
+
1649
+ private rowKey(slug: string, id: string | number): string {
1650
+ return `${this.scope}|row|${slug}|${String(id)}`;
1651
+ }
1652
+
1653
+ private absentKey(slug: string, id: string | number): string {
1654
+ return `${this.scope}|abs|${slug}|${String(id)}`;
1655
+ }
1656
+
1657
+ private queueKey(mutation: PendingMutation): string {
1658
+ return `${this.scope}|${mutation.mutationId}`;
1659
+ }
1660
+
1661
+ private async readCache<T>(key: string): Promise<T | undefined> {
1662
+ try {
1663
+ const entry = await this.store.getCache(key);
1664
+ return entry?.value as T | undefined;
1665
+ } catch {
1666
+ // A broken cache read must degrade to "no cache", never break the app.
1667
+ return undefined;
1668
+ }
1669
+ }
1670
+
1671
+ private async writeCache(key: string, value: unknown, cachedAt = Date.now()): Promise<void> {
1672
+ try {
1673
+ await this.store.setCache(key, { value, cachedAt });
1674
+ } catch {
1675
+ // Quota errors and private-browsing restrictions must not fail the
1676
+ // read or write that got us here.
1677
+ }
1678
+ }
1679
+
1680
+ private async deleteCache(keys: string[]): Promise<void> {
1681
+ try {
1682
+ await this.store.deleteCache(keys);
1683
+ } catch {
1684
+ // Same rationale as writeCache.
1685
+ }
1686
+ }
1687
+ }