@rebasepro/client 0.10.0 → 0.10.1-canary.14e53ae
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/collection.d.ts +55 -3
- package/dist/index.d.ts +23 -0
- package/dist/index.es.js +2045 -10
- package/dist/index.es.js.map +1 -1
- package/dist/offline-codec.d.ts +4 -0
- package/dist/offline-connectivity.d.ts +78 -0
- package/dist/offline-query.d.ts +51 -0
- package/dist/offline-store.d.ts +150 -0
- package/dist/offline.d.ts +306 -0
- package/dist/transport.d.ts +16 -0
- package/package.json +5 -4
- package/src/auth.ts +71 -3
- package/src/collection.ts +118 -2
- package/src/index.ts +58 -1
- package/src/offline-codec.ts +79 -0
- package/src/offline-connectivity.test.ts +157 -0
- package/src/offline-connectivity.ts +207 -0
- package/src/offline-idb-store.test.ts +286 -0
- package/src/offline-integration.test.ts +175 -0
- package/src/offline-query.test.ts +249 -0
- package/src/offline-query.ts +356 -0
- package/src/offline-store.ts +353 -0
- package/src/offline-sync-engine.test.ts +727 -0
- package/src/offline.test.ts +719 -0
- package/src/offline.ts +1687 -0
- package/src/storage.ts +11 -3
- package/src/transport.ts +17 -0
|
@@ -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
|
+
}
|