@rebasepro/client 0.13.0 → 0.13.1-canary.g06dbe5b

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/transport.ts CHANGED
@@ -190,6 +190,18 @@ export function buildQueryString(params?: FindParams): string {
190
190
 
191
191
  if (params.searchString) {
192
192
  parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
193
+ if (params.searchExplain) parts.push("searchExplain=true");
194
+ }
195
+
196
+ // The server keys vector search off `vector_search` naming the property and
197
+ // `vector` carrying the embedding as a JSON array; both must be present or
198
+ // it ignores the pair entirely.
199
+ if (params.vectorSearch) {
200
+ const vs = params.vectorSearch;
201
+ parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
202
+ parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
203
+ if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
204
+ if (vs.threshold !== undefined) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
193
205
  }
194
206
 
195
207
  if (params.include && params.include.length > 0) {
@@ -259,6 +271,28 @@ function resolveBaseUrl(configured?: string): string {
259
271
  export function createTransport(config: RebaseClientConfig, environment?: TransportEnvironment): Transport {
260
272
  const fetchFn = config.fetch || globalThis.fetch;
261
273
  const apiPath = config.apiPath || "/api";
274
+
275
+ // `apiPath` is appended to `baseUrl`, so a `baseUrl` that already ends in it
276
+ // builds `/api/api/…` and every request 404s. That was documented on
277
+ // `baseUrl` and left to be discovered at runtime — including by this
278
+ // package's own tests, which configured it that way a dozen times. A 404 on
279
+ // every call looks like a server that is down, not like a doubled path.
280
+ // `storageUrlOrigin` is checked alongside it because `storage.ts` composes
281
+ // it the same way — `${storageUrlOrigin ?? baseUrl}${apiPath}` — and its own
282
+ // docblock carries the same "no path" caveat.
283
+ for (const field of ["baseUrl", "storageUrlOrigin"] as const) {
284
+ const value = config[field];
285
+ if (!value || !apiPath) continue;
286
+ const trimmed = value.replace(/\/+$/, "");
287
+ if (!trimmed.endsWith(apiPath)) continue;
288
+ console.warn(
289
+ `[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ` +
290
+ `${JSON.stringify(apiPath)}, which is appended to it — requests will go to ` +
291
+ `${trimmed}${apiPath}/… and 404. Pass the origin only ` +
292
+ `(${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set ` +
293
+ "`apiPath` if the server really does mount the API one level deeper."
294
+ );
295
+ }
262
296
  let token = config.token;
263
297
  let tokenGetter: (() => Promise<string | null>) | undefined;
264
298
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The SDK's route to vector search.
3
+ *
4
+ * The Postgres driver has served `vector_search` since vectors landed, but no
5
+ * method on the query builder ever reached it and it appeared in no OpenAPI
6
+ * spec — so the only way to use a shipped feature was to hand-build the URL.
7
+ * These tests pin the wire format the server's parser actually expects.
8
+ */
9
+ import { buildQueryString } from "./transport";
10
+ import type { FindParams } from "@rebasepro/types";
11
+
12
+ const params = (p: FindParams): URLSearchParams =>
13
+ new URLSearchParams(buildQueryString(p).replace(/^\?/, ""));
14
+
15
+ describe("vector search serialization", () => {
16
+ it("sends the property under `vector_search` and the embedding as a JSON array", () => {
17
+ const q = params({ vectorSearch: { property: "embedding", vector: [0.1, -0.2, 0.3] } });
18
+ expect(q.get("vector_search")).toBe("embedding");
19
+ expect(q.get("vector")).toBe("[0.1,-0.2,0.3]");
20
+ });
21
+
22
+ it("omits distance and threshold when not asked for, so the server's defaults stand", () => {
23
+ const q = params({ vectorSearch: { property: "embedding", vector: [1] } });
24
+ expect(q.has("vector_distance")).toBe(false);
25
+ expect(q.has("vector_threshold")).toBe(false);
26
+ });
27
+
28
+ it("sends distance and threshold when given", () => {
29
+ const q = params({
30
+ vectorSearch: { property: "embedding", vector: [1], distance: "l2", threshold: 0.35 }
31
+ });
32
+ expect(q.get("vector_distance")).toBe("l2");
33
+ expect(q.get("vector_threshold")).toBe("0.35");
34
+ });
35
+
36
+ it("sends a threshold of 0, which is a real bound and not an absent one", () => {
37
+ const q = params({ vectorSearch: { property: "embedding", vector: [1], threshold: 0 } });
38
+ expect(q.get("vector_threshold")).toBe("0");
39
+ });
40
+
41
+ it("adds nothing at all when there is no vector search", () => {
42
+ expect(buildQueryString({ limit: 10 })).not.toContain("vector");
43
+ });
44
+
45
+ it("stacks with filters and limit, which the server ANDs before ordering by distance", () => {
46
+ const q = params({
47
+ vectorSearch: { property: "embedding", vector: [0.5] },
48
+ where: { status: ["==", "published"] },
49
+ limit: 5
50
+ });
51
+ expect(q.get("vector_search")).toBe("embedding");
52
+ expect(q.get("limit")).toBe("5");
53
+ expect(buildQueryString({ where: { status: ["==", "published"] } })).toContain("status");
54
+ });
55
+ });
@@ -0,0 +1,97 @@
1
+ import { jest } from "@jest/globals";
2
+ import { createRebaseClient } from "./index";
3
+
4
+ /**
5
+ * Where the realtime socket dials, given a `baseUrl`.
6
+ *
7
+ * The socket URL is derived rather than configured, so every disagreement
8
+ * between how it is derived and where the API actually lives shows up only as
9
+ * realtime not working — never as an error naming a cause.
10
+ */
11
+
12
+ function setWindow(origin: string | undefined) {
13
+ if (origin === undefined) {
14
+ delete (globalThis as never as { window?: unknown }).window;
15
+ return;
16
+ }
17
+ (globalThis as never as { window: unknown }).window = {
18
+ location: { origin, href: origin + "/" }
19
+ };
20
+ }
21
+
22
+ function trackingWebSocket() {
23
+ const opened: string[] = [];
24
+ class FakeWebSocket {
25
+ static readonly OPEN = 1;
26
+ readyState = 0;
27
+ onopen: (() => void) | null = null;
28
+ onclose: (() => void) | null = null;
29
+ onerror: (() => void) | null = null;
30
+ onmessage: (() => void) | null = null;
31
+ constructor(public url: string) { opened.push(url); }
32
+ close() { /* no-op */ }
33
+ send() { /* no-op */ }
34
+ }
35
+ return { FakeWebSocket: FakeWebSocket as unknown as typeof WebSocket, opened };
36
+ }
37
+
38
+ describe("realtime socket URL", () => {
39
+ const originalWebSocket = globalThis.WebSocket;
40
+
41
+ afterEach(() => {
42
+ globalThis.WebSocket = originalWebSocket;
43
+ setWindow(undefined);
44
+ jest.restoreAllMocks();
45
+ });
46
+
47
+ it("keeps the path of a relative baseUrl, as it does for an absolute one", () => {
48
+ // A backend mounted under a path is the reason `baseUrl` accepts one.
49
+ // Resolving the relative form to `.origin` dropped it, so the same
50
+ // deployment dialled two different URLs depending on whether its
51
+ // baseUrl was written as "/backend" or "https://app.example.com/backend".
52
+ const { FakeWebSocket, opened } = trackingWebSocket();
53
+ globalThis.WebSocket = FakeWebSocket;
54
+ setWindow("https://app.example.com");
55
+
56
+ const relative = createRebaseClient({ baseUrl: "/backend" });
57
+ relative.collection("posts").listen!(undefined, () => {});
58
+
59
+ const absolute = createRebaseClient({ baseUrl: "https://app.example.com/backend" });
60
+ absolute.collection("posts").listen!(undefined, () => {});
61
+
62
+ expect(opened[0]).toBe("wss://app.example.com/backend");
63
+ expect(opened[0]).toBe(opened[1]);
64
+ });
65
+
66
+ it("dials the page origin when no baseUrl is set in a browser", () => {
67
+ const { FakeWebSocket, opened } = trackingWebSocket();
68
+ globalThis.WebSocket = FakeWebSocket;
69
+ setWindow("https://app.example.com");
70
+
71
+ const client = createRebaseClient({});
72
+ client.collection("posts").listen!(undefined, () => {});
73
+
74
+ expect(opened[0]).toBe("wss://app.example.com");
75
+ });
76
+
77
+ it("says why realtime is unavailable when no URL could be derived", () => {
78
+ // Node, with a relative baseUrl there is nothing to resolve against.
79
+ // The client silently had no socket, and `realtime.channel()` then
80
+ // blamed `realtime: false` — an option the caller never passed.
81
+ setWindow(undefined);
82
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
83
+ const client = createRebaseClient({ baseUrl: "/backend" });
84
+
85
+ expect(client.ws).toBeUndefined();
86
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("websocketUrl"));
87
+ expect(() => client.realtime.channel("room")).toThrow(/no WebSocket URL could be derived/i);
88
+ // It may still *suggest* `realtime: false` as the way to make this
89
+ // deliberate; what it must not do is claim that is what happened.
90
+ expect(() => client.realtime.channel("room")).not.toThrow(/disabled on this client/);
91
+ });
92
+
93
+ it("still blames the opt-out when realtime really was switched off", () => {
94
+ const client = createRebaseClient({ baseUrl: "https://api.example.com", realtime: false });
95
+ expect(() => client.realtime.channel("room")).toThrow(/realtime: false/);
96
+ });
97
+ });
package/src/websocket.ts CHANGED
@@ -895,6 +895,22 @@ export class RebaseWebSocketClient {
895
895
  } else {
896
896
  callback.onUpdate(message);
897
897
  }
898
+ return;
899
+ }
900
+
901
+ // An error that matched no waiter used to fall off the end of this
902
+ // method and disappear. Channel frames are the ones that always do:
903
+ // they are fire-and-forget by design, so no `pendingRequests` entry
904
+ // exists to reject and the server's errors about them — RATE_LIMITED,
905
+ // CHANNEL_FORBIDDEN, CHANNEL_HISTORY_WRITE_FAILED — were dropped while
906
+ // `await channel.broadcast(...)` resolved as if it had been sent. A
907
+ // console warning is the floor, not the answer: an `onError` on
908
+ // `RebaseRealtimeChannel` is the shape this should eventually take.
909
+ if (type === "ERROR" || type === "error" || message.error) {
910
+ const { errorMessage, errorCode } = extractMessageError(message);
911
+ console.warn(
912
+ `[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`
913
+ );
898
914
  }
899
915
  }
900
916
 
@@ -1288,6 +1304,9 @@ options }
1288
1304
 
