@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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Persistence backends for the SDK's offline support.
3
+ *
4
+ * The store is a dumb, namespaced key/value surface with two areas: a read
5
+ * cache (normalized rows, query snapshots and sync bookkeeping) and a mutation
6
+ * queue (local writes waiting to reach the server). All structure — per-user
7
+ * prefixes, the `row|`/`q|`/`meta|` namespaces, mutation ordering — is owned by
8
+ * the {@link OfflineManager}; the store only promises that a prefix listing
9
+ * comes back in lexicographic key order, which is what makes the queue a FIFO.
10
+ *
11
+ * Two implementations ship with the SDK:
12
+ * - {@link IndexedDBOfflineStore} — the browser default; survives reloads.
13
+ * - {@link MemoryOfflineStore} — the fallback everywhere IndexedDB does not
14
+ * exist (Node, React Native, tests); survives only the process.
15
+ *
16
+ * Environments with neither (React Native + AsyncStorage, Electron main, …)
17
+ * implement this interface and pass it via `offline.store`.
18
+ */
19
+
20
+ /** A cached value plus the moment it was written, for LRU eviction. */
21
+ export interface OfflineCacheEntry {
22
+ value: unknown;
23
+ cachedAt: number;
24
+ }
25
+
26
+ /** A cache entry with its key, as returned by prefix listings. */
27
+ export interface OfflineCacheRecord extends OfflineCacheEntry {
28
+ key: string;
29
+ }
30
+
31
+ /** What a mutation has to put back if the server rejects it. */
32
+ export interface MutationRollback {
33
+ /**
34
+ * The rows as they were locally *before* this mutation was applied, keyed
35
+ * by id. A `null` value means "the row did not exist" — restoring it is a
36
+ * delete, not a write.
37
+ */
38
+ rows: Record<string, Record<string, unknown> | null>;
39
+ }
40
+
41
+ /**
42
+ * A local write waiting to be replayed against the server.
43
+ *
44
+ * `mutationId` orders the queue globally (not per collection): a create in one
45
+ * collection may be the parent a later insert in another references, so replay
46
+ * must preserve the order the app issued the writes in. It is lexicographically
47
+ * time-ordered and carries a random suffix, so two browser tabs writing in the
48
+ * same millisecond produce distinct, still-roughly-ordered ids instead of
49
+ * silently overwriting each other's queue entry.
50
+ */
51
+ export interface PendingMutation {
52
+ /** Unique, lexicographically sortable identity — also the queue key suffix. */
53
+ mutationId: string;
54
+ collection: string;
55
+ type: "create" | "createMany" | "update" | "delete";
56
+ /** Target row id for update/delete, and the (client-generated) id of an offline create. */
57
+ id?: string | number;
58
+ /**
59
+ * True when the SDK minted this create's id itself. Only such creates may
60
+ * cancel out against a later offline delete: a freshly generated UUID
61
+ * cannot name a row the server already has, while a caller-supplied id
62
+ * can — and there the delete must still replay to remove the server row.
63
+ */
64
+ generatedId?: boolean;
65
+ /** The payload: a row for create/update, an array of rows for createMany. */
66
+ data?: Record<string, unknown> | Record<string, unknown>[];
67
+ upsert?: boolean;
68
+ queuedAt: number;
69
+ /** How many times replay has been attempted (diagnostics for a stuck queue). */
70
+ attempts?: number;
71
+ /** The last replay failure's message, when there was one. */
72
+ lastError?: string;
73
+ /** Local state to restore if the server rejects this mutation. */
74
+ rollback?: MutationRollback;
75
+ }
76
+
77
+ export interface OfflineStore {
78
+ getCache(key: string): Promise<OfflineCacheEntry | undefined>;
79
+ setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
80
+ /** Write many entries at once — one transaction where the backend has them. */
81
+ setCacheMany(entries: { key: string; entry: OfflineCacheEntry }[]): Promise<void>;
82
+ deleteCache(keys: string[]): Promise<void>;
83
+ /** Every cache key starting with `prefix`, with its write time (for eviction). */
84
+ listCache(prefix: string): Promise<{ key: string; cachedAt: number }[]>;
85
+ /** As {@link listCache}, but with the values — the local query engine's input. */
86
+ listCacheEntries(prefix: string): Promise<OfflineCacheRecord[]>;
87
+
88
+ enqueue(key: string, mutation: PendingMutation): Promise<void>;
89
+ dequeue(key: string): Promise<void>;
90
+ /** Queued mutations whose key starts with `prefix`, in lexicographic key order. */
91
+ listQueue(prefix: string): Promise<PendingMutation[]>;
92
+
93
+ /** Remove every cache entry and queued mutation whose key starts with `prefix`. */
94
+ clear(prefix: string): Promise<void>;
95
+ }
96
+
97
+ // ─── Mutation ids ────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Monotonic within a tab, unique across tabs, and sortable as a plain string:
101
+ * `<ms base36, padded>-<counter>-<random>`. The padding is what keeps
102
+ * lexicographic order equal to chronological order, and the random suffix is
103
+ * what stops two tabs from writing the same queue key in the same millisecond
104
+ * — which would silently drop one of the two writes.
105
+ */
106
+ let mutationCounter = 0;
107
+ export function createMutationId(now: number = Date.now()): string {
108
+ const time = now.toString(36).padStart(10, "0");
109
+ const counter = (mutationCounter = (mutationCounter + 1) % 1_679_616).toString(36).padStart(4, "0");
110
+ const random = Math.random().toString(36).slice(2, 10).padStart(8, "0");
111
+ return `${time}-${counter}-${random}`;
112
+ }
113
+
114
+ // ─── Memory ──────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * In-memory store: the default outside the browser and the workhorse of the
118
+ * test suite. Values are deep-copied on the way in and out so a caller
119
+ * mutating a returned row cannot silently edit the "persisted" copy — the
120
+ * IndexedDB implementation gets the same guarantee for free from structured
121
+ * cloning, and the two must not differ in aliasing behaviour.
122
+ */
123
+ export class MemoryOfflineStore implements OfflineStore {
124
+ private cache = new Map<string, OfflineCacheEntry>();
125
+ private queue = new Map<string, PendingMutation>();
126
+
127
+ async getCache(key: string): Promise<OfflineCacheEntry | undefined> {
128
+ const entry = this.cache.get(key);
129
+ return entry ? structuredClone(entry) : undefined;
130
+ }
131
+
132
+ async setCache(key: string, entry: OfflineCacheEntry): Promise<void> {
133
+ this.cache.set(key, structuredClone(entry));
134
+ }
135
+
136
+ async setCacheMany(entries: { key: string; entry: OfflineCacheEntry }[]): Promise<void> {
137
+ for (const { key, entry } of entries) this.cache.set(key, structuredClone(entry));
138
+ }
139
+
140
+ async deleteCache(keys: string[]): Promise<void> {
141
+ for (const key of keys) this.cache.delete(key);
142
+ }
143
+
144
+ async listCache(prefix: string): Promise<{ key: string; cachedAt: number }[]> {
145
+ const out: { key: string; cachedAt: number }[] = [];
146
+ for (const [key, entry] of this.cache) {
147
+ if (key.startsWith(prefix)) out.push({ key, cachedAt: entry.cachedAt });
148
+ }
149
+ return out;
150
+ }
151
+
152
+ async listCacheEntries(prefix: string): Promise<OfflineCacheRecord[]> {
153
+ const out: OfflineCacheRecord[] = [];
154
+ for (const [key, entry] of this.cache) {
155
+ if (key.startsWith(prefix)) out.push({ key, ...structuredClone(entry) });
156
+ }
157
+ out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
158
+ return out;
159
+ }
160
+
161
+ async enqueue(key: string, mutation: PendingMutation): Promise<void> {
162
+ this.queue.set(key, structuredClone(mutation));
163
+ }
164
+
165
+ async dequeue(key: string): Promise<void> {
166
+ this.queue.delete(key);
167
+ }
168
+
169
+ async listQueue(prefix: string): Promise<PendingMutation[]> {
170
+ return [...this.queue.entries()]
171
+ .filter(([key]) => key.startsWith(prefix))
172
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
173
+ .map(([, mutation]) => structuredClone(mutation));
174
+ }
175
+
176
+ async clear(prefix: string): Promise<void> {
177
+ for (const key of [...this.cache.keys()]) {
178
+ if (key.startsWith(prefix)) this.cache.delete(key);
179
+ }
180
+ for (const key of [...this.queue.keys()]) {
181
+ if (key.startsWith(prefix)) this.queue.delete(key);
182
+ }
183
+ }
184
+ }
185
+
186
+ // ─── IndexedDB ───────────────────────────────────────────────────────────────
187
+
188
+ const IDB_NAME = "rebase-offline";
189
+ /**
190
+ * v2 introduced the normalized row cache and string mutation ids. A v1
191
+ * database holds whole-response blobs under keys this version cannot read and
192
+ * queue entries ordered by a numeric `seq` this version no longer writes, so
193
+ * the upgrade drops both stores rather than trying to translate them. Offline
194
+ * support had not shipped in a release when v2 landed, so nothing in the wild
195
+ * loses a queued write to this.
196
+ */
197
+ const IDB_VERSION = 2;
198
+ const CACHE_STORE = "cache";
199
+ const QUEUE_STORE = "queue";
200
+
201
+ /** The exclusive upper bound of an IDBKeyRange covering every key under `prefix`. */
202
+ function prefixRange(prefix: string): IDBKeyRange {
203
+ return IDBKeyRange.bound(prefix, prefix + "￿", false, false);
204
+ }
205
+
206
+ function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
207
+ return new Promise((resolve, reject) => {
208
+ request.onsuccess = () => resolve(request.result);
209
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
210
+ });
211
+ }
212
+
213
+ /** Resolve when the whole transaction commits, not just when the last request returns. */
214
+ function transactionDone(tx: IDBTransaction): Promise<void> {
215
+ return new Promise((resolve, reject) => {
216
+ tx.oncomplete = () => resolve();
217
+ tx.onabort = tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
218
+ });
219
+ }
220
+
221
+ /**
222
+ * IndexedDB-backed store — the browser default, so cached rows and queued
223
+ * writes survive a reload or a browser restart. Everything lives in one
224
+ * database with two object stores; keys are the manager's full prefixed
225
+ * strings, so multiple users (scopes) share the database without ever
226
+ * sharing entries.
227
+ */
228
+ export class IndexedDBOfflineStore implements OfflineStore {
229
+ private dbPromise?: Promise<IDBDatabase>;
230
+
231
+ private open(): Promise<IDBDatabase> {
232
+ if (!this.dbPromise) {
233
+ this.dbPromise = new Promise((resolve, reject) => {
234
+ const request = indexedDB.open(IDB_NAME, IDB_VERSION);
235
+ request.onupgradeneeded = (event) => {
236
+ const db = request.result;
237
+ // A v1 database speaks a key layout this version cannot
238
+ // read; keeping it would surface as corrupt cache entries
239
+ // and un-replayable mutations. Start clean instead.
240
+ if (event.oldVersion > 0 && event.oldVersion < 2) {
241
+ if (db.objectStoreNames.contains(CACHE_STORE)) db.deleteObjectStore(CACHE_STORE);
242
+ if (db.objectStoreNames.contains(QUEUE_STORE)) db.deleteObjectStore(QUEUE_STORE);
243
+ }
244
+ if (!db.objectStoreNames.contains(CACHE_STORE)) db.createObjectStore(CACHE_STORE);
245
+ if (!db.objectStoreNames.contains(QUEUE_STORE)) db.createObjectStore(QUEUE_STORE);
246
+ };
247
+ request.onsuccess = () => {
248
+ const db = request.result;
249
+ // Another tab asking for a newer version needs this
250
+ // connection out of the way, or its upgrade blocks forever.
251
+ db.onversionchange = () => {
252
+ db.close();
253
+ this.dbPromise = undefined;
254
+ };
255
+ resolve(db);
256
+ };
257
+ // Reset so a transient failure (private browsing quota, a
258
+ // version race with another tab) can be retried instead of
259
+ // poisoning every later call with the same rejection.
260
+ request.onerror = () => {
261
+ this.dbPromise = undefined;
262
+ reject(request.error ?? new Error("Failed to open IndexedDB"));
263
+ };
264
+ request.onblocked = () => {
265
+ this.dbPromise = undefined;
266
+ reject(new Error("IndexedDB upgrade blocked by another tab"));
267
+ };
268
+ });
269
+ }
270
+ return this.dbPromise;
271
+ }
272
+
273
+ private async store(name: string, mode: IDBTransactionMode): Promise<IDBObjectStore> {
274
+ const db = await this.open();
275
+ return db.transaction(name, mode).objectStore(name);
276
+ }
277
+
278
+ async getCache(key: string): Promise<OfflineCacheEntry | undefined> {
279
+ const store = await this.store(CACHE_STORE, "readonly");
280
+ const entry = await requestToPromise(store.get(key));
281
+ return entry as OfflineCacheEntry | undefined;
282
+ }
283
+
284
+ async setCache(key: string, entry: OfflineCacheEntry): Promise<void> {
285
+ const store = await this.store(CACHE_STORE, "readwrite");
286
+ await requestToPromise(store.put(entry, key));
287
+ }
288
+
289
+ async setCacheMany(entries: { key: string; entry: OfflineCacheEntry }[]): Promise<void> {
290
+ if (entries.length === 0) return;
291
+ const store = await this.store(CACHE_STORE, "readwrite");
292
+ for (const { key, entry } of entries) store.put(entry, key);
293
+ // One commit for the whole batch: a `find` writing 200 rows must not
294
+ // be 200 round-trips through the transaction queue.
295
+ await transactionDone(store.transaction);
296
+ }
297
+
298
+ async deleteCache(keys: string[]): Promise<void> {
299
+ if (keys.length === 0) return;
300
+ const store = await this.store(CACHE_STORE, "readwrite");
301
+ for (const key of keys) store.delete(key);
302
+ await transactionDone(store.transaction);
303
+ }
304
+
305
+ async listCache(prefix: string): Promise<{ key: string; cachedAt: number }[]> {
306
+ const store = await this.store(CACHE_STORE, "readonly");
307
+ const [keys, entries] = await Promise.all([
308
+ requestToPromise(store.getAllKeys(prefixRange(prefix))),
309
+ requestToPromise(store.getAll(prefixRange(prefix)))
310
+ ]);
311
+ return keys.map((key, i) => ({
312
+ key: String(key),
313
+ cachedAt: (entries[i] as OfflineCacheEntry)?.cachedAt ?? 0
314
+ }));
315
+ }
316
+
317
+ async listCacheEntries(prefix: string): Promise<OfflineCacheRecord[]> {
318
+ const store = await this.store(CACHE_STORE, "readonly");
319
+ const [keys, entries] = await Promise.all([
320
+ requestToPromise(store.getAllKeys(prefixRange(prefix))),
321
+ requestToPromise(store.getAll(prefixRange(prefix)))
322
+ ]);
323
+ return keys.map((key, i) => {
324
+ const entry = entries[i] as OfflineCacheEntry | undefined;
325
+ return { key: String(key), value: entry?.value, cachedAt: entry?.cachedAt ?? 0 };
326
+ });
327
+ }
328
+
329
+ async enqueue(key: string, mutation: PendingMutation): Promise<void> {
330
+ const store = await this.store(QUEUE_STORE, "readwrite");
331
+ await requestToPromise(store.put(mutation, key));
332
+ }
333
+
334
+ async dequeue(key: string): Promise<void> {
335
+ const store = await this.store(QUEUE_STORE, "readwrite");
336
+ await requestToPromise(store.delete(key));
337
+ }
338
+
339
+ async listQueue(prefix: string): Promise<PendingMutation[]> {
340
+ const store = await this.store(QUEUE_STORE, "readonly");
341
+ // getAll on a key range returns values in key order, which is the
342
+ // FIFO guarantee this interface promises.
343
+ const entries = await requestToPromise(store.getAll(prefixRange(prefix)));
344
+ return entries as PendingMutation[];
345
+ }
346
+
347
+ async clear(prefix: string): Promise<void> {
348
+ const cache = await this.store(CACHE_STORE, "readwrite");
349
+ await requestToPromise(cache.delete(prefixRange(prefix)));
350
+ const queue = await this.store(QUEUE_STORE, "readwrite");
351
+ await requestToPromise(queue.delete(prefixRange(prefix)));
352
+ }
353
+ }