@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
package/src/offline.ts
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { buildQueryString, FindParams, RebaseApiError } from "./transport";
|
|
2
|
+
import { FindResult, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue } from "@rebasepro/types";
|
|
3
|
+
import { CollectionClient } from "./collection";
|
|
4
|
+
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
5
|
+
import {
|
|
6
|
+
IndexedDBOfflineStore,
|
|
7
|
+
MemoryOfflineStore,
|
|
8
|
+
OfflineStore,
|
|
9
|
+
PendingMutation
|
|
10
|
+
} from "./offline-store";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Offline support for the SDK data layer.
|
|
14
|
+
*
|
|
15
|
+
* Reads are network-first: a successful `find`/`findById`/`count` refreshes a
|
|
16
|
+
* local cache, and a network failure falls back to the last cached answer.
|
|
17
|
+
* Writes made while offline are queued and replayed, in the order the app
|
|
18
|
+
* issued them, when connectivity returns. Until then every read — cached or
|
|
19
|
+
* fresh — has the queued writes overlaid, so the app sees its own changes
|
|
20
|
+
* immediately instead of watching them flicker away on the next fetch.
|
|
21
|
+
*
|
|
22
|
+
* What this deliberately is NOT (yet): a local query engine. A cached query
|
|
23
|
+
* is only ever answered for the exact same parameters, and queued creates are
|
|
24
|
+
* folded into filtered queries' totals never — we cannot evaluate a `where`
|
|
25
|
+
* clause client-side, so filtered results only overlay updates and deletes by
|
|
26
|
+
* row id, which are always safe.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface OfflineConfig {
|
|
30
|
+
/**
|
|
31
|
+
* Persistence backend. Defaults to IndexedDB in the browser and an
|
|
32
|
+
* in-memory store elsewhere; pass a custom implementation (e.g. backed by
|
|
33
|
+
* AsyncStorage in React Native) to persist in other environments.
|
|
34
|
+
*/
|
|
35
|
+
store?: OfflineStore;
|
|
36
|
+
/**
|
|
37
|
+
* Cached query results kept per collection; the least recently written
|
|
38
|
+
* are evicted beyond this. Defaults to 50.
|
|
39
|
+
*/
|
|
40
|
+
maxCachedQueriesPerCollection?: number;
|
|
41
|
+
/**
|
|
42
|
+
* How often to retry replaying the queue while it is non-empty, in
|
|
43
|
+
* milliseconds. The `online` browser event and a sign-in also trigger a
|
|
44
|
+
* replay; this timer is the safety net for environments with neither.
|
|
45
|
+
* Defaults to 30 000.
|
|
46
|
+
*/
|
|
47
|
+
syncIntervalMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Called when the server *rejects* a queued mutation during replay (a
|
|
50
|
+
* 4xx/5xx response — validation, RLS, a since-deleted row). The mutation
|
|
51
|
+
* is dropped: keeping it would jam the queue behind an error that will
|
|
52
|
+
* never resolve. Network failures are not errors — the mutation stays
|
|
53
|
+
* queued for the next attempt.
|
|
54
|
+
*/
|
|
55
|
+
onSyncError?: (error: Error, mutation: PendingMutation) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What `client.offline` exposes to the app. */
|
|
59
|
+
export interface OfflineApi {
|
|
60
|
+
/** Replay the queue now. Resolves with what was flushed and what remains. */
|
|
61
|
+
sync(): Promise<{ flushed: number; remaining: number }>;
|
|
62
|
+
/** The queued mutations for the current user, oldest first. */
|
|
63
|
+
pending(): Promise<PendingMutation[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Drop the current user's queued mutations AND cached reads. Destructive:
|
|
66
|
+
* queued writes are lost, not replayed. For "discard my offline changes"
|
|
67
|
+
* flows, not for sign-out (scoping already isolates users).
|
|
68
|
+
*/
|
|
69
|
+
clear(): Promise<void>;
|
|
70
|
+
/** Subscribe to queue-size changes (for a "pending changes" badge). */
|
|
71
|
+
onQueueChange(listener: (count: number) => void): () => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True for "the request never reached the server", false for a server reply. */
|
|
75
|
+
function isNetworkError(error: unknown): boolean {
|
|
76
|
+
if (error instanceof RebaseApiError) return false;
|
|
77
|
+
// fetch rejects with TypeError on network failure in every runtime we
|
|
78
|
+
// support (browsers: "Failed to fetch"/"Load failed"; undici: "fetch
|
|
79
|
+
// failed"). Anything else — a programming error — must propagate.
|
|
80
|
+
return error instanceof TypeError;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function generateOfflineId(): string {
|
|
84
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
85
|
+
return crypto.randomUUID();
|
|
86
|
+
}
|
|
87
|
+
// Non-cryptographic fallback for exotic runtimes; collision odds are
|
|
88
|
+
// irrelevant at offline-queue scale.
|
|
89
|
+
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A find is "appendable" when queued creates provably belong in its result set. */
|
|
93
|
+
function isAppendable(params?: FindParams): boolean {
|
|
94
|
+
return !params?.where && !params?.logical && !params?.searchString;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type AnyRow = Record<string, unknown>;
|
|
98
|
+
type InnerFactory = (slug: string) => SDKCollectionClient<AnyRow>;
|
|
99
|
+
|
|
100
|
+
export class OfflineManager {
|
|
101
|
+
private readonly store: OfflineStore;
|
|
102
|
+
private readonly maxCachedQueries: number;
|
|
103
|
+
private readonly onSyncError?: OfflineConfig["onSyncError"];
|
|
104
|
+
private readonly syncIntervalMs: number;
|
|
105
|
+
private readonly createInner: InnerFactory;
|
|
106
|
+
private readonly inners = new Map<string, SDKCollectionClient<AnyRow>>();
|
|
107
|
+
|
|
108
|
+
private scope = "anon";
|
|
109
|
+
/** In-memory mirror of the current scope's queue, kept in seq order. */
|
|
110
|
+
private queue: PendingMutation[] = [];
|
|
111
|
+
private loadPromise?: Promise<void>;
|
|
112
|
+
private nextSeq = 1;
|
|
113
|
+
/** Serializes enqueues so concurrent writes get distinct, ordered seqs. */
|
|
114
|
+
private enqueueChain: Promise<unknown> = Promise.resolve();
|
|
115
|
+
private flushPromise?: Promise<{ flushed: number; remaining: number }>;
|
|
116
|
+
private queueListeners = new Set<(count: number) => void>();
|
|
117
|
+
private syncTimer?: ReturnType<typeof setInterval>;
|
|
118
|
+
private readonly onOnline = () => { void this.sync().catch(() => undefined); };
|
|
119
|
+
|
|
120
|
+
readonly api: OfflineApi;
|
|
121
|
+
|
|
122
|
+
constructor(config: OfflineConfig, createInner: InnerFactory) {
|
|
123
|
+
this.store = config.store
|
|
124
|
+
?? (typeof indexedDB !== "undefined" ? new IndexedDBOfflineStore() : new MemoryOfflineStore());
|
|
125
|
+
this.maxCachedQueries = config.maxCachedQueriesPerCollection ?? 50;
|
|
126
|
+
this.syncIntervalMs = config.syncIntervalMs ?? 30_000;
|
|
127
|
+
this.onSyncError = config.onSyncError;
|
|
128
|
+
this.createInner = createInner;
|
|
129
|
+
|
|
130
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
131
|
+
window.addEventListener("online", this.onOnline);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.api = {
|
|
135
|
+
sync: () => this.sync(),
|
|
136
|
+
pending: async () => {
|
|
137
|
+
await this.ensureLoaded();
|
|
138
|
+
// Deep-copied: these are live queue entries (tail coalescing
|
|
139
|
+
// mutates them in place), and a caller must not be able to
|
|
140
|
+
// edit what will be replayed.
|
|
141
|
+
return this.queue.map((m) => structuredClone(m));
|
|
142
|
+
},
|
|
143
|
+
clear: async () => {
|
|
144
|
+
await this.store.clear(`${this.scope}|`);
|
|
145
|
+
this.queue = [];
|
|
146
|
+
this.notifyQueue();
|
|
147
|
+
},
|
|
148
|
+
onQueueChange: (listener) => {
|
|
149
|
+
this.queueListeners.add(listener);
|
|
150
|
+
return () => this.queueListeners.delete(listener);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Cache and queue are partitioned per signed-in user: cached rows are
|
|
157
|
+
* RLS-filtered for the user who fetched them, and queued writes must
|
|
158
|
+
* replay under the credentials that made them — so neither may ever leak
|
|
159
|
+
* across a sign-out/sign-in on a shared browser.
|
|
160
|
+
*/
|
|
161
|
+
setScope(uid: string | undefined): void {
|
|
162
|
+
const next = uid || "anon";
|
|
163
|
+
if (next === this.scope) return;
|
|
164
|
+
this.scope = next;
|
|
165
|
+
this.loadPromise = undefined;
|
|
166
|
+
this.queue = [];
|
|
167
|
+
// The returning user's queue may hold writes from a previous session.
|
|
168
|
+
void this.sync().catch(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Release the online listener and retry timer (client.close()). */
|
|
172
|
+
dispose(): void {
|
|
173
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
174
|
+
window.removeEventListener("online", this.onOnline);
|
|
175
|
+
}
|
|
176
|
+
this.stopTimer();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Collection wrapping ─────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
wrap<M extends AnyRow>(slug: string, inner: CollectionClient<M>): CollectionClient<M> {
|
|
182
|
+
this.inners.set(slug, inner as SDKCollectionClient<AnyRow>);
|
|
183
|
+
|
|
184
|
+
const wrapped: CollectionClient<M> = {
|
|
185
|
+
find: async (params?: FindParams): Promise<FindResult<M>> => {
|
|
186
|
+
try {
|
|
187
|
+
const res = await inner.find(params);
|
|
188
|
+
await this.cacheSet(this.findKey(slug, params), res);
|
|
189
|
+
return await this.overlayFind(slug, params, res);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (!isNetworkError(error)) throw error;
|
|
192
|
+
const cached = await this.cacheGet<FindResult<M>>(this.findKey(slug, params));
|
|
193
|
+
if (cached === undefined) throw error;
|
|
194
|
+
return this.overlayFind(slug, params, cached);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
findById: async (id: string | number) => {
|
|
199
|
+
try {
|
|
200
|
+
const row = await inner.findById(id);
|
|
201
|
+
if (row !== undefined) await this.cacheSet(this.rowKey(slug, id), row);
|
|
202
|
+
return await this.overlayRow(slug, id, row);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (!isNetworkError(error)) throw error;
|
|
205
|
+
const cached = await this.cacheGet<M>(this.rowKey(slug, id));
|
|
206
|
+
const { touched, row } = await this.composeRowFromQueue<M>(slug, id, cached);
|
|
207
|
+
if (!touched && cached === undefined) throw error;
|
|
208
|
+
return touched ? row : cached;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
create: async (data: Partial<M>, id?: string | number) => {
|
|
213
|
+
try {
|
|
214
|
+
return await inner.create(data, id);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (!isNetworkError(error)) throw error;
|
|
217
|
+
const providedId = id ?? (data as AnyRow).id as string | number | undefined;
|
|
218
|
+
const rowId = providedId ?? generateOfflineId();
|
|
219
|
+
const row = { ...(data as AnyRow), id: rowId } as unknown as M;
|
|
220
|
+
await this.enqueue({
|
|
221
|
+
collection: slug,
|
|
222
|
+
type: "create",
|
|
223
|
+
id: rowId,
|
|
224
|
+
data: row,
|
|
225
|
+
generatedId: providedId === undefined
|
|
226
|
+
});
|
|
227
|
+
await this.cacheSet(this.rowKey(slug, rowId), row);
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
createMany: async (data: Partial<M>[], options?: { upsert?: boolean }) => {
|
|
233
|
+
try {
|
|
234
|
+
return await inner.createMany(data, options);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!isNetworkError(error)) throw error;
|
|
237
|
+
if (!Array.isArray(data) || data.length === 0) return [];
|
|
238
|
+
const rows = data.map((r) => ({
|
|
239
|
+
...(r as AnyRow),
|
|
240
|
+
id: (r as AnyRow).id ?? generateOfflineId()
|
|
241
|
+
})) as unknown as M[];
|
|
242
|
+
await this.enqueue({
|
|
243
|
+
collection: slug,
|
|
244
|
+
type: "createMany",
|
|
245
|
+
data: rows,
|
|
246
|
+
upsert: options?.upsert
|
|
247
|
+
});
|
|
248
|
+
for (const row of rows) {
|
|
249
|
+
await this.cacheSet(this.rowKey(slug, row.id as string | number), row);
|
|
250
|
+
}
|
|
251
|
+
return rows;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
update: async (id: string | number, data: Partial<M>) => {
|
|
256
|
+
try {
|
|
257
|
+
const row = await inner.update(id, data);
|
|
258
|
+
await this.cacheSet(this.rowKey(slug, id), row);
|
|
259
|
+
return row;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!isNetworkError(error)) throw error;
|
|
262
|
+
await this.enqueue({ collection: slug, type: "update", id, data: data as AnyRow });
|
|
263
|
+
const base = await this.cacheGet<M>(this.rowKey(slug, id));
|
|
264
|
+
const optimistic = { ...(base ?? {}), ...(data as AnyRow), id } as unknown as M;
|
|
265
|
+
await this.cacheSet(this.rowKey(slug, id), optimistic);
|
|
266
|
+
return optimistic;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
delete: async (id: string | number) => {
|
|
271
|
+
try {
|
|
272
|
+
await inner.delete(id);
|
|
273
|
+
await this.cacheDelete(this.rowKey(slug, id));
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (!isNetworkError(error)) throw error;
|
|
276
|
+
await this.enqueue({ collection: slug, type: "delete", id });
|
|
277
|
+
await this.cacheDelete(this.rowKey(slug, id));
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
count: async (params?: FindParams): Promise<number> => {
|
|
282
|
+
try {
|
|
283
|
+
const n = await inner.count(params);
|
|
284
|
+
await this.cacheSet(this.countKey(slug, params), n);
|
|
285
|
+
return this.overlayCount(slug, params, n);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!isNetworkError(error)) throw error;
|
|
288
|
+
const cached = await this.cacheGet<number>(this.countKey(slug, params));
|
|
289
|
+
if (cached === undefined) throw error;
|
|
290
|
+
return this.overlayCount(slug, params, cached);
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// The builder calls back into `wrapped.find(...)`, so fluent
|
|
295
|
+
// queries go through the cache/overlay path like direct calls.
|
|
296
|
+
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
|
|
297
|
+
const builder = new SDKQueryBuilder<M>(wrapped);
|
|
298
|
+
if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
|
|
299
|
+
return builder.where(
|
|
300
|
+
columnOrCondition as keyof M & string,
|
|
301
|
+
operator!,
|
|
302
|
+
value as WhereValue<M[keyof M & string]>
|
|
303
|
+
);
|
|
304
|
+
},
|
|
305
|
+
orderBy: (column, direction) => new SDKQueryBuilder<M>(wrapped).orderBy(column, direction),
|
|
306
|
+
limit: (count) => new SDKQueryBuilder<M>(wrapped).limit(count),
|
|
307
|
+
offset: (count) => new SDKQueryBuilder<M>(wrapped).offset(count),
|
|
308
|
+
search: (searchString) => new SDKQueryBuilder<M>(wrapped).search(searchString),
|
|
309
|
+
include: (...relations) => new SDKQueryBuilder<M>(wrapped).include(...relations)
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Realtime passthrough: a live subscription is by definition online,
|
|
313
|
+
// so the overlay would only fight the server's own event stream.
|
|
314
|
+
if (inner.listen) wrapped.listen = inner.listen.bind(inner);
|
|
315
|
+
if (inner.listenById) wrapped.listenById = inner.listenById.bind(inner);
|
|
316
|
+
|
|
317
|
+
return wrapped;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Overlay: pending writes applied on top of a read ────────────────────
|
|
321
|
+
|
|
322
|
+
private async pendingFor(slug: string): Promise<PendingMutation[]> {
|
|
323
|
+
await this.ensureLoaded();
|
|
324
|
+
return this.queue.filter((m) => m.collection === slug);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async overlayFind<M extends AnyRow>(
|
|
328
|
+
slug: string,
|
|
329
|
+
params: FindParams | undefined,
|
|
330
|
+
result: FindResult<M>
|
|
331
|
+
): Promise<FindResult<M>> {
|
|
332
|
+
const ops = await this.pendingFor(slug);
|
|
333
|
+
if (ops.length === 0) return result;
|
|
334
|
+
|
|
335
|
+
const appendable = isAppendable(params);
|
|
336
|
+
let rows = result.data.map((r) => ({ ...r }));
|
|
337
|
+
let total = result.meta.total;
|
|
338
|
+
|
|
339
|
+
const applyCreate = (row: AnyRow) => {
|
|
340
|
+
if (!appendable) return;
|
|
341
|
+
if (rows.some((r) => r.id === row.id)) return;
|
|
342
|
+
rows.push({ ...row } as M);
|
|
343
|
+
total++;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
for (const op of ops) {
|
|
347
|
+
if (op.type === "create") {
|
|
348
|
+
applyCreate(op.data as AnyRow);
|
|
349
|
+
} else if (op.type === "createMany") {
|
|
350
|
+
for (const row of op.data as AnyRow[]) applyCreate(row);
|
|
351
|
+
} else if (op.type === "update") {
|
|
352
|
+
const idx = rows.findIndex((r) => r.id === op.id);
|
|
353
|
+
if (idx >= 0) rows[idx] = { ...rows[idx], ...(op.data as AnyRow) } as M;
|
|
354
|
+
} else if (op.type === "delete") {
|
|
355
|
+
const before = rows.length;
|
|
356
|
+
rows = rows.filter((r) => r.id !== op.id);
|
|
357
|
+
if (rows.length < before) total = Math.max(0, total - 1);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { data: rows, meta: { ...result.meta, total } };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async overlayCount(slug: string, params: FindParams | undefined, count: number): Promise<number> {
|
|
365
|
+
const ops = await this.pendingFor(slug);
|
|
366
|
+
if (ops.length === 0 || !isAppendable(params)) return count;
|
|
367
|
+
let n = count;
|
|
368
|
+
for (const op of ops) {
|
|
369
|
+
if (op.type === "create") n++;
|
|
370
|
+
else if (op.type === "createMany") n += (op.data as AnyRow[]).length;
|
|
371
|
+
else if (op.type === "delete") n = Math.max(0, n - 1);
|
|
372
|
+
}
|
|
373
|
+
return n;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Fold the queued ops for one row into a base value. `touched` separates
|
|
378
|
+
* "the queue says this row does not exist" (a pending delete → undefined)
|
|
379
|
+
* from "the queue has nothing to say" — the caller falls back differently.
|
|
380
|
+
*/
|
|
381
|
+
private async composeRowFromQueue<M extends AnyRow>(
|
|
382
|
+
slug: string,
|
|
383
|
+
id: string | number,
|
|
384
|
+
base: M | undefined
|
|
385
|
+
): Promise<{ touched: boolean; row: M | undefined }> {
|
|
386
|
+
const ops = await this.pendingFor(slug);
|
|
387
|
+
let touched = false;
|
|
388
|
+
let row = base;
|
|
389
|
+
for (const op of ops) {
|
|
390
|
+
if (op.type === "create" && op.id === id) {
|
|
391
|
+
row = { ...(op.data as AnyRow) } as M;
|
|
392
|
+
touched = true;
|
|
393
|
+
} else if (op.type === "createMany") {
|
|
394
|
+
const match = (op.data as AnyRow[]).find((r) => r.id === id);
|
|
395
|
+
if (match) { row = { ...match } as M; touched = true; }
|
|
396
|
+
} else if (op.type === "update" && op.id === id) {
|
|
397
|
+
row = { ...(row ?? {}), ...(op.data as AnyRow), id } as unknown as M;
|
|
398
|
+
touched = true;
|
|
399
|
+
} else if (op.type === "delete" && op.id === id) {
|
|
400
|
+
row = undefined;
|
|
401
|
+
touched = true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { touched, row };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private async overlayRow<M extends AnyRow>(slug: string, id: string | number, base: M | undefined): Promise<M | undefined> {
|
|
408
|
+
const { touched, row } = await this.composeRowFromQueue(slug, id, base);
|
|
409
|
+
return touched ? row : base;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── Queue ───────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
private ensureLoaded(): Promise<void> {
|
|
415
|
+
if (!this.loadPromise) {
|
|
416
|
+
const scope = this.scope;
|
|
417
|
+
this.loadPromise = this.store.listQueue(`${scope}|`).then((queue) => {
|
|
418
|
+
// A scope switch during the load must not graft the old
|
|
419
|
+
// user's queue onto the new one.
|
|
420
|
+
if (this.scope !== scope) return;
|
|
421
|
+
this.queue = queue;
|
|
422
|
+
this.nextSeq = queue.reduce((max, m) => Math.max(max, m.seq), 0) + 1;
|
|
423
|
+
if (queue.length > 0) this.startTimer();
|
|
424
|
+
this.notifyQueue();
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return this.loadPromise;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private enqueue(mutation: Omit<PendingMutation, "seq" | "queuedAt">): Promise<void> {
|
|
431
|
+
const result = this.enqueueChain.then(async () => {
|
|
432
|
+
await this.ensureLoaded();
|
|
433
|
+
|
|
434
|
+
// Tail coalescing: repeated edits to the most recently written row
|
|
435
|
+
// (typing in a form) collapse into the queued op instead of
|
|
436
|
+
// growing the queue. Only the queue *tail* may absorb an update —
|
|
437
|
+
// merging into an earlier op would move this write across ops
|
|
438
|
+
// queued after it, silently reordering what the app did.
|
|
439
|
+
if (mutation.type === "update") {
|
|
440
|
+
const tail = this.queue[this.queue.length - 1];
|
|
441
|
+
if (tail
|
|
442
|
+
&& tail.collection === mutation.collection
|
|
443
|
+
&& (tail.type === "create" || tail.type === "update")
|
|
444
|
+
&& tail.id === mutation.id) {
|
|
445
|
+
// The id must survive the merge: a queued create carries
|
|
446
|
+
// the client-generated id inside its data.
|
|
447
|
+
tail.data = { ...(tail.data as AnyRow), ...(mutation.data as AnyRow), id: tail.id };
|
|
448
|
+
await this.store.enqueue(this.queueKey(tail.seq), tail);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Cancel-out: deleting a row whose create is still queued — and
|
|
454
|
+
// whose id the SDK generated, so the server cannot already have a
|
|
455
|
+
// row under it — means the server never saw the row. Remove every
|
|
456
|
+
// queued op for it and queue nothing. Creates with caller-supplied
|
|
457
|
+
// ids do NOT cancel (the id may name an existing server row, which
|
|
458
|
+
// the delete must still remove), and neither do rows queued inside
|
|
459
|
+
// a createMany (the bulk op replays first, then the delete).
|
|
460
|
+
if (mutation.type === "delete") {
|
|
461
|
+
const hasPendingCreate = this.queue.some((m) =>
|
|
462
|
+
m.collection === mutation.collection && m.type === "create"
|
|
463
|
+
&& m.id === mutation.id && m.generatedId === true);
|
|
464
|
+
if (hasPendingCreate) {
|
|
465
|
+
const doomed = this.queue.filter((m) =>
|
|
466
|
+
m.collection === mutation.collection
|
|
467
|
+
&& m.id === mutation.id
|
|
468
|
+
&& (m.type === "create" || m.type === "update"));
|
|
469
|
+
for (const op of doomed) await this.store.dequeue(this.queueKey(op.seq));
|
|
470
|
+
this.queue = this.queue.filter((m) => !doomed.includes(m));
|
|
471
|
+
this.notifyQueue();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const full: PendingMutation = { ...mutation, seq: this.nextSeq++, queuedAt: Date.now() };
|
|
477
|
+
await this.store.enqueue(this.queueKey(full.seq), full);
|
|
478
|
+
this.queue.push(full);
|
|
479
|
+
this.startTimer();
|
|
480
|
+
this.notifyQueue();
|
|
481
|
+
});
|
|
482
|
+
// The chain must survive a failed enqueue, or every later write dies
|
|
483
|
+
// on the same stale rejection.
|
|
484
|
+
this.enqueueChain = result.catch(() => undefined);
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
sync(): Promise<{ flushed: number; remaining: number }> {
|
|
489
|
+
if (this.flushPromise) return this.flushPromise;
|
|
490
|
+
this.flushPromise = (async () => {
|
|
491
|
+
await this.ensureLoaded();
|
|
492
|
+
let flushed = 0;
|
|
493
|
+
while (this.queue.length > 0) {
|
|
494
|
+
const op = this.queue[0];
|
|
495
|
+
try {
|
|
496
|
+
await this.replay(op);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (isNetworkError(error)) break; // still offline — keep the op, retry later
|
|
499
|
+
// The server saw it and said no. Drop it — replaying can
|
|
500
|
+
// only fail the same way — and let the app know.
|
|
501
|
+
await this.drop(op);
|
|
502
|
+
this.onSyncError?.(error as Error, op);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
await this.drop(op);
|
|
506
|
+
flushed++;
|
|
507
|
+
}
|
|
508
|
+
if (this.queue.length === 0) this.stopTimer();
|
|
509
|
+
return { flushed, remaining: this.queue.length };
|
|
510
|
+
})().finally(() => { this.flushPromise = undefined; });
|
|
511
|
+
return this.flushPromise;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async replay(op: PendingMutation): Promise<void> {
|
|
515
|
+
const inner = this.innerFor(op.collection);
|
|
516
|
+
if (op.type === "create") {
|
|
517
|
+
// The queued row already carries its (client-generated) id.
|
|
518
|
+
const row = await inner.create(op.data as AnyRow);
|
|
519
|
+
await this.cacheSet(this.rowKey(op.collection, (row as AnyRow).id as string | number ?? op.id!), row);
|
|
520
|
+
} else if (op.type === "createMany") {
|
|
521
|
+
await inner.createMany(op.data as AnyRow[], op.upsert ? { upsert: true } : undefined);
|
|
522
|
+
} else if (op.type === "update") {
|
|
523
|
+
const row = await inner.update(op.id!, op.data as AnyRow);
|
|
524
|
+
await this.cacheSet(this.rowKey(op.collection, op.id!), row);
|
|
525
|
+
} else if (op.type === "delete") {
|
|
526
|
+
await inner.delete(op.id!);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private async drop(op: PendingMutation): Promise<void> {
|
|
531
|
+
await this.store.dequeue(this.queueKey(op.seq));
|
|
532
|
+
this.queue = this.queue.filter((m) => m.seq !== op.seq);
|
|
533
|
+
this.notifyQueue();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
|
|
537
|
+
private innerFor(slug: string): SDKCollectionClient<AnyRow> {
|
|
538
|
+
let inner = this.inners.get(slug);
|
|
539
|
+
if (!inner) {
|
|
540
|
+
inner = this.createInner(slug);
|
|
541
|
+
this.inners.set(slug, inner);
|
|
542
|
+
}
|
|
543
|
+
return inner;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private notifyQueue(): void {
|
|
547
|
+
for (const listener of this.queueListeners) listener(this.queue.length);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private startTimer(): void {
|
|
551
|
+
if (this.syncTimer || this.syncIntervalMs <= 0) return;
|
|
552
|
+
this.syncTimer = setInterval(() => {
|
|
553
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) return;
|
|
554
|
+
void this.sync().catch(() => undefined);
|
|
555
|
+
}, this.syncIntervalMs);
|
|
556
|
+
// In Node the timer must not keep a finished script alive.
|
|
557
|
+
(this.syncTimer as { unref?: () => void }).unref?.();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private stopTimer(): void {
|
|
561
|
+
if (this.syncTimer) {
|
|
562
|
+
clearInterval(this.syncTimer);
|
|
563
|
+
this.syncTimer = undefined;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ─── Cache ───────────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
private findKey(slug: string, params?: FindParams): string {
|
|
570
|
+
return `${this.scope}|find|${slug}|${buildQueryString(params)}`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private countKey(slug: string, params?: FindParams): string {
|
|
574
|
+
return `${this.scope}|count|${slug}|${buildQueryString(params)}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private rowKey(slug: string, id: string | number): string {
|
|
578
|
+
return `${this.scope}|row|${slug}|${String(id)}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private queueKey(seq: number): string {
|
|
582
|
+
// Padded so lexicographic key order in the store IS seq order.
|
|
583
|
+
return `${this.scope}|${String(seq).padStart(16, "0")}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private async cacheGet<T>(key: string): Promise<T | undefined> {
|
|
587
|
+
try {
|
|
588
|
+
const entry = await this.store.getCache(key);
|
|
589
|
+
return entry?.value as T | undefined;
|
|
590
|
+
} catch {
|
|
591
|
+
// A broken cache read must degrade to "no cache", never break the app.
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async cacheSet(key: string, value: unknown): Promise<void> {
|
|
597
|
+
try {
|
|
598
|
+
await this.store.setCache(key, { value, cachedAt: Date.now() });
|
|
599
|
+
if (key.startsWith(`${this.scope}|find|`)) {
|
|
600
|
+
const bucket = key.slice(0, key.lastIndexOf("|") + 1);
|
|
601
|
+
const entries = await this.store.listCache(bucket);
|
|
602
|
+
if (entries.length > this.maxCachedQueries) {
|
|
603
|
+
entries.sort((a, b) => a.cachedAt - b.cachedAt);
|
|
604
|
+
await this.store.deleteCache(
|
|
605
|
+
entries.slice(0, entries.length - this.maxCachedQueries).map((e) => e.key)
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
// Quota errors and private-browsing restrictions must not fail the read/write that got us here.
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async cacheDelete(key: string): Promise<void> {
|
|
615
|
+
try {
|
|
616
|
+
await this.store.deleteCache([key]);
|
|
617
|
+
} catch {
|
|
618
|
+
// Same rationale as cacheSet.
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|