@rebasepro/client 0.13.1-canary.gf57a27e → 0.14.0
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.es.js +169 -11
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +41 -0
- package/dist/query-contract.types.d.ts +61 -1
- package/dist/sdk_query_builder.d.ts +2 -2
- package/dist/websocket.d.ts +5 -3
- package/package.json +4 -4
- package/src/collection.ts +2 -2
- package/src/index.ts +2 -2
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +88 -0
- package/src/offline-query.ts +83 -0
- package/src/offline.test.ts +66 -1
- package/src/offline.ts +48 -6
- package/src/query-contract.types.ts +93 -1
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/sdk_query_builder.ts +2 -2
- package/src/transport.ts +45 -2
- package/src/websocket.ts +21 -3
package/src/offline.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { buildQueryString, FindParams, RebaseApiError } from "./transport";
|
|
2
|
-
import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp,
|
|
2
|
+
import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValueFor, WriteOptions } from "@rebasepro/types";
|
|
3
3
|
import { collectAllPages, paginateFind } from "@rebasepro/common";
|
|
4
4
|
import { CollectionClient, LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
|
|
5
5
|
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
6
6
|
import { dehydrateRow, hydrateRow } from "./offline-codec";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ConnectivityMonitor,
|
|
9
|
+
isDuplicateKeyError,
|
|
10
|
+
isIdempotencyInProgressError,
|
|
11
|
+
isNetworkError,
|
|
12
|
+
isRetryableError
|
|
13
|
+
} from "./offline-connectivity";
|
|
8
14
|
import {
|
|
9
15
|
IndexedDBOfflineStore,
|
|
10
16
|
MemoryOfflineStore,
|
|
@@ -14,6 +20,7 @@ import {
|
|
|
14
20
|
} from "./offline-store";
|
|
15
21
|
import {
|
|
16
22
|
isExactlyEvaluable,
|
|
23
|
+
isLocallySortable,
|
|
17
24
|
matchesParams,
|
|
18
25
|
resolvePagination,
|
|
19
26
|
runLocalQuery,
|
|
@@ -218,6 +225,18 @@ interface Observer {
|
|
|
218
225
|
// all 1,700 lines of it in silence.
|
|
219
226
|
const MISSING = "\u0000missing";
|
|
220
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Replays to spend on a mutation whose idempotency key the server is still
|
|
230
|
+
* holding, when the app has not asked for more.
|
|
231
|
+
*
|
|
232
|
+
* Retries double from a second and cap at the sync interval, so the default
|
|
233
|
+
* budget of five covers about half a minute — less than the lease a server
|
|
234
|
+
* gives a claim nobody came back for. This many outlast it with room for a slow
|
|
235
|
+
* batch, and the count is what stops a server that never releases the key from
|
|
236
|
+
* blocking the queue behind it indefinitely.
|
|
237
|
+
*/
|
|
238
|
+
const IN_PROGRESS_MIN_RETRIES = 12;
|
|
239
|
+
|
|
221
240
|
export class OfflineManager {
|
|
222
241
|
private readonly store: OfflineStore;
|
|
223
242
|
private readonly maxCachedQueries: number;
|
|
@@ -729,7 +748,7 @@ data: u.data as AnyRow })),
|
|
|
729
748
|
return builder.where(
|
|
730
749
|
columnOrCondition as keyof M & string,
|
|
731
750
|
operator!,
|
|
732
|
-
value as
|
|
751
|
+
value as WhereValueFor<WhereFilterOp, M[keyof M & string]>
|
|
733
752
|
);
|
|
734
753
|
},
|
|
735
754
|
orderBy: (column, direction) => new SDKQueryBuilder<M>(wrapped).orderBy(column, direction),
|
|
@@ -1085,7 +1104,17 @@ data: u.data as AnyRow })),
|
|
|
1085
1104
|
// `sort` being silently ignored on every list backed by this overlay:
|
|
1086
1105
|
// the query carries it, the server honours it, and the answer served
|
|
1087
1106
|
// from here did not.
|
|
1088
|
-
|
|
1107
|
+
//
|
|
1108
|
+
// …but only when the local sort would land where the server's did.
|
|
1109
|
+
// `snapshot.ids` already arrived in the server's order, so re-sorting a
|
|
1110
|
+
// text column with `Intl.Collator` *replaces* a correct order with a
|
|
1111
|
+
// possibly different one — under the C collation Postgres puts
|
|
1112
|
+
// `Banana` before `apple` and the collator does not. When the column
|
|
1113
|
+
// cannot be ordered locally the snapshot's order is the better answer,
|
|
1114
|
+
// and the result says so rather than presenting it as the sorted page
|
|
1115
|
+
// that was asked for.
|
|
1116
|
+
const orderIsLocal = isLocallySortable(rows, params?.orderBy);
|
|
1117
|
+
if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
1089
1118
|
|
|
1090
1119
|
const total = Math.max(rows.length, snapshot.total - removed + added);
|
|
1091
1120
|
return {
|
|
@@ -1098,7 +1127,9 @@ data: u.data as AnyRow })),
|
|
|
1098
1127
|
},
|
|
1099
1128
|
fromCache,
|
|
1100
1129
|
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id as string | number)),
|
|
1101
|
-
|
|
1130
|
+
// Not the page that was asked for if either the membership
|
|
1131
|
+
// decision or the order could not be reproduced here.
|
|
1132
|
+
partial: !exact || !orderIsLocal
|
|
1102
1133
|
};
|
|
1103
1134
|
}
|
|
1104
1135
|
|
|
@@ -1479,7 +1510,18 @@ data: u.data as AnyRow })),
|
|
|
1479
1510
|
}
|
|
1480
1511
|
op.attempts = (op.attempts ?? 0) + 1;
|
|
1481
1512
|
op.lastError = (error as Error)?.message ?? String(error);
|
|
1482
|
-
|
|
1513
|
+
// A key the server is still holding gets a longer
|
|
1514
|
+
// budget than a busy server does. The claim outlives
|
|
1515
|
+
// the request that took it — the process was killed
|
|
1516
|
+
// between the write and its answer — so it is refused
|
|
1517
|
+
// until the claim's lease runs out, which is longer
|
|
1518
|
+
// than the default five retries reach. Giving up on
|
|
1519
|
+
// that schedule rolls back precisely the write the key
|
|
1520
|
+
// exists to save.
|
|
1521
|
+
const limit = isIdempotencyInProgressError(error)
|
|
1522
|
+
? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES)
|
|
1523
|
+
: this.maxRetries;
|
|
1524
|
+
if (isRetryableError(error) && op.attempts < limit) {
|
|
1483
1525
|
// The server is busy, not unhappy. Keep the op — and
|
|
1484
1526
|
// its place in line, since later writes may depend on
|
|
1485
1527
|
// it — and come back after a backoff.
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* generated SDK — where `M` is a concrete row type — the documented call was a
|
|
24
24
|
* compile error. Nothing in this repo noticed; a downstream application did.
|
|
25
25
|
*/
|
|
26
|
-
import type { FindParams, FindResult, SDKQueryBuilderInterface } from "@rebasepro/types";
|
|
26
|
+
import type { FindParams, FindResult, SDKQueryBuilderInterface, WhereFilterOp } from "@rebasepro/types";
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* A row shaped the way a **generated** SDK shapes one: a type alias with a
|
|
@@ -43,8 +43,16 @@ type ContractRow = {
|
|
|
43
43
|
id: string;
|
|
44
44
|
title: string;
|
|
45
45
|
created_at: string;
|
|
46
|
+
/** An `array` property, which codegen emits as `Array<X>`. */
|
|
47
|
+
tags: string[];
|
|
48
|
+
age: number;
|
|
49
|
+
deleted_at: string | null;
|
|
46
50
|
};
|
|
47
51
|
|
|
52
|
+
/** A to-many relation, which codegen emits as `Array<TargetRow>`. */
|
|
53
|
+
type TagRow = { id: string; label: string };
|
|
54
|
+
type PostRow = { id: string; title: string; tags: TagRow[] };
|
|
55
|
+
|
|
48
56
|
// ── orderBy accepts relevance, and still rejects nonsense ───────────────────
|
|
49
57
|
|
|
50
58
|
/** The documented relevance sort must compile. */
|
|
@@ -80,6 +88,90 @@ export const fluentTypo = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
|
80
88
|
export const fluentVector = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
81
89
|
qb.vectorSearch("embedding", [0.1, 0.2], { threshold: 0.3 }).limit(10);
|
|
82
90
|
|
|
91
|
+
// ── the operator decides what the value is ─────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `array-contains` takes an **element** of the column, not the column.
|
|
95
|
+
*
|
|
96
|
+
* This was the second `_score`: documented in `docs/sdk/querying.md`, accepted
|
|
97
|
+
* by the runtime, and a compile error on a generated SDK — because
|
|
98
|
+
* `WhereValue<T> = T | T[] | null` was one value type for all sixteen
|
|
99
|
+
* operators, so on `tags: string[]` it wanted a `string[]`. The spelling that
|
|
100
|
+
* did compile, `["featured"]`, builds `@> ARRAY[$1]` with the whole array bound
|
|
101
|
+
* as the single element and matches nothing, forever, with no error anywhere.
|
|
102
|
+
*/
|
|
103
|
+
export const fluentArrayContains = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
104
|
+
qb.where("tags", "array-contains", "featured");
|
|
105
|
+
|
|
106
|
+
export const fluentArrayContainsWrapped = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
107
|
+
// @ts-expect-error - the column is not one of its own elements
|
|
108
|
+
qb.where("tags", "array-contains", ["featured"]);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Same defect, and the case the relation compiler was specifically built for:
|
|
112
|
+
* a to-many relation is emitted as `Array<TargetRow>`, and the compiler answers
|
|
113
|
+
* `array-contains` on it by comparing **ids**. So the id must be accepted even
|
|
114
|
+
* though it is not the element type.
|
|
115
|
+
*/
|
|
116
|
+
export const fluentRelationContains = (qb: SDKQueryBuilderInterface<PostRow>, tagId: string) =>
|
|
117
|
+
qb.where("tags", "array-contains", tagId);
|
|
118
|
+
|
|
119
|
+
export const fluentRelationIn = (qb: SDKQueryBuilderInterface<PostRow>, tagIds: string[]) =>
|
|
120
|
+
qb.where("tags", "in", tagIds);
|
|
121
|
+
|
|
122
|
+
export const fluentRelationTypo = (qb: SDKQueryBuilderInterface<PostRow>) =>
|
|
123
|
+
// @ts-expect-error - neither a `TagRow` nor a `TagRow["id"]`
|
|
124
|
+
qb.where("tags", "array-contains", 42);
|
|
125
|
+
|
|
126
|
+
/** The list operators take a list of elements — or one, read as a one-element list. */
|
|
127
|
+
export const fluentInList = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
128
|
+
qb.where("tags", "in", ["featured", "new"]);
|
|
129
|
+
|
|
130
|
+
export const fluentInScalar = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
131
|
+
qb.where("title", "in", "hello");
|
|
132
|
+
|
|
133
|
+
export const fluentInNested = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
134
|
+
// @ts-expect-error - a list of lists is not a list of elements
|
|
135
|
+
qb.where("tags", "in", [["featured"]]);
|
|
136
|
+
|
|
137
|
+
/** A comparison takes one value. `eq(column, ["a","b"])` is not a query anyone meant. */
|
|
138
|
+
export const fluentEqArray = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
139
|
+
// @ts-expect-error - `==` compares against a value, not a list
|
|
140
|
+
qb.where("title", "==", ["a", "b"]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A pattern is a string on every column type. The driver casts, so refusing
|
|
144
|
+
* `"%3%"` on a numeric column was the type being stricter than the runtime.
|
|
145
|
+
*/
|
|
146
|
+
export const fluentLikeOnNumber = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
147
|
+
qb.where("age", "like", "%3%");
|
|
148
|
+
|
|
149
|
+
/** The null operators ignore their value; `null` is the conventional spelling. */
|
|
150
|
+
export const fluentIsNull = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
151
|
+
qb.where("deleted_at", "is-null", null);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A caller holding an unnarrowed operator — a dynamic filter UI — must keep
|
|
155
|
+
* compiling. `WhereValueFor` distributes over the operator, so this is the
|
|
156
|
+
* union of every branch rather than a `never`.
|
|
157
|
+
*/
|
|
158
|
+
export const fluentDynamicOp = (qb: SDKQueryBuilderInterface<ContractRow>, op: WhereFilterOp) =>
|
|
159
|
+
qb.where("title", op, "anything");
|
|
160
|
+
|
|
161
|
+
/** Two conditions on one column: the shape the Mongo compiler used to drop. */
|
|
162
|
+
export const fluentRange = (qb: SDKQueryBuilderInterface<ContractRow>) =>
|
|
163
|
+
qb.where("age", ">=", 18).where("age", "<", 65);
|
|
164
|
+
|
|
165
|
+
/** The object form must accept exactly what the fluent form accepts. */
|
|
166
|
+
export const paramsArrayContains: FindParams<ContractRow> = {
|
|
167
|
+
where: { tags: ["array-contains", "featured"] }
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** …and the array-of-tuples form the builder produces from two `.where()` calls. */
|
|
171
|
+
export const paramsRange: FindParams<ContractRow> = {
|
|
172
|
+
where: { age: [[">=", 18], ["<", 65]] }
|
|
173
|
+
};
|
|
174
|
+
|
|
83
175
|
// ── what a query computes is readable off the row ──────────────────────────
|
|
84
176
|
|
|
85
177
|
/**
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server errors about channel frames used to be discarded by the client.
|
|
5
|
+
*
|
|
6
|
+
* Channel messages are fire-and-forget by design: the SDK deliberately
|
|
7
|
+
* registers no `pendingRequests` waiter for them, because their answers come
|
|
8
|
+
* back addressed by channel rather than in a response envelope. The consequence
|
|
9
|
+
* was that an `ERROR` frame — `RATE_LIMITED`, `CHANNEL_FORBIDDEN`,
|
|
10
|
+
* `CHANNEL_HISTORY_WRITE_FAILED` — matched no waiter, no channel and no
|
|
11
|
+
* subscription, fell through every branch of `handleWebSocketMessage`, and was
|
|
12
|
+
* dropped. `await channel.broadcast(...)` resolved as if it had been sent.
|
|
13
|
+
*/
|
|
14
|
+
function fakeSocket() {
|
|
15
|
+
const sockets: FakeWS[] = [];
|
|
16
|
+
|
|
17
|
+
class FakeWS {
|
|
18
|
+
static readonly OPEN = 1;
|
|
19
|
+
readyState = 1;
|
|
20
|
+
onopen: (() => void) | null = null;
|
|
21
|
+
onclose: (() => void) | null = null;
|
|
22
|
+
onerror: (() => void) | null = null;
|
|
23
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(public url: string) {
|
|
26
|
+
sockets.push(this);
|
|
27
|
+
setTimeout(() => this.onopen?.(), 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
send() { /* nothing here needs the outgoing frames */ }
|
|
31
|
+
close() { /* noop */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const instance = () => sockets[sockets.length - 1];
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
FakeWS: FakeWS as unknown as typeof WebSocket,
|
|
38
|
+
deliver: (frame: unknown) => instance()?.onmessage?.({ data: JSON.stringify(frame) })
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("unmatched realtime error frames", () => {
|
|
43
|
+
let warn: any;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
jest.useFakeTimers();
|
|
47
|
+
warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
warn.mockRestore();
|
|
52
|
+
jest.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const connect = async () => {
|
|
56
|
+
const { RebaseWebSocketClient } = await import("./websocket");
|
|
57
|
+
const { FakeWS, deliver } = fakeSocket();
|
|
58
|
+
const client = new RebaseWebSocketClient({
|
|
59
|
+
websocketUrl: "ws://localhost:3000",
|
|
60
|
+
WebSocket: FakeWS
|
|
61
|
+
});
|
|
62
|
+
client.ensureConnected();
|
|
63
|
+
await jest.advanceTimersByTimeAsync(1);
|
|
64
|
+
return { client, deliver };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
it("surfaces a channel rate-limit refusal that matches no waiter", async () => {
|
|
68
|
+
const { deliver } = await connect();
|
|
69
|
+
|
|
70
|
+
deliver({
|
|
71
|
+
type: "ERROR",
|
|
72
|
+
requestId: "req-the-client-never-registered",
|
|
73
|
+
payload: { error: { message: "Too many channel messages. Please slow down.", code: "RATE_LIMITED" } }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(String(warn.mock.calls[0][0])).toContain("RATE_LIMITED");
|
|
78
|
+
expect(String(warn.mock.calls[0][0])).toContain("Too many channel messages");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("surfaces a refused channel action", async () => {
|
|
82
|
+
const { deliver } = await connect();
|
|
83
|
+
|
|
84
|
+
// What the server now sends for a broadcast into a channel the sender
|
|
85
|
+
// never joined — `sendError` addresses it by neither requestId nor
|
|
86
|
+
// channel, so nothing else in the client will ever see it.
|
|
87
|
+
deliver({
|
|
88
|
+
type: "error",
|
|
89
|
+
payload: { error: { message: "Refused broadcast on channel \"doc:42\"", code: "CHANNEL_FORBIDDEN" } },
|
|
90
|
+
error: "Refused broadcast on channel \"doc:42\""
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(String(warn.mock.calls[0][0])).toContain("CHANNEL_FORBIDDEN");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("stays quiet for frames that are not errors", async () => {
|
|
98
|
+
const { deliver } = await connect();
|
|
99
|
+
|
|
100
|
+
deliver({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 } });
|
|
101
|
+
deliver({ type: "presence_state", channel: "doc:42", presences: {} });
|
|
102
|
+
|
|
103
|
+
expect(warn).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/sdk_query_builder.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
SDKCollectionClient,
|
|
6
6
|
SDKQueryBuilderInterface,
|
|
7
7
|
WhereFilterOp,
|
|
8
|
-
|
|
8
|
+
WhereValueFor,
|
|
9
9
|
type ComputedSortField
|
|
10
10
|
} from "@rebasepro/types";
|
|
11
11
|
|
|
@@ -37,7 +37,7 @@ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string,
|
|
|
37
37
|
* @example
|
|
38
38
|
* client.data.users.where('age', '>=', 18).find()
|
|
39
39
|
*/
|
|
40
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
40
|
+
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
|
|
41
41
|
where(logicalCondition: LogicalCondition): this;
|
|
42
42
|
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
|
|
43
43
|
if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
|
package/src/transport.ts
CHANGED
|
@@ -325,6 +325,27 @@ export function createTransport(config: RebaseClientConfig, environment?: Transp
|
|
|
325
325
|
};
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* The refusal for a success status carrying a body this client cannot read.
|
|
330
|
+
*
|
|
331
|
+
* The first 120 characters go in the message because they identify the
|
|
332
|
+
* sender at a glance: `<!doctype html>` says "you are talking to a web
|
|
333
|
+
* server, not to this API" faster than any wording here could.
|
|
334
|
+
*
|
|
335
|
+
* One function for both the first attempt and the post-refresh retry — the
|
|
336
|
+
* retry is a second copy of this whole response-reading path, and copies
|
|
337
|
+
* are how one of them ends up fixed and the other not.
|
|
338
|
+
*/
|
|
339
|
+
function unreadableResponse(status: number, text: string): RebaseApiError {
|
|
340
|
+
return new RebaseApiError(
|
|
341
|
+
`The server answered ${status} with a body that is not JSON, so there is nothing to return. ` +
|
|
342
|
+
"This usually means the request reached something other than the Rebase API — a single-page-app " +
|
|
343
|
+
"fallback serving index.html, or a proxy error page — so check the API URL configuration " +
|
|
344
|
+
`(e.g. VITE_API_URL). The body began: ${JSON.stringify(text.slice(0, 120))}`,
|
|
345
|
+
{ status, code: "INVALID_JSON_RESPONSE" }
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
328
349
|
async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
|
329
350
|
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
330
351
|
|
|
@@ -356,11 +377,27 @@ headers });
|
|
|
356
377
|
|
|
357
378
|
const text = await res.text().catch(() => "");
|
|
358
379
|
let body: Record<string, unknown> = {};
|
|
380
|
+
/**
|
|
381
|
+
* Whether the body was there and could not be read as JSON.
|
|
382
|
+
*
|
|
383
|
+
* On an error status this does not matter — the status is the answer
|
|
384
|
+
* and the message falls back to `statusText`. On a *success* status it
|
|
385
|
+
* is the whole answer, and `{}` was being returned as though the server
|
|
386
|
+
* had sent it: `find()` answered `{}` instead of an array, `getOne()`
|
|
387
|
+
* an empty object, with nothing thrown.
|
|
388
|
+
*
|
|
389
|
+
* The case that produces it is not exotic. Point `VITE_API_URL` at the
|
|
390
|
+
* frontend's own host and `/api/data/posts` lands on the SPA fallback,
|
|
391
|
+
* which answers `200` with `index.html` — so the misconfiguration the
|
|
392
|
+
* 404 branch below spends four lines explaining reaches the caller, in
|
|
393
|
+
* its most common form, as an empty success.
|
|
394
|
+
*/
|
|
395
|
+
let unreadableBody = false;
|
|
359
396
|
if (text) {
|
|
360
397
|
try {
|
|
361
398
|
body = JSON.parse(text, rebaseReviver) as Record<string, unknown>;
|
|
362
399
|
} catch (e) {
|
|
363
|
-
|
|
400
|
+
unreadableBody = true;
|
|
364
401
|
}
|
|
365
402
|
}
|
|
366
403
|
|
|
@@ -393,10 +430,13 @@ headers: retryHeaders });
|
|
|
393
430
|
if (retryRes.status === 204) return undefined as T; // SAFETY: HTTP 204 No Content has no body
|
|
394
431
|
const retryText = await retryRes.text().catch(() => "");
|
|
395
432
|
let retryBody: Record<string, unknown> = {};
|
|
433
|
+
let retryUnreadable = false;
|
|
396
434
|
if (retryText) {
|
|
397
435
|
try {
|
|
398
436
|
retryBody = JSON.parse(retryText, rebaseReviver);
|
|
399
|
-
} catch (e) {
|
|
437
|
+
} catch (e) {
|
|
438
|
+
retryUnreadable = true;
|
|
439
|
+
}
|
|
400
440
|
}
|
|
401
441
|
if (!retryRes.ok) {
|
|
402
442
|
let fallbackMessage = retryRes.statusText;
|
|
@@ -413,6 +453,7 @@ headers: retryHeaders });
|
|
|
413
453
|
}
|
|
414
454
|
);
|
|
415
455
|
}
|
|
456
|
+
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
|
|
416
457
|
return retryBody as T;
|
|
417
458
|
}
|
|
418
459
|
}
|
|
@@ -433,6 +474,8 @@ headers: retryHeaders });
|
|
|
433
474
|
);
|
|
434
475
|
}
|
|
435
476
|
|
|
477
|
+
if (unreadableBody) throw unreadableResponse(res.status, text);
|
|
478
|
+
|
|
436
479
|
return body as T;
|
|
437
480
|
}
|
|
438
481
|
|
package/src/websocket.ts
CHANGED
|
@@ -76,9 +76,11 @@ const CHANNEL_MESSAGE_TYPES = new Set([
|
|
|
76
76
|
*
|
|
77
77
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
78
78
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
79
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It
|
|
80
|
-
* package root
|
|
81
|
-
*
|
|
79
|
+
* `RebaseWebSocket` contract in `@rebasepro/types`). It stays exported from the
|
|
80
|
+
* package root for the same reason it always was — a data-source driver may
|
|
81
|
+
* instantiate it directly — but nothing in this repo does since
|
|
82
|
+
* `@rebasepro/client-postgres` was removed; its surface may change without a
|
|
83
|
+
* major bump.
|
|
82
84
|
*/
|
|
83
85
|
export class RebaseWebSocketClient {
|
|
84
86
|
private websocketUrl: string;
|
|
@@ -895,6 +897,22 @@ export class RebaseWebSocketClient {
|
|
|
895
897
|
} else {
|
|
896
898
|
callback.onUpdate(message);
|
|
897
899
|
}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// An error that matched no waiter used to fall off the end of this
|
|
904
|
+
// method and disappear. Channel frames are the ones that always do:
|
|
905
|
+
// they are fire-and-forget by design, so no `pendingRequests` entry
|
|
906
|
+
// exists to reject and the server's errors about them — RATE_LIMITED,
|
|
907
|
+
// CHANNEL_FORBIDDEN, CHANNEL_HISTORY_WRITE_FAILED — were dropped while
|
|
908
|
+
// `await channel.broadcast(...)` resolved as if it had been sent. A
|
|
909
|
+
// console warning is the floor, not the answer: an `onError` on
|
|
910
|
+
// `RebaseRealtimeChannel` is the shape this should eventually take.
|
|
911
|
+
if (type === "ERROR" || type === "error" || message.error) {
|
|
912
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
913
|
+
console.warn(
|
|
914
|
+
`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`
|
|
915
|
+
);
|
|
898
916
|
}
|
|
899
917
|
}
|
|
900
918
|
|