1289
1305
  const obj = val as Record<string, unknown>;
1290
1306
  if (obj.__type === "relation") {
1307
+ // `data` is dropped on purpose: a relation compares by the
1308
+ // reference it holds, not by the row it happens to have loaded.
1309
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1291
1310
  const { data, ...rest } = obj;
1292
1311
  return rest;
1293
1312
  }
@@ -1803,16 +1822,22 @@ onError });
1803
1822
  }
1804
1823
 
1805
1824
  private createCollectionSubscriptionKey(props: FetchCollectionProps): string {
1806
- // Create a deterministic key based on subscription parameters
1825
+ // Derived from the props, not a hand-listed subset of them.
1826
+ //
1827
+ // Two subscriptions share one server subscription when their keys
1828
+ // match, so a field left off the key makes two different queries
1829
+ // collide and hands the second listener the first one's rows. `offset`
1830
+ // and `logical` were both missing: page two of a live list showed page
1831
+ // one, and two views filtered by different `or(...)` groups saw the
1832
+ // same rows. Listing fields by hand is what let that happen, so the key
1833
+ // now covers whatever `FetchCollectionProps` carries.
1834
+ //
1835
+ // `collection` is the exception: it is the whole collection config,
1836
+ // property thunks and all, so it contributes its name as before.
1837
+ const { collection, ...query } = props as FetchCollectionProps & Record<string, unknown>;
1807
1838
  const key = {
1808
- path: props.path,
1809
- filter: props.filter,
1810
- limit: props.limit,
1811
- startAfter: props.startAfter,
1812
- orderBy: props.orderBy,
1813
- order: props.order,
1814
- searchString: props.searchString,
1815
- collection: props.collection?.name
1839
+ ...query,
1840
+ collection: collection?.name
1816
1841
  };
1817
1842
  // Use replacer function (not array) to sort keys at all levels for deterministic output
1818
1843
  return JSON.stringify(key, (_, value) => {