@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/README.md +9 -1
- package/dist/collection.d.ts +55 -3
- package/dist/index.d.ts +23 -0
- package/dist/index.es.js +2045 -10
- package/dist/index.es.js.map +1 -1
- package/dist/offline-codec.d.ts +4 -0
- package/dist/offline-connectivity.d.ts +78 -0
- package/dist/offline-query.d.ts +51 -0
- package/dist/offline-store.d.ts +150 -0
- package/dist/offline.d.ts +306 -0
- package/dist/transport.d.ts +16 -0
- package/package.json +5 -4
- package/src/auth.ts +71 -3
- package/src/collection.ts +118 -2
- package/src/index.ts +58 -1
- package/src/offline-codec.ts +79 -0
- package/src/offline-connectivity.test.ts +157 -0
- package/src/offline-connectivity.ts +207 -0
- package/src/offline-idb-store.test.ts +286 -0
- package/src/offline-integration.test.ts +175 -0
- package/src/offline-query.test.ts +249 -0
- package/src/offline-query.ts +356 -0
- package/src/offline-store.ts +353 -0
- package/src/offline-sync-engine.test.ts +727 -0
- package/src/offline.test.ts +719 -0
- package/src/offline.ts +1687 -0
- package/src/storage.ts +11 -3
- package/src/transport.ts +17 -0
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
|
+
}
|