@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,109 @@
|
|
|
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
|
+
/** A cached value plus the moment it was written, for LRU eviction. */
|
|
20
|
+
export interface OfflineCacheEntry {
|
|
21
|
+
value: unknown;
|
|
22
|
+
cachedAt: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A write made while offline, waiting to be replayed against the server.
|
|
26
|
+
*
|
|
27
|
+
* `seq` orders the queue globally (not per collection): a create in one
|
|
28
|
+
* collection may be the parent a later insert in another references, so
|
|
29
|
+
* replay must preserve the order the app issued the writes in.
|
|
30
|
+
*/
|
|
31
|
+
export interface PendingMutation {
|
|
32
|
+
seq: number;
|
|
33
|
+
collection: string;
|
|
34
|
+
type: "create" | "createMany" | "update" | "delete";
|
|
35
|
+
/** Target row id for update/delete, and the (client-generated) id of an offline create. */
|
|
36
|
+
id?: string | number;
|
|
37
|
+
/**
|
|
38
|
+
* True when the SDK minted this create's id itself. Only such creates may
|
|
39
|
+
* cancel out against a later offline delete: a freshly generated UUID
|
|
40
|
+
* cannot name a row the server already has, while a caller-supplied id
|
|
41
|
+
* can — and there the delete must still replay to remove the server row.
|
|
42
|
+
*/
|
|
43
|
+
generatedId?: boolean;
|
|
44
|
+
/** The payload: a row for create/update, an array of rows for createMany. */
|
|
45
|
+
data?: Record<string, unknown> | Record<string, unknown>[];
|
|
46
|
+
upsert?: boolean;
|
|
47
|
+
queuedAt: number;
|
|
48
|
+
}
|
|
49
|
+
export interface OfflineStore {
|
|
50
|
+
getCache(key: string): Promise<OfflineCacheEntry | undefined>;
|
|
51
|
+
setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
|
|
52
|
+
deleteCache(keys: string[]): Promise<void>;
|
|
53
|
+
/** Every cache key starting with `prefix`, with its write time (for eviction). */
|
|
54
|
+
listCache(prefix: string): Promise<{
|
|
55
|
+
key: string;
|
|
56
|
+
cachedAt: number;
|
|
57
|
+
}[]>;
|
|
58
|
+
enqueue(key: string, mutation: PendingMutation): Promise<void>;
|
|
59
|
+
dequeue(key: string): Promise<void>;
|
|
60
|
+
/** Queued mutations whose key starts with `prefix`, in lexicographic key order. */
|
|
61
|
+
listQueue(prefix: string): Promise<PendingMutation[]>;
|
|
62
|
+
/** Remove every cache entry and queued mutation whose key starts with `prefix`. */
|
|
63
|
+
clear(prefix: string): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* In-memory store: the default outside the browser and the workhorse of the
|
|
67
|
+
* test suite. Values are deep-copied on the way in and out so a caller
|
|
68
|
+
* mutating a returned row cannot silently edit the "persisted" copy — the
|
|
69
|
+
* IndexedDB implementation gets the same guarantee for free from structured
|
|
70
|
+
* cloning, and the two must not differ in aliasing behaviour.
|
|
71
|
+
*/
|
|
72
|
+
export declare class MemoryOfflineStore implements OfflineStore {
|
|
73
|
+
private cache;
|
|
74
|
+
private queue;
|
|
75
|
+
getCache(key: string): Promise<OfflineCacheEntry | undefined>;
|
|
76
|
+
setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
|
|
77
|
+
deleteCache(keys: string[]): Promise<void>;
|
|
78
|
+
listCache(prefix: string): Promise<{
|
|
79
|
+
key: string;
|
|
80
|
+
cachedAt: number;
|
|
81
|
+
}[]>;
|
|
82
|
+
enqueue(key: string, mutation: PendingMutation): Promise<void>;
|
|
83
|
+
dequeue(key: string): Promise<void>;
|
|
84
|
+
listQueue(prefix: string): Promise<PendingMutation[]>;
|
|
85
|
+
clear(prefix: string): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* IndexedDB-backed store — the browser default, so cached reads and queued
|
|
89
|
+
* writes survive a reload or a browser restart. Everything lives in one
|
|
90
|
+
* database with two object stores; keys are the manager's full prefixed
|
|
91
|
+
* strings, so multiple users (scopes) share the database without ever
|
|
92
|
+
* sharing entries.
|
|
93
|
+
*/
|
|
94
|
+
export declare class IndexedDBOfflineStore implements OfflineStore {
|
|
95
|
+
private dbPromise?;
|
|
96
|
+
private open;
|
|
97
|
+
private store;
|
|
98
|
+
getCache(key: string): Promise<OfflineCacheEntry | undefined>;
|
|
99
|
+
setCache(key: string, entry: OfflineCacheEntry): Promise<void>;
|
|
100
|
+
deleteCache(keys: string[]): Promise<void>;
|
|
101
|
+
listCache(prefix: string): Promise<{
|
|
102
|
+
key: string;
|
|
103
|
+
cachedAt: number;
|
|
104
|
+
}[]>;
|
|
105
|
+
enqueue(key: string, mutation: PendingMutation): Promise<void>;
|
|
106
|
+
dequeue(key: string): Promise<void>;
|
|
107
|
+
listQueue(prefix: string): Promise<PendingMutation[]>;
|
|
108
|
+
clear(prefix: string): Promise<void>;
|
|
109
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { SDKCollectionClient } from "@rebasepro/types";
|
|
2
|
+
import { CollectionClient } from "./collection";
|
|
3
|
+
import { OfflineStore, PendingMutation } from "./offline-store";
|
|
4
|
+
/**
|
|
5
|
+
* Offline support for the SDK data layer.
|
|
6
|
+
*
|
|
7
|
+
* Reads are network-first: a successful `find`/`findById`/`count` refreshes a
|
|
8
|
+
* local cache, and a network failure falls back to the last cached answer.
|
|
9
|
+
* Writes made while offline are queued and replayed, in the order the app
|
|
10
|
+
* issued them, when connectivity returns. Until then every read — cached or
|
|
11
|
+
* fresh — has the queued writes overlaid, so the app sees its own changes
|
|
12
|
+
* immediately instead of watching them flicker away on the next fetch.
|
|
13
|
+
*
|
|
14
|
+
* What this deliberately is NOT (yet): a local query engine. A cached query
|
|
15
|
+
* is only ever answered for the exact same parameters, and queued creates are
|
|
16
|
+
* folded into filtered queries' totals never — we cannot evaluate a `where`
|
|
17
|
+
* clause client-side, so filtered results only overlay updates and deletes by
|
|
18
|
+
* row id, which are always safe.
|
|
19
|
+
*/
|
|
20
|
+
export interface OfflineConfig {
|
|
21
|
+
/**
|
|
22
|
+
* Persistence backend. Defaults to IndexedDB in the browser and an
|
|
23
|
+
* in-memory store elsewhere; pass a custom implementation (e.g. backed by
|
|
24
|
+
* AsyncStorage in React Native) to persist in other environments.
|
|
25
|
+
*/
|
|
26
|
+
store?: OfflineStore;
|
|
27
|
+
/**
|
|
28
|
+
* Cached query results kept per collection; the least recently written
|
|
29
|
+
* are evicted beyond this. Defaults to 50.
|
|
30
|
+
*/
|
|
31
|
+
maxCachedQueriesPerCollection?: number;
|
|
32
|
+
/**
|
|
33
|
+
* How often to retry replaying the queue while it is non-empty, in
|
|
34
|
+
* milliseconds. The `online` browser event and a sign-in also trigger a
|
|
35
|
+
* replay; this timer is the safety net for environments with neither.
|
|
36
|
+
* Defaults to 30 000.
|
|
37
|
+
*/
|
|
38
|
+
syncIntervalMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Called when the server *rejects* a queued mutation during replay (a
|
|
41
|
+
* 4xx/5xx response — validation, RLS, a since-deleted row). The mutation
|
|
42
|
+
* is dropped: keeping it would jam the queue behind an error that will
|
|
43
|
+
* never resolve. Network failures are not errors — the mutation stays
|
|
44
|
+
* queued for the next attempt.
|
|
45
|
+
*/
|
|
46
|
+
onSyncError?: (error: Error, mutation: PendingMutation) => void;
|
|
47
|
+
}
|
|
48
|
+
/** What `client.offline` exposes to the app. */
|
|
49
|
+
export interface OfflineApi {
|
|
50
|
+
/** Replay the queue now. Resolves with what was flushed and what remains. */
|
|
51
|
+
sync(): Promise<{
|
|
52
|
+
flushed: number;
|
|
53
|
+
remaining: number;
|
|
54
|
+
}>;
|
|
55
|
+
/** The queued mutations for the current user, oldest first. */
|
|
56
|
+
pending(): Promise<PendingMutation[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Drop the current user's queued mutations AND cached reads. Destructive:
|
|
59
|
+
* queued writes are lost, not replayed. For "discard my offline changes"
|
|
60
|
+
* flows, not for sign-out (scoping already isolates users).
|
|
61
|
+
*/
|
|
62
|
+
clear(): Promise<void>;
|
|
63
|
+
/** Subscribe to queue-size changes (for a "pending changes" badge). */
|
|
64
|
+
onQueueChange(listener: (count: number) => void): () => void;
|
|
65
|
+
}
|
|
66
|
+
type AnyRow = Record<string, unknown>;
|
|
67
|
+
type InnerFactory = (slug: string) => SDKCollectionClient<AnyRow>;
|
|
68
|
+
export declare class OfflineManager {
|
|
69
|
+
private readonly store;
|
|
70
|
+
private readonly maxCachedQueries;
|
|
71
|
+
private readonly onSyncError?;
|
|
72
|
+
private readonly syncIntervalMs;
|
|
73
|
+
private readonly createInner;
|
|
74
|
+
private readonly inners;
|
|
75
|
+
private scope;
|
|
76
|
+
/** In-memory mirror of the current scope's queue, kept in seq order. */
|
|
77
|
+
private queue;
|
|
78
|
+
private loadPromise?;
|
|
79
|
+
private nextSeq;
|
|
80
|
+
/** Serializes enqueues so concurrent writes get distinct, ordered seqs. */
|
|
81
|
+
private enqueueChain;
|
|
82
|
+
private flushPromise?;
|
|
83
|
+
private queueListeners;
|
|
84
|
+
private syncTimer?;
|
|
85
|
+
private readonly onOnline;
|
|
86
|
+
readonly api: OfflineApi;
|
|
87
|
+
constructor(config: OfflineConfig, createInner: InnerFactory);
|
|
88
|
+
/**
|
|
89
|
+
* Cache and queue are partitioned per signed-in user: cached rows are
|
|
90
|
+
* RLS-filtered for the user who fetched them, and queued writes must
|
|
91
|
+
* replay under the credentials that made them — so neither may ever leak
|
|
92
|
+
* across a sign-out/sign-in on a shared browser.
|
|
93
|
+
*/
|
|
94
|
+
setScope(uid: string | undefined): void;
|
|
95
|
+
/** Release the online listener and retry timer (client.close()). */
|
|
96
|
+
dispose(): void;
|
|
97
|
+
wrap<M extends AnyRow>(slug: string, inner: CollectionClient<M>): CollectionClient<M>;
|
|
98
|
+
private pendingFor;
|
|
99
|
+
private overlayFind;
|
|
100
|
+
private overlayCount;
|
|
101
|
+
/**
|
|
102
|
+
* Fold the queued ops for one row into a base value. `touched` separates
|
|
103
|
+
* "the queue says this row does not exist" (a pending delete → undefined)
|
|
104
|
+
* from "the queue has nothing to say" — the caller falls back differently.
|
|
105
|
+
*/
|
|
106
|
+
private composeRowFromQueue;
|
|
107
|
+
private overlayRow;
|
|
108
|
+
private ensureLoaded;
|
|
109
|
+
private enqueue;
|
|
110
|
+
sync(): Promise<{
|
|
111
|
+
flushed: number;
|
|
112
|
+
remaining: number;
|
|
113
|
+
}>;
|
|
114
|
+
private replay;
|
|
115
|
+
private drop;
|
|
116
|
+
/** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
|
|
117
|
+
private innerFor;
|
|
118
|
+
private notifyQueue;
|
|
119
|
+
private startTimer;
|
|
120
|
+
private stopTimer;
|
|
121
|
+
private findKey;
|
|
122
|
+
private countKey;
|
|
123
|
+
private rowKey;
|
|
124
|
+
private queueKey;
|
|
125
|
+
private cacheGet;
|
|
126
|
+
private cacheSet;
|
|
127
|
+
private cacheDelete;
|
|
128
|
+
}
|
|
129
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.1-canary.a54c057",
|
|
5
5
|
"description": "HTTP SDK client for the Rebase custom backend",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"./package.json": "./package.json"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@rebasepro/
|
|
33
|
-
"@rebasepro/types": "0.10.
|
|
34
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/common": "0.10.1-canary.a54c057",
|
|
33
|
+
"@rebasepro/types": "0.10.1-canary.a54c057",
|
|
34
|
+
"@rebasepro/utils": "0.10.1-canary.a54c057"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@jest/globals": "^30.4.1",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@types/node": "^25.9.3",
|
|
40
40
|
"@types/ws": "^8.18.1",
|
|
41
41
|
"cross-env": "^10.1.0",
|
|
42
|
+
"fake-indexeddb": "^6.2.5",
|
|
42
43
|
"jest": "^30.4.2",
|
|
43
44
|
"ts-jest": "^29.4.11",
|
|
44
45
|
"tsd": "^0.33.0",
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createStorage } from "./storage";
|
|
|
11
11
|
import { ClientStorageSourceRegistry } from "./storage-registry";
|
|
12
12
|
import { RebaseWebSocketClient } from "./websocket";
|
|
13
13
|
import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel";
|
|
14
|
+
import { OfflineManager, type OfflineApi, type OfflineConfig } from "./offline";
|
|
14
15
|
import {
|
|
15
16
|
DEFAULT_STORAGE_SOURCE_KEY,
|
|
16
17
|
InsertOf,
|
|
@@ -88,6 +89,15 @@ export type {
|
|
|
88
89
|
ChannelHistoryResult
|
|
89
90
|
} from "./realtime-channel";
|
|
90
91
|
|
|
92
|
+
// Offline: config + the `client.offline` surface, plus the store contract so
|
|
93
|
+
// other environments (React Native/AsyncStorage, Electron, …) can supply
|
|
94
|
+
// their own persistence. `MemoryOfflineStore` is exported for tests and as
|
|
95
|
+
// the reference implementation; the IndexedDB store is wired automatically
|
|
96
|
+
// in the browser and needs no direct construction.
|
|
97
|
+
export type { OfflineApi, OfflineConfig } from "./offline";
|
|
98
|
+
export type { OfflineStore, OfflineCacheEntry, PendingMutation } from "./offline-store";
|
|
99
|
+
export { MemoryOfflineStore } from "./offline-store";
|
|
100
|
+
|
|
91
101
|
export interface CreateRebaseClientOptions extends RebaseClientConfig {
|
|
92
102
|
auth?: CreateAuthOptions;
|
|
93
103
|
admin?: CreateAdminOptions;
|
|
@@ -108,6 +118,15 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
|
|
|
108
118
|
* correct slugs via this map before falling back to automatic snake_casing.
|
|
109
119
|
*/
|
|
110
120
|
collections?: Record<string, string>;
|
|
121
|
+
/**
|
|
122
|
+
* Offline support for the data layer. `true` enables it with defaults
|
|
123
|
+
* (network-first reads served from cache on network failure, writes
|
|
124
|
+
* queued and replayed when connectivity returns); pass an
|
|
125
|
+
* {@link OfflineConfig} to control the store, cache size, or sync error
|
|
126
|
+
* handling. Cached reads and queued writes are partitioned per signed-in
|
|
127
|
+
* user. Off by default.
|
|
128
|
+
*/
|
|
129
|
+
offline?: boolean | OfflineConfig;
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
// ─── Typed Data Proxy ────────────────────────────────────────────────────────
|
|
@@ -186,6 +205,8 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
|
|
|
186
205
|
call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
|
|
187
206
|
collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
|
|
188
207
|
data: TypedDataLayer<DB>;
|
|
208
|
+
/** Present only when the client was created with `offline` enabled. */
|
|
209
|
+
offline?: OfflineApi;
|
|
189
210
|
};
|
|
190
211
|
|
|
191
212
|
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
@@ -398,12 +419,33 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
398
419
|
return undefined;
|
|
399
420
|
}
|
|
400
421
|
|
|
422
|
+
// Offline layer: wraps every collection client with a read cache and a
|
|
423
|
+
// write queue. Replay goes through *unwrapped* clients (the factory below)
|
|
424
|
+
// so a failing replay can never re-queue itself.
|
|
425
|
+
const offlineManager = options.offline
|
|
426
|
+
? new OfflineManager(
|
|
427
|
+
typeof options.offline === "object" ? options.offline : {},
|
|
428
|
+
(slug) => createCollectionClient(transport, slug)
|
|
429
|
+
)
|
|
430
|
+
: undefined;
|
|
431
|
+
|
|
432
|
+
if (offlineManager) {
|
|
433
|
+
// Cache and queue are partitioned per user: cached rows are RLS-scoped
|
|
434
|
+
// to whoever fetched them, and queued writes must replay as the user
|
|
435
|
+
// who made them — a shared browser must never mix the two.
|
|
436
|
+
offlineManager.setScope(auth.getSession()?.user?.uid);
|
|
437
|
+
auth.onAuthStateChange((event, session) => {
|
|
438
|
+
offlineManager.setScope(event === "SIGNED_OUT" ? undefined : session?.user?.uid);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
401
442
|
const collectionClients = new Map<string, CollectionClient<Record<string, unknown>>>();
|
|
402
443
|
let untypedWarned = false;
|
|
403
444
|
|
|
404
445
|
function collection(slug: string): CollectionClient<Record<string, unknown>> {
|
|
405
446
|
if (!collectionClients.has(slug)) {
|
|
406
|
-
|
|
447
|
+
const inner = createCollectionClient(transport, slug, ws);
|
|
448
|
+
collectionClients.set(slug, offlineManager ? offlineManager.wrap(slug, inner) : inner);
|
|
407
449
|
}
|
|
408
450
|
return collectionClients.get(slug)!;
|
|
409
451
|
}
|
|
@@ -510,6 +552,9 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
510
552
|
// Permanent: nothing queued afterwards may redial and keep the
|
|
511
553
|
// event loop alive, which is the reason this method exists.
|
|
512
554
|
ws?.disconnect(true);
|
|
555
|
+
// The offline retry timer is unref'd but the `online` listener is
|
|
556
|
+
// not, and neither should outlive the client.
|
|
557
|
+
offlineManager?.dispose();
|
|
513
558
|
},
|
|
514
559
|
setToken: transport.setToken,
|
|
515
560
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
@@ -526,6 +571,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
526
571
|
return res.data ?? (res as T);
|
|
527
572
|
},
|
|
528
573
|
data: dataProxy,
|
|
574
|
+
...(offlineManager ? { offline: offlineManager.api } : {}),
|
|
529
575
|
} as unknown as CreateRebaseClientResult<DB>;
|
|
530
576
|
|
|
531
577
|
return target;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import "fake-indexeddb/auto";
|
|
2
|
+
import { IndexedDBOfflineStore, PendingMutation } from "./offline-store";
|
|
3
|
+
import { OfflineManager } from "./offline";
|
|
4
|
+
import { RebaseApiError } from "./transport";
|
|
5
|
+
import type { CollectionClient } from "./collection";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The IndexedDB store against fake-indexeddb — the same structured-clone
|
|
9
|
+
* semantics and key ordering as a real browser, without one. All tests share
|
|
10
|
+
* one fake database (like real tabs share one), so each uses its own key
|
|
11
|
+
* prefix; the prefix isolation test is exactly what makes that safe.
|
|
12
|
+
*/
|
|
13
|
+
describe("IndexedDBOfflineStore", () => {
|
|
14
|
+
const store = new IndexedDBOfflineStore();
|
|
15
|
+
|
|
16
|
+
function mutation(scope: string, seq: number, extra: Partial<PendingMutation> = {}): PendingMutation {
|
|
17
|
+
return {
|
|
18
|
+
seq,
|
|
19
|
+
collection: "posts",
|
|
20
|
+
type: "create",
|
|
21
|
+
id: `id-${seq}`,
|
|
22
|
+
data: { id: `id-${seq}` },
|
|
23
|
+
queuedAt: Date.now(),
|
|
24
|
+
...extra
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function queueKey(scope: string, seq: number): string {
|
|
29
|
+
return `${scope}|${String(seq).padStart(16, "0")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it("round-trips cache entries, including Date values", async () => {
|
|
33
|
+
const createdAt = new Date("2026-07-01T12:00:00Z");
|
|
34
|
+
await store.setCache("t1|find|posts|", { value: { data: [{ id: "p1", createdAt }] }, cachedAt: 111 });
|
|
35
|
+
|
|
36
|
+
const entry = await store.getCache("t1|find|posts|");
|
|
37
|
+
expect(entry?.cachedAt).toBe(111);
|
|
38
|
+
const row = (entry?.value as { data: { id: string; createdAt: Date }[] }).data[0];
|
|
39
|
+
// Not toBeInstanceOf: fake-indexeddb clones through another realm, so
|
|
40
|
+
// the constructor identity differs while the value is a real Date.
|
|
41
|
+
expect(Object.prototype.toString.call(row.createdAt)).toBe("[object Date]");
|
|
42
|
+
expect(row.createdAt.toISOString()).toBe(createdAt.toISOString());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns undefined for a missing cache key", async () => {
|
|
46
|
+
expect(await store.getCache("t2|nope")).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("lists only the keys under a prefix, with their write times", async () => {
|
|
50
|
+
await store.setCache("t3|find|posts|a", { value: 1, cachedAt: 10 });
|
|
51
|
+
await store.setCache("t3|find|posts|b", { value: 2, cachedAt: 20 });
|
|
52
|
+
await store.setCache("t3|find|users|a", { value: 3, cachedAt: 30 });
|
|
53
|
+
|
|
54
|
+
const listed = await store.listCache("t3|find|posts|");
|
|
55
|
+
expect(listed.map((e) => e.key).sort()).toEqual(["t3|find|posts|a", "t3|find|posts|b"]);
|
|
56
|
+
expect(listed.find((e) => e.key.endsWith("|a"))?.cachedAt).toBe(10);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("deletes several cache keys at once", async () => {
|
|
60
|
+
await store.setCache("t4|x", { value: 1, cachedAt: 1 });
|
|
61
|
+
await store.setCache("t4|y", { value: 2, cachedAt: 2 });
|
|
62
|
+
await store.deleteCache(["t4|x", "t4|y"]);
|
|
63
|
+
expect(await store.getCache("t4|x")).toBeUndefined();
|
|
64
|
+
expect(await store.getCache("t4|y")).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns the queue in seq order even when written out of order", async () => {
|
|
68
|
+
await store.enqueue(queueKey("t5", 2), mutation("t5", 2));
|
|
69
|
+
await store.enqueue(queueKey("t5", 10), mutation("t5", 10));
|
|
70
|
+
await store.enqueue(queueKey("t5", 1), mutation("t5", 1));
|
|
71
|
+
|
|
72
|
+
const queue = await store.listQueue("t5|");
|
|
73
|
+
expect(queue.map((m) => m.seq)).toEqual([1, 2, 10]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("dequeues a single mutation", async () => {
|
|
77
|
+
await store.enqueue(queueKey("t6", 1), mutation("t6", 1));
|
|
78
|
+
await store.enqueue(queueKey("t6", 2), mutation("t6", 2));
|
|
79
|
+
await store.dequeue(queueKey("t6", 1));
|
|
80
|
+
expect((await store.listQueue("t6|")).map((m) => m.seq)).toEqual([2]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("clear() removes one prefix's cache and queue and nothing else", async () => {
|
|
84
|
+
await store.setCache("t7a|find|posts|", { value: 1, cachedAt: 1 });
|
|
85
|
+
await store.enqueue(queueKey("t7a", 1), mutation("t7a", 1));
|
|
86
|
+
await store.setCache("t7b|find|posts|", { value: 2, cachedAt: 2 });
|
|
87
|
+
await store.enqueue(queueKey("t7b", 1), mutation("t7b", 1));
|
|
88
|
+
|
|
89
|
+
await store.clear("t7a|");
|
|
90
|
+
|
|
91
|
+
expect(await store.getCache("t7a|find|posts|")).toBeUndefined();
|
|
92
|
+
expect(await store.listQueue("t7a|")).toHaveLength(0);
|
|
93
|
+
expect(await store.getCache("t7b|find|posts|")).toBeDefined();
|
|
94
|
+
expect(await store.listQueue("t7b|")).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("persists across store instances", async () => {
|
|
98
|
+
await store.setCache("t8|find|posts|", { value: 42, cachedAt: 1 });
|
|
99
|
+
await store.enqueue(queueKey("t8", 1), mutation("t8", 1));
|
|
100
|
+
|
|
101
|
+
const reopened = new IndexedDBOfflineStore();
|
|
102
|
+
expect((await reopened.getCache("t8|find|posts|"))?.value).toBe(42);
|
|
103
|
+
expect(await reopened.listQueue("t8|")).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("OfflineManager over IndexedDB", () => {
|
|
108
|
+
it("queues offline writes in one manager and replays them from a fresh one", async () => {
|
|
109
|
+
const table = new Map<string, Record<string, unknown>>();
|
|
110
|
+
const state = { online: true };
|
|
111
|
+
const fakeClient = {
|
|
112
|
+
async find() {
|
|
113
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
114
|
+
const data = [...table.values()];
|
|
115
|
+
return { data, meta: { total: data.length, limit: 20, offset: 0, hasMore: false } };
|
|
116
|
+
},
|
|
117
|
+
async findById(id: string | number) {
|
|
118
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
119
|
+
return table.get(String(id));
|
|
120
|
+
},
|
|
121
|
+
async create(data: Record<string, unknown>) {
|
|
122
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
123
|
+
table.set(String(data.id), data);
|
|
124
|
+
return data;
|
|
125
|
+
},
|
|
126
|
+
async createMany() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
127
|
+
async update() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
128
|
+
async delete() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
129
|
+
async count() { return table.size; }
|
|
130
|
+
} as unknown as CollectionClient<Record<string, unknown>>;
|
|
131
|
+
|
|
132
|
+
const first = new OfflineManager(
|
|
133
|
+
{ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
|
|
134
|
+
() => fakeClient
|
|
135
|
+
);
|
|
136
|
+
state.online = false;
|
|
137
|
+
const posts = first.wrap("posts", fakeClient);
|
|
138
|
+
const row = await posts.create({ title: "persisted offline" });
|
|
139
|
+
expect(await posts.findById(row.id as string)).toMatchObject({ title: "persisted offline" });
|
|
140
|
+
first.dispose();
|
|
141
|
+
|
|
142
|
+
state.online = true;
|
|
143
|
+
const second = new OfflineManager(
|
|
144
|
+
{ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
|
|
145
|
+
() => fakeClient
|
|
146
|
+
);
|
|
147
|
+
expect(await second.sync()).toEqual({ flushed: 1, remaining: 0 });
|
|
148
|
+
expect(table.get(String(row.id))).toMatchObject({ title: "persisted offline" });
|
|
149
|
+
second.dispose();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createRebaseClient, RebaseApiError } from "./index";
|
|
2
|
+
import { MemoryOfflineStore } from "./offline-store";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* End-to-end wiring test: a real client (real transport, real collection
|
|
6
|
+
* client, real offline manager) against a fetch stub, proving the `offline`
|
|
7
|
+
* option actually threads through `createRebaseClient` — the unit suite in
|
|
8
|
+
* offline.test.ts covers the manager against fakes, not this plumbing.
|
|
9
|
+
*/
|
|
10
|
+
describe("createRebaseClient({ offline })", () => {
|
|
11
|
+
function jsonResponse(body: unknown): Response {
|
|
12
|
+
return new Response(JSON.stringify(body), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { "Content-Type": "application/json" }
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createHarness(offline: boolean | { store: MemoryOfflineStore; syncIntervalMs: number } =
|
|
19
|
+
{ store: new MemoryOfflineStore(), syncIntervalMs: 0 }) {
|
|
20
|
+
const network = { online: true, serverError: false };
|
|
21
|
+
const requests: { method: string; url: string; body?: unknown }[] = [];
|
|
22
|
+
const fetchStub: typeof fetch = async (input, init) => {
|
|
23
|
+
const url = String(input);
|
|
24
|
+
if (!network.online) throw new TypeError("fetch failed");
|
|
25
|
+
if (network.serverError) {
|
|
26
|
+
return new Response(JSON.stringify({ error: { message: "boom", code: "internal" } }), {
|
|
27
|
+
status: 500,
|
|
28
|
+
headers: { "Content-Type": "application/json" }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
requests.push({
|
|
32
|
+
method: init?.method ?? "GET",
|
|
33
|
+
url,
|
|
34
|
+
body: init?.body ? JSON.parse(String(init.body)) : undefined
|
|
35
|
+
});
|
|
36
|
+
if (url.includes("/data/posts/count")) return jsonResponse({ count: 1 });
|
|
37
|
+
if (url.includes("/data/posts") && (init?.method ?? "GET") === "GET") {
|
|
38
|
+
return jsonResponse({
|
|
39
|
+
data: [{ id: "p1", title: "hello" }],
|
|
40
|
+
meta: { total: 1, limit: 20, offset: 0, hasMore: false }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (url.includes("/data/posts") && init?.method === "POST") {
|
|
44
|
+
return jsonResponse(JSON.parse(String(init?.body)));
|
|
45
|
+
}
|
|
46
|
+
return jsonResponse({});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const client = createRebaseClient({
|
|
50
|
+
baseUrl: "http://localhost:9999",
|
|
51
|
+
realtime: false,
|
|
52
|
+
fetch: fetchStub,
|
|
53
|
+
offline
|
|
54
|
+
});
|
|
55
|
+
return { client, network, requests };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it("exposes client.offline only when enabled", () => {
|
|
59
|
+
const { client } = createHarness();
|
|
60
|
+
expect(client.offline).toBeDefined();
|
|
61
|
+
|
|
62
|
+
const plain = createRebaseClient({
|
|
63
|
+
baseUrl: "http://localhost:9999",
|
|
64
|
+
realtime: false
|
|
65
|
+
});
|
|
66
|
+
expect(plain.offline).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("serves cached reads and queues writes through the data proxy, then syncs", async () => {
|
|
70
|
+
const { client, network, requests } = createHarness();
|
|
71
|
+
const posts = client.data.collection("posts");
|
|
72
|
+
|
|
73
|
+
const online = await posts.find();
|
|
74
|
+
expect(online.data).toEqual([{ id: "p1", title: "hello" }]);
|
|
75
|
+
|
|
76
|
+
network.online = false;
|
|
77
|
+
const cached = await posts.find();
|
|
78
|
+
expect(cached.data).toEqual([{ id: "p1", title: "hello" }]);
|
|
79
|
+
|
|
80
|
+
const draft = await posts.create({ title: "offline draft" });
|
|
81
|
+
expect(draft.title).toBe("offline draft");
|
|
82
|
+
expect(await client.offline!.pending()).toHaveLength(1);
|
|
83
|
+
|
|
84
|
+
// The pending create is visible in the cached, filterless find.
|
|
85
|
+
const overlaid = await posts.find();
|
|
86
|
+
expect(overlaid.data.map((r) => r.title)).toEqual(["hello", "offline draft"]);
|
|
87
|
+
expect(overlaid.meta.total).toBe(2);
|
|
88
|
+
|
|
89
|
+
network.online = true;
|
|
90
|
+
const result = await client.offline!.sync();
|
|
91
|
+
expect(result).toEqual({ flushed: 1, remaining: 0 });
|
|
92
|
+
|
|
93
|
+
const replayed = requests.find((r) => r.method === "POST" && r.url.endsWith("/data/posts"));
|
|
94
|
+
expect(replayed?.body).toMatchObject({ title: "offline draft", id: draft.id });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("offline: true works in Node via the in-memory fallback store", async () => {
|
|
98
|
+
const { client, network } = createHarness(true);
|
|
99
|
+
const posts = client.data.collection("posts");
|
|
100
|
+
|
|
101
|
+
await posts.find();
|
|
102
|
+
network.online = false;
|
|
103
|
+
expect((await posts.find()).data).toEqual([{ id: "p1", title: "hello" }]);
|
|
104
|
+
|
|
105
|
+
await posts.create({ title: "queued" });
|
|
106
|
+
expect(await client.offline!.pending()).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("routes fluent-builder queries through the cache", async () => {
|
|
110
|
+
const { client, network, requests } = createHarness();
|
|
111
|
+
const posts = client.data.collection("posts");
|
|
112
|
+
|
|
113
|
+
const online = await posts.where("title", "==", "hello").find();
|
|
114
|
+
expect(online.data).toHaveLength(1);
|
|
115
|
+
// The filter must actually reach the wire — otherwise this test would
|
|
116
|
+
// pass by caching an unfiltered query.
|
|
117
|
+
expect(requests.some((r) => r.url.includes("title="))).toBe(true);
|
|
118
|
+
|
|
119
|
+
network.online = false;
|
|
120
|
+
const cached = await posts.where("title", "==", "hello").find();
|
|
121
|
+
expect(cached.data).toEqual(online.data);
|
|
122
|
+
// A different filter is a different cache key, so it must miss.
|
|
123
|
+
await expect(posts.where("title", "==", "other").find()).rejects.toThrow(TypeError);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not serve cache on a server error — only on network failure", async () => {
|
|
127
|
+
const { client, network } = createHarness();
|
|
128
|
+
const posts = client.data.collection("posts");
|
|
129
|
+
await posts.find();
|
|
130
|
+
|
|
131
|
+
network.serverError = true;
|
|
132
|
+
await expect(posts.find()).rejects.toThrow(RebaseApiError);
|
|
133
|
+
});
|
|
134
|
+
});
|