@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/src/offline.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { buildQueryString, FindParams, RebaseApiError } from "./transport";
2
- import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue, WriteOptions } from "@rebasepro/types";
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 { ConnectivityMonitor, isDuplicateKeyError, isNetworkError, isRetryableError } from "./offline-connectivity";
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 WhereValue<M[keyof M & string]>
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
- if (params?.orderBy) sortRows(rows, params.orderBy);
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
- partial: !exact
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
- if (isRetryableError(error) && op.attempts < this.maxRetries) {
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
+ });
@@ -5,7 +5,7 @@ import {
5
5
  SDKCollectionClient,
6
6
  SDKQueryBuilderInterface,
7
7
  WhereFilterOp,
8
- WhereValue,
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: WhereFilterOp, value: WhereValue<M[K]>): this;
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
- // If not valid JSON, fallback
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) { /* ignore */ }
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 is re-exported from the
80
- * package root only because the `@rebasepro/client-postgres` driver
81
- * instantiates it directly; its surface may change without a major bump.
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