@rebasepro/client 0.10.0 → 0.10.1-canary.a54c057
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +591 -3
- package/dist/index.es.js.map +1 -1
- package/dist/offline-store.d.ts +109 -0
- package/dist/offline.d.ts +129 -0
- package/package.json +5 -4
- package/src/index.ts +47 -1
- package/src/offline-idb-store.test.ts +151 -0
- package/src/offline-integration.test.ts +134 -0
- package/src/offline-store.ts +232 -0
- package/src/offline.test.ts +688 -0
- package/src/offline.ts +621 -0
|
@@ -0,0 +1,232 @@
|
|
|
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 (query results, LRU-evicted) and a mutation queue (writes made while
|
|
6
|
+
* offline, replayed in order). All scoping — per-user prefixes, sequence
|
|
7
|
+
* numbers, key layout — is owned by the `OfflineManager`; the store only
|
|
8
|
+
* promises that `listQueue` returns entries in lexicographic key order, which
|
|
9
|
+
* is what makes the padded sequence numbers 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
|
+
/**
|
|
27
|
+
* A write made while offline, waiting to be replayed against the server.
|
|
28
|
+
*
|
|
29
|
+
* `seq` orders the queue globally (not per collection): a create in one
|
|
30
|
+
* collection may be the parent a later insert in another references, so
|
|
31
|
+
* replay must preserve the order the app issued the writes in.
|
|
32
|
+
*/
|
|
33
|
+
export interface PendingMutation {
|
|
34
|
+
seq: number;
|
|
35
|
+
collection: string;
|
|
36
|
+
type: "create" | "createMany" | "update" | "delete";
|
|
37
|
+
/** Target row id for update/delete, and the (client-generated) id of an offline create. */
|
|
38
|
+
id?: string | number;
|
|
39
|
+
/**
|
|
40
|
+
* True when the SDK minted this create's id itself. Only such creates may
|
|
41
|
+
* cancel out against a later offline delete: a freshly generated UUID
|
|
42
|
+
* cannot name a row the server already has, while a caller-supplied id
|
|
43
|
+
* can — and there the delete must still replay to remove the server row.
|
|
44
|
+
*/
|
|
45
|
+
generatedId?: boolean;
|
|
46
|
+
/** The payload: a row for create/update, an array of rows for createMany. */
|
|
47
|
+
data?: Record<string, unknown> | Record<string, unknown>[];
|
|
48
|
+
upsert?: boolean;
|
|
49
|
+
queuedAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OfflineStore {
|
|
53
|
+
getCache(key: string): Promise<OfflineCacheEntry | undefined>;
|
|
54
|
+
setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
|
|
55
|
+
deleteCache(keys: string[]): Promise<void>;
|
|
56
|
+
/** Every cache key starting with `prefix`, with its write time (for eviction). */
|
|
57
|
+
listCache(prefix: string): Promise<{ key: string; cachedAt: number }[]>;
|
|
58
|
+
|
|
59
|
+
enqueue(key: string, mutation: PendingMutation): Promise<void>;
|
|
60
|
+
dequeue(key: string): Promise<void>;
|
|
61
|
+
/** Queued mutations whose key starts with `prefix`, in lexicographic key order. */
|
|
62
|
+
listQueue(prefix: string): Promise<PendingMutation[]>;
|
|
63
|
+
|
|
64
|
+
/** Remove every cache entry and queued mutation whose key starts with `prefix`. */
|
|
65
|
+
clear(prefix: string): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* In-memory store: the default outside the browser and the workhorse of the
|
|
70
|
+
* test suite. Values are deep-copied on the way in and out so a caller
|
|
71
|
+
* mutating a returned row cannot silently edit the "persisted" copy — the
|
|
72
|
+
* IndexedDB implementation gets the same guarantee for free from structured
|
|
73
|
+
* cloning, and the two must not differ in aliasing behaviour.
|
|
74
|
+
*/
|
|
75
|
+
export class MemoryOfflineStore implements OfflineStore {
|
|
76
|
+
private cache = new Map<string, OfflineCacheEntry>();
|
|
77
|
+
private queue = new Map<string, PendingMutation>();
|
|
78
|
+
|
|
79
|
+
async getCache(key: string): Promise<OfflineCacheEntry | undefined> {
|
|
80
|
+
const entry = this.cache.get(key);
|
|
81
|
+
return entry ? structuredClone(entry) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async setCache(key: string, entry: OfflineCacheEntry): Promise<void> {
|
|
85
|
+
this.cache.set(key, structuredClone(entry));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async deleteCache(keys: string[]): Promise<void> {
|
|
89
|
+
for (const key of keys) this.cache.delete(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async listCache(prefix: string): Promise<{ key: string; cachedAt: number }[]> {
|
|
93
|
+
const out: { key: string; cachedAt: number }[] = [];
|
|
94
|
+
for (const [key, entry] of this.cache) {
|
|
95
|
+
if (key.startsWith(prefix)) out.push({ key, cachedAt: entry.cachedAt });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async enqueue(key: string, mutation: PendingMutation): Promise<void> {
|
|
101
|
+
this.queue.set(key, structuredClone(mutation));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async dequeue(key: string): Promise<void> {
|
|
105
|
+
this.queue.delete(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async listQueue(prefix: string): Promise<PendingMutation[]> {
|
|
109
|
+
return [...this.queue.entries()]
|
|
110
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
111
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
112
|
+
.map(([, mutation]) => structuredClone(mutation));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async clear(prefix: string): Promise<void> {
|
|
116
|
+
for (const key of [...this.cache.keys()]) {
|
|
117
|
+
if (key.startsWith(prefix)) this.cache.delete(key);
|
|
118
|
+
}
|
|
119
|
+
for (const key of [...this.queue.keys()]) {
|
|
120
|
+
if (key.startsWith(prefix)) this.queue.delete(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const IDB_NAME = "rebase-offline";
|
|
126
|
+
const IDB_VERSION = 1;
|
|
127
|
+
const CACHE_STORE = "cache";
|
|
128
|
+
const QUEUE_STORE = "queue";
|
|
129
|
+
|
|
130
|
+
/** The exclusive upper bound of an IDBKeyRange covering every key under `prefix`. */
|
|
131
|
+
function prefixRange(prefix: string): IDBKeyRange {
|
|
132
|
+
return IDBKeyRange.bound(prefix, prefix + "", false, false);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
request.onsuccess = () => resolve(request.result);
|
|
138
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* IndexedDB-backed store — the browser default, so cached reads and queued
|
|
144
|
+
* writes survive a reload or a browser restart. Everything lives in one
|
|
145
|
+
* database with two object stores; keys are the manager's full prefixed
|
|
146
|
+
* strings, so multiple users (scopes) share the database without ever
|
|
147
|
+
* sharing entries.
|
|
148
|
+
*/
|
|
149
|
+
export class IndexedDBOfflineStore implements OfflineStore {
|
|
150
|
+
private dbPromise?: Promise<IDBDatabase>;
|
|
151
|
+
|
|
152
|
+
private open(): Promise<IDBDatabase> {
|
|
153
|
+
if (!this.dbPromise) {
|
|
154
|
+
this.dbPromise = new Promise((resolve, reject) => {
|
|
155
|
+
const request = indexedDB.open(IDB_NAME, IDB_VERSION);
|
|
156
|
+
request.onupgradeneeded = () => {
|
|
157
|
+
const db = request.result;
|
|
158
|
+
if (!db.objectStoreNames.contains(CACHE_STORE)) db.createObjectStore(CACHE_STORE);
|
|
159
|
+
if (!db.objectStoreNames.contains(QUEUE_STORE)) db.createObjectStore(QUEUE_STORE);
|
|
160
|
+
};
|
|
161
|
+
request.onsuccess = () => resolve(request.result);
|
|
162
|
+
// Reset so a transient failure (private browsing quota, a
|
|
163
|
+
// version race with another tab) can be retried instead of
|
|
164
|
+
// poisoning every later call with the same rejection.
|
|
165
|
+
request.onerror = () => {
|
|
166
|
+
this.dbPromise = undefined;
|
|
167
|
+
reject(request.error ?? new Error("Failed to open IndexedDB"));
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return this.dbPromise;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async store(name: string, mode: IDBTransactionMode): Promise<IDBObjectStore> {
|
|
175
|
+
const db = await this.open();
|
|
176
|
+
return db.transaction(name, mode).objectStore(name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getCache(key: string): Promise<OfflineCacheEntry | undefined> {
|
|
180
|
+
const store = await this.store(CACHE_STORE, "readonly");
|
|
181
|
+
const entry = await requestToPromise(store.get(key));
|
|
182
|
+
return entry as OfflineCacheEntry | undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async setCache(key: string, entry: OfflineCacheEntry): Promise<void> {
|
|
186
|
+
const store = await this.store(CACHE_STORE, "readwrite");
|
|
187
|
+
await requestToPromise(store.put(entry, key));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async deleteCache(keys: string[]): Promise<void> {
|
|
191
|
+
if (keys.length === 0) return;
|
|
192
|
+
const store = await this.store(CACHE_STORE, "readwrite");
|
|
193
|
+
await Promise.all(keys.map((key) => requestToPromise(store.delete(key))));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async listCache(prefix: string): Promise<{ key: string; cachedAt: number }[]> {
|
|
197
|
+
const store = await this.store(CACHE_STORE, "readonly");
|
|
198
|
+
const [keys, entries] = await Promise.all([
|
|
199
|
+
requestToPromise(store.getAllKeys(prefixRange(prefix))),
|
|
200
|
+
requestToPromise(store.getAll(prefixRange(prefix)))
|
|
201
|
+
]);
|
|
202
|
+
return keys.map((key, i) => ({
|
|
203
|
+
key: String(key),
|
|
204
|
+
cachedAt: (entries[i] as OfflineCacheEntry)?.cachedAt ?? 0
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async enqueue(key: string, mutation: PendingMutation): Promise<void> {
|
|
209
|
+
const store = await this.store(QUEUE_STORE, "readwrite");
|
|
210
|
+
await requestToPromise(store.put(mutation, key));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async dequeue(key: string): Promise<void> {
|
|
214
|
+
const store = await this.store(QUEUE_STORE, "readwrite");
|
|
215
|
+
await requestToPromise(store.delete(key));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async listQueue(prefix: string): Promise<PendingMutation[]> {
|
|
219
|
+
const store = await this.store(QUEUE_STORE, "readonly");
|
|
220
|
+
// getAll on a key range returns values in key order, which is the
|
|
221
|
+
// FIFO guarantee this interface promises.
|
|
222
|
+
const entries = await requestToPromise(store.getAll(prefixRange(prefix)));
|
|
223
|
+
return entries as PendingMutation[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async clear(prefix: string): Promise<void> {
|
|
227
|
+
const cache = await this.store(CACHE_STORE, "readwrite");
|
|
228
|
+
await requestToPromise(cache.delete(prefixRange(prefix)));
|
|
229
|
+
const queue = await this.store(QUEUE_STORE, "readwrite");
|
|
230
|
+
await requestToPromise(queue.delete(prefixRange(prefix)));
|
|
231
|
+
}
|
|
232
|
+
}
|