@rebasepro/client 0.17.3-canary.gdd23447 → 0.18.0

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