@rebasepro/client 0.17.3 → 0.18.1
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 +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- package/src/websocket.ts +0 -1837
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { matchesOperator } from "./offline-query";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A `like` pattern is user input, and it becomes a regular expression.
|
|
5
|
-
*
|
|
6
|
-
* `%` translates to `[\s\S]*`, so `%%%%X` becomes four adjacent unbounded
|
|
7
|
-
* quantifiers followed by a literal. On a subject that does not match, the
|
|
8
|
-
* engine has to try every way of splitting the subject between them — which is
|
|
9
|
-
* exponential. Twelve `%` against a forty-character value took **100 seconds**
|
|
10
|
-
* on this machine before answering `false`.
|
|
11
|
-
*
|
|
12
|
-
* The pattern arrives over HTTP: `?title=like.%25%25%25…` is a public filter
|
|
13
|
-
* operator, listed in the operator table. On the offline evaluator that freezes
|
|
14
|
-
* the tab; the same translation in the Mongo driver hands the expression to the
|
|
15
|
-
* database, where it occupies a server thread instead.
|
|
16
|
-
*
|
|
17
|
-
* Consecutive `%` mean exactly what one `%` means, so collapsing a run is
|
|
18
|
-
* semantics-preserving and removes the ambiguity the backtracking feeds on.
|
|
19
|
-
*/
|
|
20
|
-
const HOSTILE = "%".repeat(14) + "X";
|
|
21
|
-
const SUBJECT = "a".repeat(48);
|
|
22
|
-
|
|
23
|
-
describe("like patterns cannot be made to backtrack", () => {
|
|
24
|
-
it("answers a hostile pattern promptly", () => {
|
|
25
|
-
const started = Date.now();
|
|
26
|
-
const result = matchesOperator(SUBJECT, "like", HOSTILE);
|
|
27
|
-
const elapsed = Date.now() - started;
|
|
28
|
-
|
|
29
|
-
expect(result).toBe(false);
|
|
30
|
-
expect(elapsed).toBeLessThan(1000);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("answers the case-insensitive form promptly too", () => {
|
|
34
|
-
const started = Date.now();
|
|
35
|
-
matchesOperator(SUBJECT, "ilike", HOSTILE);
|
|
36
|
-
|
|
37
|
-
expect(Date.now() - started).toBeLessThan(1000);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("still means what LIKE means", () => {
|
|
41
|
-
expect(matchesOperator("post-1", "like", "post-%")).toBe(true);
|
|
42
|
-
expect(matchesOperator("post-1", "like", "%1")).toBe(true);
|
|
43
|
-
expect(matchesOperator("post-1", "like", "%st-%")).toBe(true);
|
|
44
|
-
expect(matchesOperator("post-1", "like", "other-%")).toBe(false);
|
|
45
|
-
// A run of wildcards is the same query as one wildcard.
|
|
46
|
-
expect(matchesOperator("post-1", "like", "post%%%%1")).toBe(true);
|
|
47
|
-
expect(matchesOperator("abc", "like", "a_c")).toBe(true);
|
|
48
|
-
expect(matchesOperator("abbc", "like", "a_c")).toBe(false);
|
|
49
|
-
// `_` is fixed-width, so a run of them still counts.
|
|
50
|
-
expect(matchesOperator("abc", "like", "a__")).toBe(true);
|
|
51
|
-
expect(matchesOperator("ab", "like", "a__")).toBe(false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("keeps an escaped percent literal", () => {
|
|
55
|
-
expect(matchesOperator("50%", "like", "50\\%")).toBe(true);
|
|
56
|
-
expect(matchesOperator("500", "like", "50\\%")).toBe(false);
|
|
57
|
-
// An escaped percent next to a wildcard is still a literal.
|
|
58
|
-
expect(matchesOperator("50%off", "like", "50\\%%")).toBe(true);
|
|
59
|
-
expect(matchesOperator("50off", "like", "50\\%%")).toBe(false);
|
|
60
|
-
});
|
|
61
|
-
});
|
package/src/offline-codec.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
|
|
2
|
-
import { rebaseReviver } from "./reviver";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Lossless round-tripping of rows through the offline store.
|
|
6
|
-
*
|
|
7
|
-
* Both persistence backends move values by structured clone, which keeps
|
|
8
|
-
* `Date` but flattens every class instance to a plain object. For
|
|
9
|
-
* `EntityReference`/`EntityRelation` that is harmless — they carry their own
|
|
10
|
-
* `__type` discriminator, so the JSON reviver can rebuild them — but
|
|
11
|
-
* `GeoPoint` and `Vector` do not, and would come back out of the cache as
|
|
12
|
-
* anonymous `{ latitude, longitude }` / `{ value }` bags. A row read from the
|
|
13
|
-
* cache must be indistinguishable from the same row read from the network, so
|
|
14
|
-
* those two are tagged on the way in and revived on the way out.
|
|
15
|
-
*
|
|
16
|
-
* Type tests here are structural rather than `instanceof`, because a structured
|
|
17
|
-
* clone can arrive from another realm — an iframe, a worker, or the polyfill
|
|
18
|
-
* the tests run against — where the constructor identity differs but the value
|
|
19
|
-
* is the real thing. Only *plain* objects are walked; anything else is passed
|
|
20
|
-
* through whole, so a class instance is never quietly reduced to `{}`.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
function isDate(value: unknown): value is Date {
|
|
24
|
-
return Object.prototype.toString.call(value) === "[object Date]";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** An object literal — not a Date, RegExp, Map, or any class instance. */
|
|
28
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
29
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
30
|
-
const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null;
|
|
31
|
-
if (proto === null || proto === Object.prototype) return true;
|
|
32
|
-
// A literal cloned out of another realm has a different `Object.prototype`
|
|
33
|
-
// but is still, in every way that matters here, a plain object.
|
|
34
|
-
return proto.constructor?.name === "Object";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function dehydrateValue(value: unknown): unknown {
|
|
38
|
-
if (value === null || value === undefined) return value;
|
|
39
|
-
if (value instanceof GeoPoint) {
|
|
40
|
-
return { __type: "GeoPoint", latitude: value.latitude, longitude: value.longitude };
|
|
41
|
-
}
|
|
42
|
-
if (value instanceof Vector) return { __type: "Vector", value: [...value.value] };
|
|
43
|
-
// EntityReference/EntityRelation already serialize themselves via `__type`
|
|
44
|
-
// own properties, so a structured clone is enough for the reviver below.
|
|
45
|
-
if (value instanceof EntityReference || value instanceof EntityRelation) return value;
|
|
46
|
-
if (Array.isArray(value)) return value.map(dehydrateValue);
|
|
47
|
-
if (isPlainObject(value)) {
|
|
48
|
-
const out: Record<string, unknown> = {};
|
|
49
|
-
for (const [key, inner] of Object.entries(value)) out[key] = dehydrateValue(inner);
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
return value;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function hydrateValue(value: unknown): unknown {
|
|
56
|
-
if (value === null || value === undefined || isDate(value)) return value;
|
|
57
|
-
if (Array.isArray(value)) return value.map(hydrateValue);
|
|
58
|
-
if (typeof value === "object") {
|
|
59
|
-
const revived = rebaseReviver("", value);
|
|
60
|
-
// The reviver recognised it — hand back the class instance untouched
|
|
61
|
-
// rather than walking into its (now private) internals.
|
|
62
|
-
if (revived !== value) return revived;
|
|
63
|
-
if (!isPlainObject(value)) return value;
|
|
64
|
-
const out: Record<string, unknown> = {};
|
|
65
|
-
for (const [key, inner] of Object.entries(value)) out[key] = hydrateValue(inner);
|
|
66
|
-
return out;
|
|
67
|
-
}
|
|
68
|
-
return value;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Prepare a row for the store. */
|
|
72
|
-
export function dehydrateRow<T extends Record<string, unknown>>(row: T): Record<string, unknown> {
|
|
73
|
-
return dehydrateValue(row) as Record<string, unknown>;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Restore a row read back from the store. */
|
|
77
|
-
export function hydrateRow<T extends Record<string, unknown>>(row: Record<string, unknown>): T {
|
|
78
|
-
return hydrateValue(row) as T;
|
|
79
|
-
}
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ConnectivityMonitor,
|
|
3
|
-
isDuplicateKeyError,
|
|
4
|
-
isNetworkError,
|
|
5
|
-
isRetryableError
|
|
6
|
-
} from "./offline-connectivity";
|
|
7
|
-
import { RebaseApiError } from "./transport";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The monitor decides whether a request is worth sending at all, so getting it
|
|
11
|
-
* wrong is either an app that hangs on every read during an outage, or one that
|
|
12
|
-
* never notices the network came back.
|
|
13
|
-
*/
|
|
14
|
-
describe("network error classification", () => {
|
|
15
|
-
it("recognises a request that never reached the server", () => {
|
|
16
|
-
expect(isNetworkError(new TypeError("Failed to fetch"))).toBe(true);
|
|
17
|
-
expect(isNetworkError(new TypeError("fetch failed"))).toBe(true);
|
|
18
|
-
expect(isNetworkError(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBe(true);
|
|
19
|
-
expect(isNetworkError(Object.assign(new Error("timed out"), { name: "TimeoutError" }))).toBe(true);
|
|
20
|
-
expect(isNetworkError(new RebaseApiError("no response", { status: 0 }))).toBe(true);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("does not mistake a server's answer for a dead network", () => {
|
|
24
|
-
expect(isNetworkError(new RebaseApiError("nope", { status: 403 }))).toBe(false);
|
|
25
|
-
expect(isNetworkError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
|
|
26
|
-
// A programming error must propagate, not be swallowed as "offline".
|
|
27
|
-
expect(isNetworkError(new RangeError("bug"))).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("retries what will pass and gives up on what will not", () => {
|
|
31
|
-
expect(isRetryableError(new TypeError("Failed to fetch"))).toBe(true);
|
|
32
|
-
expect(isRetryableError(new RebaseApiError("busy", { status: 429 }))).toBe(true);
|
|
33
|
-
expect(isRetryableError(new RebaseApiError("down", { status: 503 }))).toBe(true);
|
|
34
|
-
expect(isRetryableError(new RebaseApiError("gateway", { status: 502 }))).toBe(true);
|
|
35
|
-
|
|
36
|
-
expect(isRetryableError(new RebaseApiError("invalid", { status: 400 }))).toBe(false);
|
|
37
|
-
expect(isRetryableError(new RebaseApiError("denied", { status: 403 }))).toBe(false);
|
|
38
|
-
expect(isRetryableError(new RebaseApiError("gone", { status: 404 }))).toBe(false);
|
|
39
|
-
// A 500 is far more often a bug the same payload will hit again than a
|
|
40
|
-
// blip, and retrying it forever jams every write queued behind it.
|
|
41
|
-
expect(isRetryableError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("retries a write the server is still answering, and only that 409", () => {
|
|
45
|
-
// The server's own message says to retry — "its result will be
|
|
46
|
-
// replayed" — and a key whose claim outlived the process that took it
|
|
47
|
-
// is refused until the lease expires. Giving up instead rolls back a
|
|
48
|
-
// write that retrying would have completed.
|
|
49
|
-
expect(isRetryableError(new RebaseApiError("in progress", {
|
|
50
|
-
status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
51
|
-
}))).toBe(true);
|
|
52
|
-
// Every other 409 is a real conflict and stays fatal.
|
|
53
|
-
expect(isRetryableError(new RebaseApiError("row exists", { status: 409, code: "23505" }))).toBe(false);
|
|
54
|
-
expect(isRetryableError(new RebaseApiError("conflict", { status: 409 }))).toBe(false);
|
|
55
|
-
expect(isRetryableError(new RebaseApiError("reused", {
|
|
56
|
-
status: 422, code: "IDEMPOTENCY_KEY_REUSED"
|
|
57
|
-
}))).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("does not read an unanswered write as a row that is already there", () => {
|
|
61
|
-
// The status alone cannot decide it. Read as a duplicate, the queue
|
|
62
|
-
// went looking for a row that was never written, found nothing,
|
|
63
|
-
// concluded there was nothing left to do and deleted the write.
|
|
64
|
-
expect(isDuplicateKeyError(new RebaseApiError("in progress", {
|
|
65
|
-
status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
|
|
66
|
-
}))).toBe(false);
|
|
67
|
-
|
|
68
|
-
expect(isDuplicateKeyError(new RebaseApiError("dup", { status: 400, code: "23505" }))).toBe(true);
|
|
69
|
-
expect(isDuplicateKeyError(new RebaseApiError("conflict", { status: 409 }))).toBe(true);
|
|
70
|
-
expect(isDuplicateKeyError(new RebaseApiError("gone", { status: 404 }))).toBe(false);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("ConnectivityMonitor", () => {
|
|
75
|
-
function createMonitor(overrides: Partial<ConstructorParameters<typeof ConnectivityMonitor>[0]> = {}) {
|
|
76
|
-
let now = 1_000_000;
|
|
77
|
-
const timers: { fn: () => void; at: number }[] = [];
|
|
78
|
-
const monitor = new ConnectivityMonitor({
|
|
79
|
-
initialBackoffMs: 100,
|
|
80
|
-
maxBackoffMs: 800,
|
|
81
|
-
now: () => now,
|
|
82
|
-
setTimer: ((fn: () => void, ms: number) => {
|
|
83
|
-
timers.push({ fn, at: now + ms });
|
|
84
|
-
return timers.length as unknown as ReturnType<typeof setTimeout>;
|
|
85
|
-
}) as never,
|
|
86
|
-
clearTimer: (() => undefined) as never,
|
|
87
|
-
...overrides
|
|
88
|
-
});
|
|
89
|
-
const advance = (ms: number) => {
|
|
90
|
-
now += ms;
|
|
91
|
-
for (const timer of timers.splice(0)) {
|
|
92
|
-
if (timer.at <= now) timer.fn();
|
|
93
|
-
else timers.push(timer);
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
return { monitor, advance, at: () => now };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
it("starts willing to try", () => {
|
|
100
|
-
const { monitor } = createMonitor();
|
|
101
|
-
expect(monitor.isOnline()).toBe(true);
|
|
102
|
-
expect(monitor.shouldAttempt()).toBe(true);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("stops attempting after a failure, until the backoff window opens", () => {
|
|
106
|
-
const { monitor, advance } = createMonitor();
|
|
107
|
-
monitor.markFailure();
|
|
108
|
-
|
|
109
|
-
expect(monitor.isOnline()).toBe(false);
|
|
110
|
-
// This is the whole point: the second read during an outage costs
|
|
111
|
-
// nothing instead of another timeout.
|
|
112
|
-
expect(monitor.shouldAttempt()).toBe(false);
|
|
113
|
-
|
|
114
|
-
advance(200);
|
|
115
|
-
expect(monitor.shouldAttempt()).toBe(true);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("doubles the delay on repeated failures and caps it", () => {
|
|
119
|
-
const { monitor, advance } = createMonitor();
|
|
120
|
-
monitor.markFailure();
|
|
121
|
-
const first = monitor.msUntilRetry();
|
|
122
|
-
advance(first);
|
|
123
|
-
|
|
124
|
-
monitor.markFailure();
|
|
125
|
-
const second = monitor.msUntilRetry();
|
|
126
|
-
expect(second).toBeGreaterThan(first);
|
|
127
|
-
|
|
128
|
-
for (let i = 0; i < 10; i++) {
|
|
129
|
-
advance(monitor.msUntilRetry());
|
|
130
|
-
monitor.markFailure();
|
|
131
|
-
}
|
|
132
|
-
// 800 plus the 20% jitter ceiling.
|
|
133
|
-
expect(monitor.msUntilRetry()).toBeLessThanOrEqual(800 * 1.2);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("resets the backoff once a request gets through", () => {
|
|
137
|
-
const { monitor, advance } = createMonitor();
|
|
138
|
-
monitor.markFailure();
|
|
139
|
-
advance(monitor.msUntilRetry());
|
|
140
|
-
monitor.markFailure();
|
|
141
|
-
monitor.markSuccess();
|
|
142
|
-
|
|
143
|
-
expect(monitor.isOnline()).toBe(true);
|
|
144
|
-
expect(monitor.shouldAttempt()).toBe(true);
|
|
145
|
-
monitor.markFailure();
|
|
146
|
-
// Back to the initial delay, not to where the doubling had reached.
|
|
147
|
-
expect(monitor.msUntilRetry()).toBeLessThanOrEqual(100 * 1.2);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("fires the retry hook when the window opens", () => {
|
|
151
|
-
const { monitor, advance } = createMonitor();
|
|
152
|
-
let retries = 0;
|
|
153
|
-
monitor.onRetryDue = () => { retries++; };
|
|
154
|
-
|
|
155
|
-
monitor.markFailure();
|
|
156
|
-
expect(retries).toBe(0);
|
|
157
|
-
advance(200);
|
|
158
|
-
expect(retries).toBe(1);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("backs off without claiming the connection is gone", () => {
|
|
162
|
-
const { monitor } = createMonitor();
|
|
163
|
-
// A 429 means the server answered — the app is demonstrably online and
|
|
164
|
-
// an "offline" badge would be a lie.
|
|
165
|
-
monitor.deferRetry();
|
|
166
|
-
expect(monitor.isOnline()).toBe(true);
|
|
167
|
-
expect(monitor.shouldAttempt()).toBe(true);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("notifies listeners on each transition, and only on transitions", () => {
|
|
171
|
-
const { monitor } = createMonitor();
|
|
172
|
-
const seen: boolean[] = [];
|
|
173
|
-
monitor.onChange((online) => seen.push(online));
|
|
174
|
-
|
|
175
|
-
monitor.markFailure();
|
|
176
|
-
monitor.markFailure();
|
|
177
|
-
monitor.markSuccess();
|
|
178
|
-
monitor.markSuccess();
|
|
179
|
-
|
|
180
|
-
expect(seen).toEqual([false, true]);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("keeps attempting when backoff suppression is off", () => {
|
|
184
|
-
// Without a retry timer nothing would ever reopen the window, so
|
|
185
|
-
// suppressing attempts would strand the client offline forever.
|
|
186
|
-
const { monitor } = createMonitor({ respectBackoff: false });
|
|
187
|
-
monitor.markFailure();
|
|
188
|
-
expect(monitor.isOnline()).toBe(false);
|
|
189
|
-
expect(monitor.shouldAttempt()).toBe(true);
|
|
190
|
-
});
|
|
191
|
-
});
|
|
@@ -1,255 +0,0 @@
|
|
|
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
|
-
/**
|
|
42
|
-
* The server holds this key for a request it has not answered yet.
|
|
43
|
-
*
|
|
44
|
-
* It is a 409 like a duplicate row is a 409, and nothing but the code separates
|
|
45
|
-
* them — one means "your write is already there", the other means "your write
|
|
46
|
-
* may not have happened at all, ask again".
|
|
47
|
-
*/
|
|
48
|
-
const IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Is the server still answering an earlier attempt of this same write?
|
|
52
|
-
*
|
|
53
|
-
* The only correct response is to ask again — which is exactly what the
|
|
54
|
-
* server's own message says, and exactly what this SDK used not to do.
|
|
55
|
-
*/
|
|
56
|
-
export function isIdempotencyInProgressError(error: unknown): boolean {
|
|
57
|
-
return error instanceof RebaseApiError
|
|
58
|
-
&& error.status === 409
|
|
59
|
-
&& error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Is this failure worth another attempt later? */
|
|
63
|
-
export function isRetryableError(error: unknown): boolean {
|
|
64
|
-
if (isNetworkError(error)) return true;
|
|
65
|
-
if (!(error instanceof RebaseApiError)) return false;
|
|
66
|
-
// The one 409 that resolves on its own. A key whose claim outlived the
|
|
67
|
-
// request that took it — the process was killed between the write and the
|
|
68
|
-
// answer — is refused until the claim's lease expires, and giving up on it
|
|
69
|
-
// means dropping a write that retrying would have completed.
|
|
70
|
-
if (isIdempotencyInProgressError(error)) return true;
|
|
71
|
-
return error.status !== undefined && RETRYABLE_STATUSES.has(error.status);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Did this write fail because the row is already there?
|
|
76
|
-
*
|
|
77
|
-
* Matched on the SQLSTATE the server passes through (`23505`, unique_violation)
|
|
78
|
-
* and on 409, never on the message — a duplicate-key message names the
|
|
79
|
-
* constraint and the values, so it is neither stable nor safe to parse.
|
|
80
|
-
*
|
|
81
|
-
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
82
|
-
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
83
|
-
* the server can only be rejecting it because the first attempt actually landed.
|
|
84
|
-
*
|
|
85
|
-
* Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
|
|
86
|
-
* is a 409 that means the opposite — the row may not exist at all. Read as a
|
|
87
|
-
* duplicate, the queue looked for a row that was never written, found nothing,
|
|
88
|
-
* concluded there was nothing left to do and deleted the write from the queue.
|
|
89
|
-
*/
|
|
90
|
-
export function isDuplicateKeyError(error: unknown): boolean {
|
|
91
|
-
if (!(error instanceof RebaseApiError)) return false;
|
|
92
|
-
if (error.code === "23505") return true;
|
|
93
|
-
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export interface ConnectivityOptions {
|
|
97
|
-
/** First retry delay after a failure. Defaults to 1 000 ms. */
|
|
98
|
-
initialBackoffMs?: number;
|
|
99
|
-
/** Ceiling for the doubling retry delay. Defaults to 60 000 ms. */
|
|
100
|
-
maxBackoffMs?: number;
|
|
101
|
-
/**
|
|
102
|
-
* Let a known-failed connection suppress further attempts until the
|
|
103
|
-
* backoff window opens. On by default — it is what makes a read or write
|
|
104
|
-
* during an outage instant instead of a timeout. Turn it off when nothing
|
|
105
|
-
* will ever wake the client up again (no retry timer, no `online` event),
|
|
106
|
-
* where suppressing attempts would mean never recovering.
|
|
107
|
-
*/
|
|
108
|
-
respectBackoff?: boolean;
|
|
109
|
-
/** Injected for tests. */
|
|
110
|
-
now?: () => number;
|
|
111
|
-
/** Injected for tests; must return a handle `clearTimeout` accepts. */
|
|
112
|
-
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
113
|
-
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export class ConnectivityMonitor {
|
|
117
|
-
private state: "online" | "offline" = "online";
|
|
118
|
-
private backoffMs: number;
|
|
119
|
-
private readonly initialBackoffMs: number;
|
|
120
|
-
private readonly maxBackoffMs: number;
|
|
121
|
-
private retryAt = 0;
|
|
122
|
-
private timer?: ReturnType<typeof setTimeout>;
|
|
123
|
-
private listeners = new Set<(online: boolean) => void>();
|
|
124
|
-
private readonly respectBackoff: boolean;
|
|
125
|
-
private readonly now: () => number;
|
|
126
|
-
private readonly setTimer: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
127
|
-
private readonly clearTimer: (handle: ReturnType<typeof setTimeout>) => void;
|
|
128
|
-
/** Called when the backoff window expires, to drive an automatic retry. */
|
|
129
|
-
onRetryDue?: () => void;
|
|
130
|
-
|
|
131
|
-
private readonly handleOnline = () => {
|
|
132
|
-
// The OS says the interface is back. Trust it enough to try
|
|
133
|
-
// immediately rather than sitting out the rest of the backoff — and to
|
|
134
|
-
// say so, or a client with nothing queued would have no request whose
|
|
135
|
-
// success could ever flip the badge back to "online".
|
|
136
|
-
this.retryAt = 0;
|
|
137
|
-
this.backoffMs = this.initialBackoffMs;
|
|
138
|
-
this.clearPendingTimer();
|
|
139
|
-
this.setState("online");
|
|
140
|
-
this.onRetryDue?.();
|
|
141
|
-
};
|
|
142
|
-
private readonly handleOffline = () => {
|
|
143
|
-
this.setState("offline");
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
constructor(options: ConnectivityOptions = {}) {
|
|
147
|
-
this.initialBackoffMs = options.initialBackoffMs ?? 1_000;
|
|
148
|
-
this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 60_000);
|
|
149
|
-
this.backoffMs = this.initialBackoffMs;
|
|
150
|
-
this.respectBackoff = options.respectBackoff ?? true;
|
|
151
|
-
this.now = options.now ?? (() => Date.now());
|
|
152
|
-
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
153
|
-
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
154
|
-
|
|
155
|
-
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
156
|
-
window.addEventListener("online", this.handleOnline);
|
|
157
|
-
window.addEventListener("offline", this.handleOffline);
|
|
158
|
-
}
|
|
159
|
-
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
160
|
-
this.state = "offline";
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** What the app should be told: are we connected? */
|
|
165
|
-
isOnline(): boolean {
|
|
166
|
-
if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
|
|
167
|
-
return this.state === "online";
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Should this request even be sent? False means "answer from the local
|
|
172
|
-
* store instead" — the request would only burn a timeout to reach the same
|
|
173
|
-
* conclusion the last one already did.
|
|
174
|
-
*/
|
|
175
|
-
shouldAttempt(): boolean {
|
|
176
|
-
if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
|
|
177
|
-
if (this.state === "online" || !this.respectBackoff) return true;
|
|
178
|
-
// Exactly one request is let through when the window opens; it is the
|
|
179
|
-
// probe whose outcome decides whether we are back.
|
|
180
|
-
return this.now() >= this.retryAt;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** A request reached the server. */
|
|
184
|
-
markSuccess(): void {
|
|
185
|
-
this.backoffMs = this.initialBackoffMs;
|
|
186
|
-
this.retryAt = 0;
|
|
187
|
-
this.clearPendingTimer();
|
|
188
|
-
this.setState("online");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** A request did not reach the server: we are offline until proven otherwise. */
|
|
192
|
-
markFailure(): void {
|
|
193
|
-
this.deferRetry();
|
|
194
|
-
this.setState("offline");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Back off and try again later without claiming the connection is gone.
|
|
199
|
-
* This is what a 429 or a 503 deserves — the server answered, so the app
|
|
200
|
-
* is demonstrably online; it just should not hammer.
|
|
201
|
-
*/
|
|
202
|
-
deferRetry(): void {
|
|
203
|
-
const jitter = 0.8 + Math.random() * 0.4;
|
|
204
|
-
this.retryAt = this.now() + this.backoffMs * jitter;
|
|
205
|
-
const delay = Math.max(0, this.retryAt - this.now());
|
|
206
|
-
this.backoffMs = Math.min(this.maxBackoffMs, this.backoffMs * 2);
|
|
207
|
-
this.scheduleRetry(delay);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Milliseconds until the next attempt is allowed; 0 when one is allowed now. */
|
|
211
|
-
msUntilRetry(): number {
|
|
212
|
-
if (this.state === "online") return 0;
|
|
213
|
-
return Math.max(0, this.retryAt - this.now());
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
onChange(listener: (online: boolean) => void): () => void {
|
|
217
|
-
this.listeners.add(listener);
|
|
218
|
-
return () => this.listeners.delete(listener);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
dispose(): void {
|
|
222
|
-
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
223
|
-
window.removeEventListener("online", this.handleOnline);
|
|
224
|
-
window.removeEventListener("offline", this.handleOffline);
|
|
225
|
-
}
|
|
226
|
-
this.clearPendingTimer();
|
|
227
|
-
this.listeners.clear();
|
|
228
|
-
this.onRetryDue = undefined;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private scheduleRetry(delay: number): void {
|
|
232
|
-
this.clearPendingTimer();
|
|
233
|
-
if (!this.onRetryDue) return;
|
|
234
|
-
this.timer = this.setTimer(() => {
|
|
235
|
-
this.timer = undefined;
|
|
236
|
-
this.onRetryDue?.();
|
|
237
|
-
}, delay);
|
|
238
|
-
// A retry timer must never be the reason a Node script refuses to exit.
|
|
239
|
-
(this.timer as unknown as { unref?: () => void }).unref?.();
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private clearPendingTimer(): void {
|
|
243
|
-
if (this.timer !== undefined) {
|
|
244
|
-
this.clearTimer(this.timer);
|
|
245
|
-
this.timer = undefined;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private setState(next: "online" | "offline"): void {
|
|
250
|
-
if (this.state === next) return;
|
|
251
|
-
this.state = next;
|
|
252
|
-
const online = this.isOnline();
|
|
253
|
-
for (const listener of this.listeners) listener(online);
|
|
254
|
-
}
|
|
255
|
-
}
|