@rebasepro/client 0.10.0 → 0.10.1-canary.a54c057

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,621 @@
1
+ import { buildQueryString, FindParams, RebaseApiError } from "./transport";
2
+ import { FindResult, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue } from "@rebasepro/types";
3
+ import { CollectionClient } from "./collection";
4
+ import { SDKQueryBuilder } from "./sdk_query_builder";
5
+ import {
6
+ IndexedDBOfflineStore,
7
+ MemoryOfflineStore,
8
+ OfflineStore,
9
+ PendingMutation
10
+ } from "./offline-store";
11
+
12
+ /**
13
+ * Offline support for the SDK data layer.
14
+ *
15
+ * Reads are network-first: a successful `find`/`findById`/`count` refreshes a
16
+ * local cache, and a network failure falls back to the last cached answer.
17
+ * Writes made while offline are queued and replayed, in the order the app
18
+ * issued them, when connectivity returns. Until then every read — cached or
19
+ * fresh — has the queued writes overlaid, so the app sees its own changes
20
+ * immediately instead of watching them flicker away on the next fetch.
21
+ *
22
+ * What this deliberately is NOT (yet): a local query engine. A cached query
23
+ * is only ever answered for the exact same parameters, and queued creates are
24
+ * folded into filtered queries' totals never — we cannot evaluate a `where`
25
+ * clause client-side, so filtered results only overlay updates and deletes by
26
+ * row id, which are always safe.
27
+ */
28
+
29
+ export interface OfflineConfig {
30
+ /**
31
+ * Persistence backend. Defaults to IndexedDB in the browser and an
32
+ * in-memory store elsewhere; pass a custom implementation (e.g. backed by
33
+ * AsyncStorage in React Native) to persist in other environments.
34
+ */
35
+ store?: OfflineStore;
36
+ /**
37
+ * Cached query results kept per collection; the least recently written
38
+ * are evicted beyond this. Defaults to 50.
39
+ */
40
+ maxCachedQueriesPerCollection?: number;
41
+ /**
42
+ * How often to retry replaying the queue while it is non-empty, in
43
+ * milliseconds. The `online` browser event and a sign-in also trigger a
44
+ * replay; this timer is the safety net for environments with neither.
45
+ * Defaults to 30 000.
46
+ */
47
+ syncIntervalMs?: number;
48
+ /**
49
+ * Called when the server *rejects* a queued mutation during replay (a
50
+ * 4xx/5xx response — validation, RLS, a since-deleted row). The mutation
51
+ * is dropped: keeping it would jam the queue behind an error that will
52
+ * never resolve. Network failures are not errors — the mutation stays
53
+ * queued for the next attempt.
54
+ */
55
+ onSyncError?: (error: Error, mutation: PendingMutation) => void;
56
+ }
57
+
58
+ /** What `client.offline` exposes to the app. */
59
+ export interface OfflineApi {
60
+ /** Replay the queue now. Resolves with what was flushed and what remains. */
61
+ sync(): Promise<{ flushed: number; remaining: number }>;
62
+ /** The queued mutations for the current user, oldest first. */
63
+ pending(): Promise<PendingMutation[]>;
64
+ /**
65
+ * Drop the current user's queued mutations AND cached reads. Destructive:
66
+ * queued writes are lost, not replayed. For "discard my offline changes"
67
+ * flows, not for sign-out (scoping already isolates users).
68
+ */
69
+ clear(): Promise<void>;
70
+ /** Subscribe to queue-size changes (for a "pending changes" badge). */
71
+ onQueueChange(listener: (count: number) => void): () => void;
72
+ }
73
+
74
+ /** True for "the request never reached the server", false for a server reply. */
75
+ function isNetworkError(error: unknown): boolean {
76
+ if (error instanceof RebaseApiError) return false;
77
+ // fetch rejects with TypeError on network failure in every runtime we
78
+ // support (browsers: "Failed to fetch"/"Load failed"; undici: "fetch
79
+ // failed"). Anything else — a programming error — must propagate.
80
+ return error instanceof TypeError;
81
+ }
82
+
83
+ function generateOfflineId(): string {
84
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
85
+ return crypto.randomUUID();
86
+ }
87
+ // Non-cryptographic fallback for exotic runtimes; collision odds are
88
+ // irrelevant at offline-queue scale.
89
+ return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
90
+ }
91
+
92
+ /** A find is "appendable" when queued creates provably belong in its result set. */
93
+ function isAppendable(params?: FindParams): boolean {
94
+ return !params?.where && !params?.logical && !params?.searchString;
95
+ }
96
+
97
+ type AnyRow = Record<string, unknown>;
98
+ type InnerFactory = (slug: string) => SDKCollectionClient<AnyRow>;
99
+
100
+ export class OfflineManager {
101
+ private readonly store: OfflineStore;
102
+ private readonly maxCachedQueries: number;
103
+ private readonly onSyncError?: OfflineConfig["onSyncError"];
104
+ private readonly syncIntervalMs: number;
105
+ private readonly createInner: InnerFactory;
106
+ private readonly inners = new Map<string, SDKCollectionClient<AnyRow>>();
107
+
108
+ private scope = "anon";
109
+ /** In-memory mirror of the current scope's queue, kept in seq order. */
110
+ private queue: PendingMutation[] = [];
111
+ private loadPromise?: Promise<void>;
112
+ private nextSeq = 1;
113
+ /** Serializes enqueues so concurrent writes get distinct, ordered seqs. */
114
+ private enqueueChain: Promise<unknown> = Promise.resolve();
115
+ private flushPromise?: Promise<{ flushed: number; remaining: number }>;
116
+ private queueListeners = new Set<(count: number) => void>();
117
+ private syncTimer?: ReturnType<typeof setInterval>;
118
+ private readonly onOnline = () => { void this.sync().catch(() => undefined); };
119
+
120
+ readonly api: OfflineApi;
121
+
122
+ constructor(config: OfflineConfig, createInner: InnerFactory) {
123
+ this.store = config.store
124
+ ?? (typeof indexedDB !== "undefined" ? new IndexedDBOfflineStore() : new MemoryOfflineStore());
125
+ this.maxCachedQueries = config.maxCachedQueriesPerCollection ?? 50;
126
+ this.syncIntervalMs = config.syncIntervalMs ?? 30_000;
127
+ this.onSyncError = config.onSyncError;
128
+ this.createInner = createInner;
129
+
130
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
131
+ window.addEventListener("online", this.onOnline);
132
+ }
133
+
134
+ this.api = {
135
+ sync: () => this.sync(),
136
+ pending: async () => {
137
+ await this.ensureLoaded();
138
+ // Deep-copied: these are live queue entries (tail coalescing
139
+ // mutates them in place), and a caller must not be able to
140
+ // edit what will be replayed.
141
+ return this.queue.map((m) => structuredClone(m));
142
+ },
143
+ clear: async () => {
144
+ await this.store.clear(`${this.scope}|`);
145
+ this.queue = [];
146
+ this.notifyQueue();
147
+ },
148
+ onQueueChange: (listener) => {
149
+ this.queueListeners.add(listener);
150
+ return () => this.queueListeners.delete(listener);
151
+ }
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Cache and queue are partitioned per signed-in user: cached rows are
157
+ * RLS-filtered for the user who fetched them, and queued writes must
158
+ * replay under the credentials that made them — so neither may ever leak
159
+ * across a sign-out/sign-in on a shared browser.
160
+ */
161
+ setScope(uid: string | undefined): void {
162
+ const next = uid || "anon";
163
+ if (next === this.scope) return;
164
+ this.scope = next;
165
+ this.loadPromise = undefined;
166
+ this.queue = [];
167
+ // The returning user's queue may hold writes from a previous session.
168
+ void this.sync().catch(() => undefined);
169
+ }
170
+
171
+ /** Release the online listener and retry timer (client.close()). */
172
+ dispose(): void {
173
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
174
+ window.removeEventListener("online", this.onOnline);
175
+ }
176
+ this.stopTimer();
177
+ }
178
+
179
+ // ─── Collection wrapping ─────────────────────────────────────────────────
180
+
181
+ wrap<M extends AnyRow>(slug: string, inner: CollectionClient<M>): CollectionClient<M> {
182
+ this.inners.set(slug, inner as SDKCollectionClient<AnyRow>);
183
+
184
+ const wrapped: CollectionClient<M> = {
185
+ find: async (params?: FindParams): Promise<FindResult<M>> => {
186
+ try {
187
+ const res = await inner.find(params);
188
+ await this.cacheSet(this.findKey(slug, params), res);
189
+ return await this.overlayFind(slug, params, res);
190
+ } catch (error) {
191
+ if (!isNetworkError(error)) throw error;
192
+ const cached = await this.cacheGet<FindResult<M>>(this.findKey(slug, params));
193
+ if (cached === undefined) throw error;
194
+ return this.overlayFind(slug, params, cached);
195
+ }
196
+ },
197
+
198
+ findById: async (id: string | number) => {
199
+ try {
200
+ const row = await inner.findById(id);
201
+ if (row !== undefined) await this.cacheSet(this.rowKey(slug, id), row);
202
+ return await this.overlayRow(slug, id, row);
203
+ } catch (error) {
204
+ if (!isNetworkError(error)) throw error;
205
+ const cached = await this.cacheGet<M>(this.rowKey(slug, id));
206
+ const { touched, row } = await this.composeRowFromQueue<M>(slug, id, cached);
207
+ if (!touched && cached === undefined) throw error;
208
+ return touched ? row : cached;
209
+ }
210
+ },
211
+
212
+ create: async (data: Partial<M>, id?: string | number) => {
213
+ try {
214
+ return await inner.create(data, id);
215
+ } catch (error) {
216
+ if (!isNetworkError(error)) throw error;
217
+ const providedId = id ?? (data as AnyRow).id as string | number | undefined;
218
+ const rowId = providedId ?? generateOfflineId();
219
+ const row = { ...(data as AnyRow), id: rowId } as unknown as M;
220
+ await this.enqueue({
221
+ collection: slug,
222
+ type: "create",
223
+ id: rowId,
224
+ data: row,
225
+ generatedId: providedId === undefined
226
+ });
227
+ await this.cacheSet(this.rowKey(slug, rowId), row);
228
+ return row;
229
+ }
230
+ },
231
+
232
+ createMany: async (data: Partial<M>[], options?: { upsert?: boolean }) => {
233
+ try {
234
+ return await inner.createMany(data, options);
235
+ } catch (error) {
236
+ if (!isNetworkError(error)) throw error;
237
+ if (!Array.isArray(data) || data.length === 0) return [];
238
+ const rows = data.map((r) => ({
239
+ ...(r as AnyRow),
240
+ id: (r as AnyRow).id ?? generateOfflineId()
241
+ })) as unknown as M[];
242
+ await this.enqueue({
243
+ collection: slug,
244
+ type: "createMany",
245
+ data: rows,
246
+ upsert: options?.upsert
247
+ });
248
+ for (const row of rows) {
249
+ await this.cacheSet(this.rowKey(slug, row.id as string | number), row);
250
+ }
251
+ return rows;
252
+ }
253
+ },
254
+
255
+ update: async (id: string | number, data: Partial<M>) => {
256
+ try {
257
+ const row = await inner.update(id, data);
258
+ await this.cacheSet(this.rowKey(slug, id), row);
259
+ return row;
260
+ } catch (error) {
261
+ if (!isNetworkError(error)) throw error;
262
+ await this.enqueue({ collection: slug, type: "update", id, data: data as AnyRow });
263
+ const base = await this.cacheGet<M>(this.rowKey(slug, id));
264
+ const optimistic = { ...(base ?? {}), ...(data as AnyRow), id } as unknown as M;
265
+ await this.cacheSet(this.rowKey(slug, id), optimistic);
266
+ return optimistic;
267
+ }
268
+ },
269
+
270
+ delete: async (id: string | number) => {
271
+ try {
272
+ await inner.delete(id);
273
+ await this.cacheDelete(this.rowKey(slug, id));
274
+ } catch (error) {
275
+ if (!isNetworkError(error)) throw error;
276
+ await this.enqueue({ collection: slug, type: "delete", id });
277
+ await this.cacheDelete(this.rowKey(slug, id));
278
+ }
279
+ },
280
+
281
+ count: async (params?: FindParams): Promise<number> => {
282
+ try {
283
+ const n = await inner.count(params);
284
+ await this.cacheSet(this.countKey(slug, params), n);
285
+ return this.overlayCount(slug, params, n);
286
+ } catch (error) {
287
+ if (!isNetworkError(error)) throw error;
288
+ const cached = await this.cacheGet<number>(this.countKey(slug, params));
289
+ if (cached === undefined) throw error;
290
+ return this.overlayCount(slug, params, cached);
291
+ }
292
+ },
293
+
294
+ // The builder calls back into `wrapped.find(...)`, so fluent
295
+ // queries go through the cache/overlay path like direct calls.
296
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
297
+ const builder = new SDKQueryBuilder<M>(wrapped);
298
+ if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
299
+ return builder.where(
300
+ columnOrCondition as keyof M & string,
301
+ operator!,
302
+ value as WhereValue<M[keyof M & string]>
303
+ );
304
+ },
305
+ orderBy: (column, direction) => new SDKQueryBuilder<M>(wrapped).orderBy(column, direction),
306
+ limit: (count) => new SDKQueryBuilder<M>(wrapped).limit(count),
307
+ offset: (count) => new SDKQueryBuilder<M>(wrapped).offset(count),
308
+ search: (searchString) => new SDKQueryBuilder<M>(wrapped).search(searchString),
309
+ include: (...relations) => new SDKQueryBuilder<M>(wrapped).include(...relations)
310
+ };
311
+
312
+ // Realtime passthrough: a live subscription is by definition online,
313
+ // so the overlay would only fight the server's own event stream.
314
+ if (inner.listen) wrapped.listen = inner.listen.bind(inner);
315
+ if (inner.listenById) wrapped.listenById = inner.listenById.bind(inner);
316
+
317
+ return wrapped;
318
+ }
319
+
320
+ // ─── Overlay: pending writes applied on top of a read ────────────────────
321
+
322
+ private async pendingFor(slug: string): Promise<PendingMutation[]> {
323
+ await this.ensureLoaded();
324
+ return this.queue.filter((m) => m.collection === slug);
325
+ }
326
+
327
+ private async overlayFind<M extends AnyRow>(
328
+ slug: string,
329
+ params: FindParams | undefined,
330
+ result: FindResult<M>
331
+ ): Promise<FindResult<M>> {
332
+ const ops = await this.pendingFor(slug);
333
+ if (ops.length === 0) return result;
334
+
335
+ const appendable = isAppendable(params);
336
+ let rows = result.data.map((r) => ({ ...r }));
337
+ let total = result.meta.total;
338
+
339
+ const applyCreate = (row: AnyRow) => {
340
+ if (!appendable) return;
341
+ if (rows.some((r) => r.id === row.id)) return;
342
+ rows.push({ ...row } as M);
343
+ total++;
344
+ };
345
+
346
+ for (const op of ops) {
347
+ if (op.type === "create") {
348
+ applyCreate(op.data as AnyRow);
349
+ } else if (op.type === "createMany") {
350
+ for (const row of op.data as AnyRow[]) applyCreate(row);
351
+ } else if (op.type === "update") {
352
+ const idx = rows.findIndex((r) => r.id === op.id);
353
+ if (idx >= 0) rows[idx] = { ...rows[idx], ...(op.data as AnyRow) } as M;
354
+ } else if (op.type === "delete") {
355
+ const before = rows.length;
356
+ rows = rows.filter((r) => r.id !== op.id);
357
+ if (rows.length < before) total = Math.max(0, total - 1);
358
+ }
359
+ }
360
+
361
+ return { data: rows, meta: { ...result.meta, total } };
362
+ }
363
+
364
+ private async overlayCount(slug: string, params: FindParams | undefined, count: number): Promise<number> {
365
+ const ops = await this.pendingFor(slug);
366
+ if (ops.length === 0 || !isAppendable(params)) return count;
367
+ let n = count;
368
+ for (const op of ops) {
369
+ if (op.type === "create") n++;
370
+ else if (op.type === "createMany") n += (op.data as AnyRow[]).length;
371
+ else if (op.type === "delete") n = Math.max(0, n - 1);
372
+ }
373
+ return n;
374
+ }
375
+
376
+ /**
377
+ * Fold the queued ops for one row into a base value. `touched` separates
378
+ * "the queue says this row does not exist" (a pending delete → undefined)
379
+ * from "the queue has nothing to say" — the caller falls back differently.
380
+ */
381
+ private async composeRowFromQueue<M extends AnyRow>(
382
+ slug: string,
383
+ id: string | number,
384
+ base: M | undefined
385
+ ): Promise<{ touched: boolean; row: M | undefined }> {
386
+ const ops = await this.pendingFor(slug);
387
+ let touched = false;
388
+ let row = base;
389
+ for (const op of ops) {
390
+ if (op.type === "create" && op.id === id) {
391
+ row = { ...(op.data as AnyRow) } as M;
392
+ touched = true;
393
+ } else if (op.type === "createMany") {
394
+ const match = (op.data as AnyRow[]).find((r) => r.id === id);
395
+ if (match) { row = { ...match } as M; touched = true; }
396
+ } else if (op.type === "update" && op.id === id) {
397
+ row = { ...(row ?? {}), ...(op.data as AnyRow), id } as unknown as M;
398
+ touched = true;
399
+ } else if (op.type === "delete" && op.id === id) {
400
+ row = undefined;
401
+ touched = true;
402
+ }
403
+ }
404
+ return { touched, row };
405
+ }
406
+
407
+ private async overlayRow<M extends AnyRow>(slug: string, id: string | number, base: M | undefined): Promise<M | undefined> {
408
+ const { touched, row } = await this.composeRowFromQueue(slug, id, base);
409
+ return touched ? row : base;
410
+ }
411
+
412
+ // ─── Queue ───────────────────────────────────────────────────────────────
413
+
414
+ private ensureLoaded(): Promise<void> {
415
+ if (!this.loadPromise) {
416
+ const scope = this.scope;
417
+ this.loadPromise = this.store.listQueue(`${scope}|`).then((queue) => {
418
+ // A scope switch during the load must not graft the old
419
+ // user's queue onto the new one.
420
+ if (this.scope !== scope) return;
421
+ this.queue = queue;
422
+ this.nextSeq = queue.reduce((max, m) => Math.max(max, m.seq), 0) + 1;
423
+ if (queue.length > 0) this.startTimer();
424
+ this.notifyQueue();
425
+ });
426
+ }
427
+ return this.loadPromise;
428
+ }
429
+
430
+ private enqueue(mutation: Omit<PendingMutation, "seq" | "queuedAt">): Promise<void> {
431
+ const result = this.enqueueChain.then(async () => {
432
+ await this.ensureLoaded();
433
+
434
+ // Tail coalescing: repeated edits to the most recently written row
435
+ // (typing in a form) collapse into the queued op instead of
436
+ // growing the queue. Only the queue *tail* may absorb an update —
437
+ // merging into an earlier op would move this write across ops
438
+ // queued after it, silently reordering what the app did.
439
+ if (mutation.type === "update") {
440
+ const tail = this.queue[this.queue.length - 1];
441
+ if (tail
442
+ && tail.collection === mutation.collection
443
+ && (tail.type === "create" || tail.type === "update")
444
+ && tail.id === mutation.id) {
445
+ // The id must survive the merge: a queued create carries
446
+ // the client-generated id inside its data.
447
+ tail.data = { ...(tail.data as AnyRow), ...(mutation.data as AnyRow), id: tail.id };
448
+ await this.store.enqueue(this.queueKey(tail.seq), tail);
449
+ return;
450
+ }
451
+ }
452
+
453
+ // Cancel-out: deleting a row whose create is still queued — and
454
+ // whose id the SDK generated, so the server cannot already have a
455
+ // row under it — means the server never saw the row. Remove every
456
+ // queued op for it and queue nothing. Creates with caller-supplied
457
+ // ids do NOT cancel (the id may name an existing server row, which
458
+ // the delete must still remove), and neither do rows queued inside
459
+ // a createMany (the bulk op replays first, then the delete).
460
+ if (mutation.type === "delete") {
461
+ const hasPendingCreate = this.queue.some((m) =>
462
+ m.collection === mutation.collection && m.type === "create"
463
+ && m.id === mutation.id && m.generatedId === true);
464
+ if (hasPendingCreate) {
465
+ const doomed = this.queue.filter((m) =>
466
+ m.collection === mutation.collection
467
+ && m.id === mutation.id
468
+ && (m.type === "create" || m.type === "update"));
469
+ for (const op of doomed) await this.store.dequeue(this.queueKey(op.seq));
470
+ this.queue = this.queue.filter((m) => !doomed.includes(m));
471
+ this.notifyQueue();
472
+ return;
473
+ }
474
+ }
475
+
476
+ const full: PendingMutation = { ...mutation, seq: this.nextSeq++, queuedAt: Date.now() };
477
+ await this.store.enqueue(this.queueKey(full.seq), full);
478
+ this.queue.push(full);
479
+ this.startTimer();
480
+ this.notifyQueue();
481
+ });
482
+ // The chain must survive a failed enqueue, or every later write dies
483
+ // on the same stale rejection.
484
+ this.enqueueChain = result.catch(() => undefined);
485
+ return result;
486
+ }
487
+
488
+ sync(): Promise<{ flushed: number; remaining: number }> {
489
+ if (this.flushPromise) return this.flushPromise;
490
+ this.flushPromise = (async () => {
491
+ await this.ensureLoaded();
492
+ let flushed = 0;
493
+ while (this.queue.length > 0) {
494
+ const op = this.queue[0];
495
+ try {
496
+ await this.replay(op);
497
+ } catch (error) {
498
+ if (isNetworkError(error)) break; // still offline — keep the op, retry later
499
+ // The server saw it and said no. Drop it — replaying can
500
+ // only fail the same way — and let the app know.
501
+ await this.drop(op);
502
+ this.onSyncError?.(error as Error, op);
503
+ continue;
504
+ }
505
+ await this.drop(op);
506
+ flushed++;
507
+ }
508
+ if (this.queue.length === 0) this.stopTimer();
509
+ return { flushed, remaining: this.queue.length };
510
+ })().finally(() => { this.flushPromise = undefined; });
511
+ return this.flushPromise;
512
+ }
513
+
514
+ private async replay(op: PendingMutation): Promise<void> {
515
+ const inner = this.innerFor(op.collection);
516
+ if (op.type === "create") {
517
+ // The queued row already carries its (client-generated) id.
518
+ const row = await inner.create(op.data as AnyRow);
519
+ await this.cacheSet(this.rowKey(op.collection, (row as AnyRow).id as string | number ?? op.id!), row);
520
+ } else if (op.type === "createMany") {
521
+ await inner.createMany(op.data as AnyRow[], op.upsert ? { upsert: true } : undefined);
522
+ } else if (op.type === "update") {
523
+ const row = await inner.update(op.id!, op.data as AnyRow);
524
+ await this.cacheSet(this.rowKey(op.collection, op.id!), row);
525
+ } else if (op.type === "delete") {
526
+ await inner.delete(op.id!);
527
+ }
528
+ }
529
+
530
+ private async drop(op: PendingMutation): Promise<void> {
531
+ await this.store.dequeue(this.queueKey(op.seq));
532
+ this.queue = this.queue.filter((m) => m.seq !== op.seq);
533
+ this.notifyQueue();
534
+ }
535
+
536
+ /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
537
+ private innerFor(slug: string): SDKCollectionClient<AnyRow> {
538
+ let inner = this.inners.get(slug);
539
+ if (!inner) {
540
+ inner = this.createInner(slug);
541
+ this.inners.set(slug, inner);
542
+ }
543
+ return inner;
544
+ }
545
+
546
+ private notifyQueue(): void {
547
+ for (const listener of this.queueListeners) listener(this.queue.length);
548
+ }
549
+
550
+ private startTimer(): void {
551
+ if (this.syncTimer || this.syncIntervalMs <= 0) return;
552
+ this.syncTimer = setInterval(() => {
553
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
554
+ void this.sync().catch(() => undefined);
555
+ }, this.syncIntervalMs);
556
+ // In Node the timer must not keep a finished script alive.
557
+ (this.syncTimer as { unref?: () => void }).unref?.();
558
+ }
559
+
560
+ private stopTimer(): void {
561
+ if (this.syncTimer) {
562
+ clearInterval(this.syncTimer);
563
+ this.syncTimer = undefined;
564
+ }
565
+ }
566
+
567
+ // ─── Cache ───────────────────────────────────────────────────────────────
568
+
569
+ private findKey(slug: string, params?: FindParams): string {
570
+ return `${this.scope}|find|${slug}|${buildQueryString(params)}`;
571
+ }
572
+
573
+ private countKey(slug: string, params?: FindParams): string {
574
+ return `${this.scope}|count|${slug}|${buildQueryString(params)}`;
575
+ }
576
+
577
+ private rowKey(slug: string, id: string | number): string {
578
+ return `${this.scope}|row|${slug}|${String(id)}`;
579
+ }
580
+
581
+ private queueKey(seq: number): string {
582
+ // Padded so lexicographic key order in the store IS seq order.
583
+ return `${this.scope}|${String(seq).padStart(16, "0")}`;
584
+ }
585
+
586
+ private async cacheGet<T>(key: string): Promise<T | undefined> {
587
+ try {
588
+ const entry = await this.store.getCache(key);
589
+ return entry?.value as T | undefined;
590
+ } catch {
591
+ // A broken cache read must degrade to "no cache", never break the app.
592
+ return undefined;
593
+ }
594
+ }
595
+
596
+ private async cacheSet(key: string, value: unknown): Promise<void> {
597
+ try {
598
+ await this.store.setCache(key, { value, cachedAt: Date.now() });
599
+ if (key.startsWith(`${this.scope}|find|`)) {
600
+ const bucket = key.slice(0, key.lastIndexOf("|") + 1);
601
+ const entries = await this.store.listCache(bucket);
602
+ if (entries.length > this.maxCachedQueries) {
603
+ entries.sort((a, b) => a.cachedAt - b.cachedAt);
604
+ await this.store.deleteCache(
605
+ entries.slice(0, entries.length - this.maxCachedQueries).map((e) => e.key)
606
+ );
607
+ }
608
+ }
609
+ } catch {
610
+ // Quota errors and private-browsing restrictions must not fail the read/write that got us here.
611
+ }
612
+ }
613
+
614
+ private async cacheDelete(key: string): Promise<void> {
615
+ try {
616
+ await this.store.deleteCache([key]);
617
+ } catch {
618
+ // Same rationale as cacheSet.
619
+ }
620
+ }
621
+ }