@rebasepro/client 0.10.0 → 0.10.1-canary.31c773c
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 +1963 -4
- 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/package.json +5 -4
- 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 +676 -0
- package/src/offline.test.ts +719 -0
- package/src/offline.ts +1647 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { RebaseApiError } from "./transport";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the network is worth trying, and when to try again after it wasn't.
|
|
5
|
+
*
|
|
6
|
+
* `navigator.onLine` is necessary but not sufficient: it reports the state of
|
|
7
|
+
* the network interface, so it stays `true` behind a captive portal, on a
|
|
8
|
+
* connection that resolves DNS but reaches nothing, and while the API itself
|
|
9
|
+
* is down. This tracks what actually happened to requests as well, so the
|
|
10
|
+
* first failure is the only one an app pays for — everything after it inside
|
|
11
|
+
* the backoff window skips the doomed round trip and answers from the local
|
|
12
|
+
* store immediately, which is the difference between an app that freezes when
|
|
13
|
+
* the wifi drops and one that does not.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The request never reached the server, so nothing was decided by it. */
|
|
17
|
+
export function isNetworkError(error: unknown): boolean {
|
|
18
|
+
if (error instanceof RebaseApiError) {
|
|
19
|
+
// A 0 status is what a transport reports when it has no response at all.
|
|
20
|
+
return error.status === 0;
|
|
21
|
+
}
|
|
22
|
+
// fetch rejects with TypeError on network failure in every runtime we
|
|
23
|
+
// support (browsers: "Failed to fetch"/"Load failed"; undici: "fetch
|
|
24
|
+
// failed").
|
|
25
|
+
if (error instanceof TypeError) return true;
|
|
26
|
+
const name = (error as { name?: string } | undefined)?.name;
|
|
27
|
+
// AbortError covers both an explicit abort and a fetch timeout; the others
|
|
28
|
+
// are what Node and Safari surface for a dropped connection.
|
|
29
|
+
return name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Statuses that mean "not now" rather than "not ever": a queued write that
|
|
34
|
+
* gets one of these is worth replaying, while a 400 or a 403 never will be.
|
|
35
|
+
* 500 is deliberately absent — an unhandled server error is far more often a
|
|
36
|
+
* bug the same payload will hit again than a blip, and retrying it forever
|
|
37
|
+
* jams every write behind it.
|
|
38
|
+
*/
|
|
39
|
+
const RETRYABLE_STATUSES = new Set([408, 425, 429, 502, 503, 504]);
|
|
40
|
+
|
|
41
|
+
/** Is this failure worth another attempt later? */
|
|
42
|
+
export function isRetryableError(error: unknown): boolean {
|
|
43
|
+
if (isNetworkError(error)) return true;
|
|
44
|
+
if (error instanceof RebaseApiError) return error.status !== undefined && RETRYABLE_STATUSES.has(error.status);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConnectivityOptions {
|
|
49
|
+
/** First retry delay after a failure. Defaults to 1 000 ms. */
|
|
50
|
+
initialBackoffMs?: number;
|
|
51
|
+
/** Ceiling for the doubling retry delay. Defaults to 60 000 ms. */
|
|
52
|
+
maxBackoffMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Let a known-failed connection suppress further attempts until the
|
|
55
|
+
* backoff window opens. On by default — it is what makes a read or write
|
|
56
|
+
* during an outage instant instead of a timeout. Turn it off when nothing
|
|
57
|
+
* will ever wake the client up again (no retry timer, no `online` event),
|
|
58
|
+
* where suppressing attempts would mean never recovering.
|
|
59
|
+
*/
|
|
60
|
+
respectBackoff?: boolean;
|
|
61
|
+
/** Injected for tests. */
|
|
62
|
+
now?: () => number;
|
|
63
|
+
/** Injected for tests; must return a handle `clearTimeout` accepts. */
|
|
64
|
+
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
65
|
+
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ConnectivityMonitor {
|
|
69
|
+
private state: "online" | "offline" = "online";
|
|
70
|
+
private backoffMs: number;
|
|
71
|
+
private readonly initialBackoffMs: number;
|
|
72
|
+
private readonly maxBackoffMs: number;
|
|
73
|
+
private retryAt = 0;
|
|
74
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
75
|
+
private listeners = new Set<(online: boolean) => void>();
|
|
76
|
+
private readonly respectBackoff: boolean;
|
|
77
|
+
private readonly now: () => number;
|
|
78
|
+
private readonly setTimer: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
79
|
+
private readonly clearTimer: (handle: ReturnType<typeof setTimeout>) => void;
|
|
80
|
+
/** Called when the backoff window expires, to drive an automatic retry. */
|
|
81
|
+
onRetryDue?: () => void;
|
|
82
|
+
|
|
83
|
+
private readonly handleOnline = () => {
|
|
84
|
+
// The OS says the interface is back. Trust it enough to try
|
|
85
|
+
// immediately rather than sitting out the rest of the backoff — and to
|
|
86
|
+
// say so, or a client with nothing queued would have no request whose
|
|
87
|
+
// success could ever flip the badge back to "online".
|
|
88
|
+
this.retryAt = 0;
|
|
89
|
+
this.backoffMs = this.initialBackoffMs;
|
|
90
|
+
this.clearPendingTimer();
|
|
91
|
+
this.setState("online");
|
|
92
|
+
this.onRetryDue?.();
|
|
93
|
+
};
|
|
94
|
+
private readonly handleOffline = () => {
|
|
95
|
+
this.setState("offline");
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
constructor(options: ConnectivityOptions = {}) {
|
|
99
|
+
this.initialBackoffMs = options.initialBackoffMs ?? 1_000;
|
|
100
|
+
this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 60_000);
|
|
101
|
+
this.backoffMs = this.initialBackoffMs;
|
|
102
|
+
this.respectBackoff = options.respectBackoff ?? true;
|
|
103
|
+
this.now = options.now ?? (() => Date.now());
|
|
104
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
105
|
+
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
106
|
+
|
|
107
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
108
|
+
window.addEventListener("online", this.handleOnline);
|
|
109
|
+
window.addEventListener("offline", this.handleOffline);
|
|
110
|
+
}
|
|
111
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
112
|
+
this.state = "offline";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What the app should be told: are we connected? */
|
|
117
|
+
isOnline(): boolean {
|
|
118
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
|
|
119
|
+
return this.state === "online";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Should this request even be sent? False means "answer from the local
|
|
124
|
+
* store instead" — the request would only burn a timeout to reach the same
|
|
125
|
+
* conclusion the last one already did.
|
|
126
|
+
*/
|
|
127
|
+
shouldAttempt(): boolean {
|
|
128
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
|
|
129
|
+
if (this.state === "online" || !this.respectBackoff) return true;
|
|
130
|
+
// Exactly one request is let through when the window opens; it is the
|
|
131
|
+
// probe whose outcome decides whether we are back.
|
|
132
|
+
return this.now() >= this.retryAt;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A request reached the server. */
|
|
136
|
+
markSuccess(): void {
|
|
137
|
+
this.backoffMs = this.initialBackoffMs;
|
|
138
|
+
this.retryAt = 0;
|
|
139
|
+
this.clearPendingTimer();
|
|
140
|
+
this.setState("online");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A request did not reach the server: we are offline until proven otherwise. */
|
|
144
|
+
markFailure(): void {
|
|
145
|
+
this.deferRetry();
|
|
146
|
+
this.setState("offline");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Back off and try again later without claiming the connection is gone.
|
|
151
|
+
* This is what a 429 or a 503 deserves — the server answered, so the app
|
|
152
|
+
* is demonstrably online; it just should not hammer.
|
|
153
|
+
*/
|
|
154
|
+
deferRetry(): void {
|
|
155
|
+
const jitter = 0.8 + Math.random() * 0.4;
|
|
156
|
+
this.retryAt = this.now() + this.backoffMs * jitter;
|
|
157
|
+
const delay = Math.max(0, this.retryAt - this.now());
|
|
158
|
+
this.backoffMs = Math.min(this.maxBackoffMs, this.backoffMs * 2);
|
|
159
|
+
this.scheduleRetry(delay);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Milliseconds until the next attempt is allowed; 0 when one is allowed now. */
|
|
163
|
+
msUntilRetry(): number {
|
|
164
|
+
if (this.state === "online") return 0;
|
|
165
|
+
return Math.max(0, this.retryAt - this.now());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onChange(listener: (online: boolean) => void): () => void {
|
|
169
|
+
this.listeners.add(listener);
|
|
170
|
+
return () => this.listeners.delete(listener);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
dispose(): void {
|
|
174
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
175
|
+
window.removeEventListener("online", this.handleOnline);
|
|
176
|
+
window.removeEventListener("offline", this.handleOffline);
|
|
177
|
+
}
|
|
178
|
+
this.clearPendingTimer();
|
|
179
|
+
this.listeners.clear();
|
|
180
|
+
this.onRetryDue = undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private scheduleRetry(delay: number): void {
|
|
184
|
+
this.clearPendingTimer();
|
|
185
|
+
if (!this.onRetryDue) return;
|
|
186
|
+
this.timer = this.setTimer(() => {
|
|
187
|
+
this.timer = undefined;
|
|
188
|
+
this.onRetryDue?.();
|
|
189
|
+
}, delay);
|
|
190
|
+
// A retry timer must never be the reason a Node script refuses to exit.
|
|
191
|
+
(this.timer as unknown as { unref?: () => void }).unref?.();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private clearPendingTimer(): void {
|
|
195
|
+
if (this.timer !== undefined) {
|
|
196
|
+
this.clearTimer(this.timer);
|
|
197
|
+
this.timer = undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private setState(next: "online" | "offline"): void {
|
|
202
|
+
if (this.state === next) return;
|
|
203
|
+
this.state = next;
|
|
204
|
+
const online = this.isOnline();
|
|
205
|
+
for (const listener of this.listeners) listener(online);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import "fake-indexeddb/auto";
|
|
2
|
+
import { IndexedDBOfflineStore, PendingMutation } from "./offline-store";
|
|
3
|
+
import { OfflineManager } from "./offline";
|
|
4
|
+
import { RebaseApiError } from "./transport";
|
|
5
|
+
import { dehydrateRow, hydrateRow } from "./offline-codec";
|
|
6
|
+
import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
|
|
7
|
+
import type { CollectionClient } from "./collection";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The IndexedDB store against fake-indexeddb — the same structured-clone
|
|
11
|
+
* semantics and key ordering as a real browser, without one. All tests share
|
|
12
|
+
* one fake database (like real tabs share one), so each uses its own key
|
|
13
|
+
* prefix; the prefix isolation test is exactly what makes that safe.
|
|
14
|
+
*/
|
|
15
|
+
describe("IndexedDBOfflineStore", () => {
|
|
16
|
+
const store = new IndexedDBOfflineStore();
|
|
17
|
+
|
|
18
|
+
function mutation(seq: number, extra: Partial<PendingMutation> = {}): PendingMutation {
|
|
19
|
+
return {
|
|
20
|
+
mutationId: String(seq).padStart(10, "0"),
|
|
21
|
+
collection: "posts",
|
|
22
|
+
type: "create",
|
|
23
|
+
id: `id-${seq}`,
|
|
24
|
+
data: { id: `id-${seq}` },
|
|
25
|
+
queuedAt: Date.now(),
|
|
26
|
+
...extra
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function queueKey(scope: string, seq: number): string {
|
|
31
|
+
return `${scope}|${String(seq).padStart(10, "0")}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it("round-trips cache entries, including Date values", async () => {
|
|
35
|
+
const createdAt = new Date("2026-07-01T12:00:00Z");
|
|
36
|
+
await store.setCache("t1|find|posts|", { value: { data: [{ id: "p1", createdAt }] }, cachedAt: 111 });
|
|
37
|
+
|
|
38
|
+
const entry = await store.getCache("t1|find|posts|");
|
|
39
|
+
expect(entry?.cachedAt).toBe(111);
|
|
40
|
+
const row = (entry?.value as { data: { id: string; createdAt: Date }[] }).data[0];
|
|
41
|
+
// Not toBeInstanceOf: fake-indexeddb clones through another realm, so
|
|
42
|
+
// the constructor identity differs while the value is a real Date.
|
|
43
|
+
expect(Object.prototype.toString.call(row.createdAt)).toBe("[object Date]");
|
|
44
|
+
expect(row.createdAt.toISOString()).toBe(createdAt.toISOString());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns undefined for a missing cache key", async () => {
|
|
48
|
+
expect(await store.getCache("t2|nope")).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("lists only the keys under a prefix, with their write times", async () => {
|
|
52
|
+
await store.setCache("t3|find|posts|a", { value: 1, cachedAt: 10 });
|
|
53
|
+
await store.setCache("t3|find|posts|b", { value: 2, cachedAt: 20 });
|
|
54
|
+
await store.setCache("t3|find|users|a", { value: 3, cachedAt: 30 });
|
|
55
|
+
|
|
56
|
+
const listed = await store.listCache("t3|find|posts|");
|
|
57
|
+
expect(listed.map((e) => e.key).sort()).toEqual(["t3|find|posts|a", "t3|find|posts|b"]);
|
|
58
|
+
expect(listed.find((e) => e.key.endsWith("|a"))?.cachedAt).toBe(10);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("deletes several cache keys at once", async () => {
|
|
62
|
+
await store.setCache("t4|x", { value: 1, cachedAt: 1 });
|
|
63
|
+
await store.setCache("t4|y", { value: 2, cachedAt: 2 });
|
|
64
|
+
await store.deleteCache(["t4|x", "t4|y"]);
|
|
65
|
+
expect(await store.getCache("t4|x")).toBeUndefined();
|
|
66
|
+
expect(await store.getCache("t4|y")).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns the queue in mutation-id order even when written out of order", async () => {
|
|
70
|
+
await store.enqueue(queueKey("t5", 2), mutation(2));
|
|
71
|
+
await store.enqueue(queueKey("t5", 10), mutation(10));
|
|
72
|
+
await store.enqueue(queueKey("t5", 1), mutation(1));
|
|
73
|
+
|
|
74
|
+
const queue = await store.listQueue("t5|");
|
|
75
|
+
expect(queue.map((m) => m.id)).toEqual(["id-1", "id-2", "id-10"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("dequeues a single mutation", async () => {
|
|
79
|
+
await store.enqueue(queueKey("t6", 1), mutation(1));
|
|
80
|
+
await store.enqueue(queueKey("t6", 2), mutation(2));
|
|
81
|
+
await store.dequeue(queueKey("t6", 1));
|
|
82
|
+
expect((await store.listQueue("t6|")).map((m) => m.id)).toEqual(["id-2"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("clear() removes one prefix's cache and queue and nothing else", async () => {
|
|
86
|
+
await store.setCache("t7a|find|posts|", { value: 1, cachedAt: 1 });
|
|
87
|
+
await store.enqueue(queueKey("t7a", 1), mutation(1));
|
|
88
|
+
await store.setCache("t7b|find|posts|", { value: 2, cachedAt: 2 });
|
|
89
|
+
await store.enqueue(queueKey("t7b", 1), mutation(1));
|
|
90
|
+
|
|
91
|
+
await store.clear("t7a|");
|
|
92
|
+
|
|
93
|
+
expect(await store.getCache("t7a|find|posts|")).toBeUndefined();
|
|
94
|
+
expect(await store.listQueue("t7a|")).toHaveLength(0);
|
|
95
|
+
expect(await store.getCache("t7b|find|posts|")).toBeDefined();
|
|
96
|
+
expect(await store.listQueue("t7b|")).toHaveLength(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("persists across store instances", async () => {
|
|
100
|
+
await store.setCache("t8|find|posts|", { value: 42, cachedAt: 1 });
|
|
101
|
+
await store.enqueue(queueKey("t8", 1), mutation(1));
|
|
102
|
+
|
|
103
|
+
const reopened = new IndexedDBOfflineStore();
|
|
104
|
+
expect((await reopened.getCache("t8|find|posts|"))?.value).toBe(42);
|
|
105
|
+
expect(await reopened.listQueue("t8|")).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("writes a batch in one transaction and reads the values back by prefix", async () => {
|
|
109
|
+
await store.setCacheMany([
|
|
110
|
+
{ key: "t9|row|posts|a", entry: { value: { id: "a", n: 1 }, cachedAt: 5 } },
|
|
111
|
+
{ key: "t9|row|posts|b", entry: { value: { id: "b", n: 2 }, cachedAt: 6 } },
|
|
112
|
+
{ key: "t9|row|users|c", entry: { value: { id: "c" }, cachedAt: 7 } }
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const entries = await store.listCacheEntries("t9|row|posts|");
|
|
116
|
+
expect(entries.map((e) => e.key)).toEqual(["t9|row|posts|a", "t9|row|posts|b"]);
|
|
117
|
+
expect(entries.map((e) => (e.value as { n: number }).n)).toEqual([1, 2]);
|
|
118
|
+
expect(entries[1].cachedAt).toBe(6);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("row codec round-trip", () => {
|
|
123
|
+
/**
|
|
124
|
+
* Both stores move values by structured clone, which keeps `Date` but
|
|
125
|
+
* flattens class instances. A row read back from the cache has to be
|
|
126
|
+
* indistinguishable from the same row read from the network, or an app
|
|
127
|
+
* that calls `row.point.latitude` works online and throws offline.
|
|
128
|
+
*/
|
|
129
|
+
it("survives the store as the same shapes it went in as", async () => {
|
|
130
|
+
const store = new IndexedDBOfflineStore();
|
|
131
|
+
const row = {
|
|
132
|
+
id: "r1",
|
|
133
|
+
at: new Date("2026-07-01T00:00:00Z"),
|
|
134
|
+
point: new GeoPoint(41.4, 2.2),
|
|
135
|
+
embedding: new Vector([0.1, 0.2]),
|
|
136
|
+
author: new EntityReference({ id: "u1", path: "users" }),
|
|
137
|
+
tag: new EntityRelation("t1", "tags"),
|
|
138
|
+
nested: { deep: [new GeoPoint(1, 2)] },
|
|
139
|
+
plain: "text"
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
await store.setCache("codec|row|things|r1", { value: dehydrateRow(row), cachedAt: 1 });
|
|
143
|
+
const back = hydrateRow((await store.getCache("codec|row|things|r1"))!.value as Record<string, unknown>);
|
|
144
|
+
|
|
145
|
+
expect(back.point).toBeInstanceOf(GeoPoint);
|
|
146
|
+
expect((back.point as GeoPoint).latitude).toBe(41.4);
|
|
147
|
+
expect(back.embedding).toBeInstanceOf(Vector);
|
|
148
|
+
expect((back.embedding as Vector).value).toEqual([0.1, 0.2]);
|
|
149
|
+
expect(back.author).toBeInstanceOf(EntityReference);
|
|
150
|
+
expect((back.author as EntityReference).pathWithId).toBe("users/u1");
|
|
151
|
+
expect(back.tag).toBeInstanceOf(EntityRelation);
|
|
152
|
+
expect(((back.nested as { deep: GeoPoint[] }).deep[0])).toBeInstanceOf(GeoPoint);
|
|
153
|
+
expect(Object.prototype.toString.call(back.at)).toBe("[object Date]");
|
|
154
|
+
expect(back.plain).toBe("text");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("two tabs over one database", () => {
|
|
159
|
+
/** BroadcastChannel delivery is asynchronous; give it a macrotask or two. */
|
|
160
|
+
const settle = async () => {
|
|
161
|
+
for (let i = 0; i < 3; i++) await new Promise((resolve) => setTimeout(resolve, 0));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function createServer() {
|
|
165
|
+
const table = new Map<string, Record<string, unknown>>();
|
|
166
|
+
const state = { online: true };
|
|
167
|
+
const client = {
|
|
168
|
+
async find() {
|
|
169
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
170
|
+
const data = [...table.values()];
|
|
171
|
+
return { data, meta: { total: data.length, limit: 20, offset: 0, hasMore: false } };
|
|
172
|
+
},
|
|
173
|
+
async findById(id: string | number) {
|
|
174
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
175
|
+
return table.get(String(id));
|
|
176
|
+
},
|
|
177
|
+
async create(data: Record<string, unknown>) {
|
|
178
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
179
|
+
table.set(String(data.id), data);
|
|
180
|
+
return data;
|
|
181
|
+
},
|
|
182
|
+
async createMany() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
183
|
+
async update() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
184
|
+
async delete() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
185
|
+
async count() { return table.size; }
|
|
186
|
+
} as unknown as CollectionClient<Record<string, unknown>>;
|
|
187
|
+
return { table, state, client };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Web Locks is how two tabs avoid replaying the same queue at the same
|
|
192
|
+
* time. Node has no implementation, so this is the browser's — a plain
|
|
193
|
+
* mutex keyed by name, which is all the manager asks of it.
|
|
194
|
+
*/
|
|
195
|
+
function installWebLocks(): () => void {
|
|
196
|
+
const held = new Map<string, Promise<unknown>>();
|
|
197
|
+
const locks = {
|
|
198
|
+
request: (name: string, fn: () => Promise<unknown>) => {
|
|
199
|
+
const previous = held.get(name) ?? Promise.resolve();
|
|
200
|
+
const next = previous.then(fn, fn);
|
|
201
|
+
held.set(name, next.then(() => undefined, () => undefined));
|
|
202
|
+
return next;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
Object.defineProperty(globalThis.navigator, "locks", { value: locks, configurable: true });
|
|
206
|
+
return () => { delete (globalThis.navigator as { locks?: unknown }).locks; };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
it("shows one tab's offline write in the other, and queues it only once", async () => {
|
|
210
|
+
const uninstall = installWebLocks();
|
|
211
|
+
const server = createServer();
|
|
212
|
+
const tabA = new OfflineManager({ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 }, () => server.client);
|
|
213
|
+
const tabB = new OfflineManager({ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 }, () => server.client);
|
|
214
|
+
const postsA = tabA.wrap("xtab", server.client);
|
|
215
|
+
const postsB = tabB.wrap("xtab", server.client);
|
|
216
|
+
|
|
217
|
+
await postsA.find();
|
|
218
|
+
await postsB.find();
|
|
219
|
+
|
|
220
|
+
server.state.online = false;
|
|
221
|
+
await postsA.create({ title: "from tab A" }, "xt1");
|
|
222
|
+
await settle();
|
|
223
|
+
|
|
224
|
+
// Without cross-tab propagation this tab would be showing a list that
|
|
225
|
+
// is already wrong, and would keep showing it until a refetch.
|
|
226
|
+
expect((await postsB.find()).data.map((r) => r.id)).toContain("xt1");
|
|
227
|
+
expect(await tabB.api.pending()).toHaveLength(1);
|
|
228
|
+
|
|
229
|
+
// Both tabs share the queue, so the write must reach the server once —
|
|
230
|
+
// the lock serialises them, and the loser re-reads a drained queue.
|
|
231
|
+
server.state.online = true;
|
|
232
|
+
const [a, b] = await Promise.all([tabA.sync(), tabB.sync()]);
|
|
233
|
+
expect(a.flushed + b.flushed).toBe(1);
|
|
234
|
+
expect(server.table.get("xt1")).toMatchObject({ title: "from tab A" });
|
|
235
|
+
|
|
236
|
+
tabA.dispose();
|
|
237
|
+
tabB.dispose();
|
|
238
|
+
uninstall();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("OfflineManager over IndexedDB", () => {
|
|
243
|
+
it("queues offline writes in one manager and replays them from a fresh one", async () => {
|
|
244
|
+
const table = new Map<string, Record<string, unknown>>();
|
|
245
|
+
const state = { online: true };
|
|
246
|
+
const fakeClient = {
|
|
247
|
+
async find() {
|
|
248
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
249
|
+
const data = [...table.values()];
|
|
250
|
+
return { data, meta: { total: data.length, limit: 20, offset: 0, hasMore: false } };
|
|
251
|
+
},
|
|
252
|
+
async findById(id: string | number) {
|
|
253
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
254
|
+
return table.get(String(id));
|
|
255
|
+
},
|
|
256
|
+
async create(data: Record<string, unknown>) {
|
|
257
|
+
if (!state.online) throw new TypeError("fetch failed");
|
|
258
|
+
table.set(String(data.id), data);
|
|
259
|
+
return data;
|
|
260
|
+
},
|
|
261
|
+
async createMany() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
262
|
+
async update() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
263
|
+
async delete() { throw new RebaseApiError("unused", { status: 500 }); },
|
|
264
|
+
async count() { return table.size; }
|
|
265
|
+
} as unknown as CollectionClient<Record<string, unknown>>;
|
|
266
|
+
|
|
267
|
+
const first = new OfflineManager(
|
|
268
|
+
{ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
|
|
269
|
+
() => fakeClient
|
|
270
|
+
);
|
|
271
|
+
state.online = false;
|
|
272
|
+
const posts = first.wrap("posts", fakeClient);
|
|
273
|
+
const row = await posts.create({ title: "persisted offline" });
|
|
274
|
+
expect(await posts.findById(row.id as string)).toMatchObject({ title: "persisted offline" });
|
|
275
|
+
first.dispose();
|
|
276
|
+
|
|
277
|
+
state.online = true;
|
|
278
|
+
const second = new OfflineManager(
|
|
279
|
+
{ store: new IndexedDBOfflineStore(), syncIntervalMs: 0 },
|
|
280
|
+
() => fakeClient
|
|
281
|
+
);
|
|
282
|
+
expect(await second.sync()).toEqual({ flushed: 1, remaining: 0 });
|
|
283
|
+
expect(table.get(String(row.id))).toMatchObject({ title: "persisted offline" });
|
|
284
|
+
second.dispose();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createRebaseClient, RebaseApiError, type LiveResult } 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, fetchStub };
|
|
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 filter the server never answered here is still evaluated locally
|
|
123
|
+
// against the rows that *are* cached — and this one matches none.
|
|
124
|
+
expect((await posts.where("title", "==", "other").find()).data).toEqual([]);
|
|
125
|
+
expect((await posts.where("title", "==", "hello").limit(5).find()).data).toEqual(online.data);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not serve cache on a server error — only on network failure", async () => {
|
|
129
|
+
const { client, network } = createHarness();
|
|
130
|
+
const posts = client.data.collection("posts");
|
|
131
|
+
await posts.find();
|
|
132
|
+
|
|
133
|
+
network.serverError = true;
|
|
134
|
+
await expect(posts.find()).rejects.toThrow(RebaseApiError);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("drives a live query through the data proxy, offline included", async () => {
|
|
138
|
+
const { client, network } = createHarness();
|
|
139
|
+
const posts = client.data.collection("posts");
|
|
140
|
+
await posts.find();
|
|
141
|
+
|
|
142
|
+
const results: LiveResult<Record<string, unknown>>[] = [];
|
|
143
|
+
const stop = posts.observe(undefined, (r) => results.push(r));
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
145
|
+
expect(results[0].data.map((r) => r.id)).toEqual(["p1"]);
|
|
146
|
+
|
|
147
|
+
network.online = false;
|
|
148
|
+
await posts.create({ title: "written offline" });
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
150
|
+
|
|
151
|
+
const last = results[results.length - 1];
|
|
152
|
+
expect(last.data.map((r) => r.title)).toEqual(["hello", "written offline"]);
|
|
153
|
+
expect(last.hasPendingWrites).toBe(true);
|
|
154
|
+
expect(client.offline!.status()).toMatchObject({ online: false, pending: 1 });
|
|
155
|
+
stop();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("observes without offline support too, straight off the network", async () => {
|
|
159
|
+
const { fetchStub } = createHarness();
|
|
160
|
+
const plain = createRebaseClient({
|
|
161
|
+
baseUrl: "http://localhost:9999",
|
|
162
|
+
realtime: false,
|
|
163
|
+
fetch: fetchStub
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const results: LiveResult<Record<string, unknown>>[] = [];
|
|
167
|
+
const stop = plain.data.collection("posts").observe(undefined, (r) => results.push(r));
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
169
|
+
|
|
170
|
+
expect(results[0].data.map((r) => r.id)).toEqual(["p1"]);
|
|
171
|
+
// No local database, so nothing here is ever cached, pending or partial.
|
|
172
|
+
expect(results[0]).toMatchObject({ fromCache: false, hasPendingWrites: false, partial: false });
|
|
173
|
+
stop();
|
|
174
|
+
});
|
|
175
|
+
});
|