@rebasepro/client 0.13.0 → 0.13.1-canary.g249daa1

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.
@@ -50,7 +50,7 @@ describe("realtime opt-out", () => {
50
50
  const { FakeWebSocket, opened } = trackingWebSocket();
51
51
  globalThis.WebSocket = FakeWebSocket;
52
52
 
53
- createRebaseClient({ baseUrl: "http://localhost:3000/api" });
53
+ createRebaseClient({ baseUrl: "http://localhost:3000" });
54
54
 
55
55
  expect(opened).toHaveLength(0);
56
56
  });
@@ -59,7 +59,7 @@ describe("realtime opt-out", () => {
59
59
  const { FakeWebSocket, opened } = trackingWebSocket();
60
60
  globalThis.WebSocket = FakeWebSocket;
61
61
 
62
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
62
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
63
63
 
64
64
  // Asking for the channel is not using it.
65
65
  const channel = client.realtime.channel("doc:1");
@@ -73,7 +73,7 @@ describe("realtime opt-out", () => {
73
73
  const { FakeWebSocket, opened } = trackingWebSocket();
74
74
  globalThis.WebSocket = FakeWebSocket;
75
75
 
76
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
76
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
77
77
  expect(opened).toHaveLength(0);
78
78
 
79
79
  client.collection("posts").listen!(undefined, () => { /* noop */ });
@@ -85,7 +85,7 @@ describe("realtime opt-out", () => {
85
85
  const { FakeWebSocket, opened } = trackingWebSocket();
86
86
  globalThis.WebSocket = FakeWebSocket;
87
87
 
88
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
88
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
89
89
 
90
90
  client.collection("posts").listen!(undefined, () => { /* noop */ });
91
91
  client.collection("authors").listen!(undefined, () => { /* noop */ });
@@ -102,7 +102,7 @@ describe("realtime opt-out", () => {
102
102
  const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
103
103
  const debug = jest.spyOn(console, "debug").mockImplementation(() => { /* capture */ });
104
104
 
105
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
105
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
106
106
  void client.realtime.channel("doc:1"); // obtained, never used
107
107
 
108
108
  expect(opened).toHaveLength(0);
@@ -118,7 +118,7 @@ describe("realtime opt-out", () => {
118
118
  delete globalThis.WebSocket;
119
119
  const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
120
120
 
121
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
121
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
122
122
  expect(warn).not.toHaveBeenCalled();
123
123
 
124
124
  void client.realtime.channel("doc:1").join();
@@ -134,7 +134,7 @@ describe("realtime opt-out", () => {
134
134
  globalThis.WebSocket = FakeWebSocket;
135
135
 
136
136
  const client = createRebaseClient({
137
- baseUrl: "http://localhost:3000/api",
137
+ baseUrl: "http://localhost:3000",
138
138
  realtime: false
139
139
  });
140
140
 
@@ -148,7 +148,7 @@ describe("realtime opt-out", () => {
148
148
  globalThis.WebSocket = FakeWebSocket;
149
149
 
150
150
  createRebaseClient({
151
- baseUrl: "http://localhost:3000/api",
151
+ baseUrl: "http://localhost:3000",
152
152
  websocketUrl: "ws://localhost:3000",
153
153
  realtime: false
154
154
  });
@@ -160,7 +160,7 @@ describe("realtime opt-out", () => {
160
160
  const { FakeWebSocket, opened, closed } = trackingWebSocket();
161
161
  globalThis.WebSocket = FakeWebSocket;
162
162
 
163
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
163
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
164
164
  client.collection("posts").listen!(undefined, () => { /* noop */ });
165
165
  expect(opened).toHaveLength(1);
166
166
 
@@ -176,7 +176,7 @@ describe("realtime opt-out", () => {
176
176
  const { FakeWebSocket, opened } = trackingWebSocket();
177
177
  globalThis.WebSocket = FakeWebSocket;
178
178
 
179
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
179
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
180
180
  client.collection("posts").listen!(undefined, () => { /* noop */ });
181
181
  expect(opened).toHaveLength(1);
182
182
 
@@ -190,10 +190,10 @@ describe("realtime opt-out", () => {
190
190
  const { FakeWebSocket } = trackingWebSocket();
191
191
  globalThis.WebSocket = FakeWebSocket;
192
192
 
193
- const offline = createRebaseClient({ baseUrl: "http://localhost:3000/api", realtime: false });
193
+ const offline = createRebaseClient({ baseUrl: "http://localhost:3000", realtime: false });
194
194
  expect(() => offline.close()).not.toThrow();
195
195
 
196
- const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
196
+ const live = createRebaseClient({ baseUrl: "http://localhost:3000" });
197
197
  live.close();
198
198
  expect(() => live.close()).not.toThrow();
199
199
  });
@@ -205,7 +205,7 @@ describe("realtime opt-out", () => {
205
205
  globalThis.WebSocket = FakeWebSocket;
206
206
 
207
207
  const client = createRebaseClient({
208
- baseUrl: "http://localhost:3000/api",
208
+ baseUrl: "http://localhost:3000",
209
209
  realtime: false
210
210
  });
211
211
 
@@ -244,7 +244,7 @@ isAnonymous: false }
244
244
  })) as unknown as typeof globalThis.fetch;
245
245
 
246
246
  const client = createRebaseClient({
247
- baseUrl: "http://localhost:3000/api",
247
+ baseUrl: "http://localhost:3000",
248
248
  fetch: fetchMock
249
249
  });
250
250
 
@@ -265,7 +265,7 @@ isAnonymous: false }
265
265
  globalThis.WebSocket = FakeWebSocket;
266
266
 
267
267
  const client = createRebaseClient({
268
- baseUrl: "http://localhost:3000/api",
268
+ baseUrl: "http://localhost:3000",
269
269
  realtime: false
270
270
  });
271
271
 
@@ -0,0 +1,92 @@
1
+ import { jest } from "@jest/globals";
2
+ import { RebaseWebSocketClient } from "./websocket";
3
+ import { or, cond } from "@rebasepro/common";
4
+
5
+ /**
6
+ * Two subscriptions are the same subscription only when they ask for the same
7
+ * rows. The key that decides this was built from a hand-listed subset of the
8
+ * props, so any field left off it made two different queries collide — and the
9
+ * second listener was handed the first one's rows, which is worse than not
10
+ * subscribing at all.
11
+ */
12
+
13
+ function fakeSocket() {
14
+ const sent: Record<string, unknown>[] = [];
15
+
16
+ class FakeWS {
17
+ static readonly OPEN = 1;
18
+ readyState = 1;
19
+ onopen: (() => void) | null = null;
20
+ onclose: (() => void) | null = null;
21
+ onerror: (() => void) | null = null;
22
+ onmessage: ((event: { data: string }) => void) | null = null;
23
+
24
+ constructor(public url: string) {
25
+ setTimeout(() => this.onopen?.(), 0);
26
+ }
27
+
28
+ send(raw: string) {
29
+ const message = JSON.parse(raw) as Record<string, unknown>;
30
+ sent.push(message);
31
+ if (message.type === "AUTHENTICATE") {
32
+ setTimeout(() => this.onmessage?.({
33
+ data: JSON.stringify({ type: "AUTH_SUCCESS", requestId: message.requestId })
34
+ }), 0);
35
+ }
36
+ }
37
+
38
+ close() { /* noop */ }
39
+ }
40
+
41
+ return { FakeWS: FakeWS as unknown as typeof WebSocket, sent };
42
+ }
43
+
44
+ function subscribeFrames(sent: Record<string, unknown>[]) {
45
+ return sent.filter((m) => m.type === "subscribe_collection");
46
+ }
47
+
48
+ describe("collection subscription identity", () => {
49
+ beforeEach(() => jest.useFakeTimers());
50
+ afterEach(() => jest.useRealTimers());
51
+
52
+ async function twoSubscriptions(
53
+ a: Record<string, unknown>,
54
+ b: Record<string, unknown>
55
+ ) {
56
+ const { FakeWS, sent } = fakeSocket();
57
+ const client = new RebaseWebSocketClient({
58
+ websocketUrl: "ws://localhost:1234",
59
+ WebSocket: FakeWS,
60
+ getAuthToken: async () => "token"
61
+ });
62
+ client.listenCollection({ path: "posts", ...a } as never, () => {});
63
+ client.listenCollection({ path: "posts", ...b } as never, () => {});
64
+ await jest.advanceTimersByTimeAsync(50);
65
+ return subscribeFrames(sent);
66
+ }
67
+
68
+ it("treats different offsets as different subscriptions", async () => {
69
+ // Page one and page two of the same live list. Sharing a subscription
70
+ // here shows page one's rows on page two.
71
+ const frames = await twoSubscriptions({ limit: 10, offset: 0 }, { limit: 10, offset: 10 });
72
+ expect(frames).toHaveLength(2);
73
+ });
74
+
75
+ it("treats different logical groups as different subscriptions", async () => {
76
+ const frames = await twoSubscriptions(
77
+ { logical: or(cond("status", "==", "draft")) },
78
+ { logical: or(cond("status", "==", "published")) }
79
+ );
80
+ expect(frames).toHaveLength(2);
81
+ });
82
+
83
+ it("still shares one subscription for genuinely identical queries", async () => {
84
+ // The de-duplication itself is the point of the key: two components
85
+ // watching the same list must not open two server subscriptions.
86
+ const frames = await twoSubscriptions(
87
+ { limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) },
88
+ { limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) }
89
+ );
90
+ expect(frames).toHaveLength(1);
91
+ });
92
+ });
@@ -5,7 +5,8 @@ import {
5
5
  SDKCollectionClient,
6
6
  SDKQueryBuilderInterface,
7
7
  WhereFilterOp,
8
- WhereValue
8
+ WhereValue,
9
+ type ComputedSortField
9
10
  } from "@rebasepro/types";
10
11
 
11
12
  /**
@@ -72,7 +73,7 @@ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string,
72
73
  /**
73
74
  * Order the results by a specific column.
74
75
  */
75
- orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
76
+ orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
76
77
  this.params.orderBy = [column, direction];
77
78
  return this;
78
79
  }
@@ -95,9 +96,60 @@ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string,
95
96
 
96
97
  /**
97
98
  * Set a free-text search string if supported by the backend.
99
+ *
100
+ * By default this is a substring match across the collection's top-level
101
+ * string properties. A Postgres collection that declares a `search` block
102
+ * gets ranked full-text matching over the fields it named instead, and each
103
+ * row comes back with a `_score` you can sort on:
104
+ *
105
+ * ```ts
106
+ * client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
107
+ * ```
108
+ *
109
+ * Pass `{ explain: true }` to have each row report which of the declared
110
+ * fields matched, with a highlighted snippet, on `_matches`:
111
+ *
112
+ * ```ts
113
+ * const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
114
+ * data[0]._matches
115
+ * // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
116
+ * ```
98
117
  */
99
- search(searchString: string): this {
118
+ search(searchString: string, options?: { explain?: boolean }): this {
100
119
  this.params.searchString = searchString;
120
+ if (options?.explain !== undefined) this.params.searchExplain = options.explain;
121
+ return this;
122
+ }
123
+
124
+ /**
125
+ * Order rows by nearest-neighbour distance to `vector`.
126
+ *
127
+ * The server has supported this from the REST layer since vectors landed;
128
+ * this is the SDK reaching it. Results come back closest-first with a
129
+ * `_distance` on each row, and any `where` / `orderBy` on the same query is
130
+ * a filter applied before the ordering — distance decides the order.
131
+ *
132
+ * You supply the query vector. Rebase stores and searches embeddings; it
133
+ * does not produce them, so this is where whatever model you already use
134
+ * for the stored vectors gets called.
135
+ *
136
+ * @param property - Name of the `vector` property to compare against.
137
+ * @param vector - The query embedding. Its length must match the property's
138
+ * declared `dimensions`, or the server answers 400.
139
+ * @example
140
+ * client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
141
+ */
142
+ vectorSearch(
143
+ property: string,
144
+ vector: number[],
145
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
146
+ ): this {
147
+ this.params.vectorSearch = {
148
+ property,
149
+ vector,
150
+ ...(options?.distance !== undefined && { distance: options.distance }),
151
+ ...(options?.threshold !== undefined && { threshold: options.threshold })
152
+ };
101
153
  return this;
102
154
  }
103
155
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from "@jest/globals";
1
+ import { describe, it, expect, afterEach, jest } from "@jest/globals";
2
2
  import { createTransport } from "./transport";
3
3
 
4
4
  /**
@@ -51,3 +51,51 @@ describe("transport baseUrl resolution", () => {
51
51
  expect(createTransport({}).baseUrl).toBe("");
52
52
  });
53
53
  });
54
+
55
+ describe("baseUrl that already contains the apiPath", () => {
56
+ /**
57
+ * The docblock on `baseUrl` says this "silently builds `/api/api/…` and
58
+ * every request 404s" — a failure mode understood well enough to be written
59
+ * down, and still left to be discovered at runtime. This package's own
60
+ * tests configured it that way a dozen times over, which is about as clear
61
+ * a signal as a trap gets.
62
+ */
63
+ it("warns rather than silently building /api/api", () => {
64
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
65
+ createTransport({ baseUrl: "http://localhost:3000/api" });
66
+
67
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("/api/api"));
68
+ warn.mockRestore();
69
+ });
70
+
71
+ it("warns for a custom apiPath too, and tolerates a trailing slash", () => {
72
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
73
+ createTransport({ baseUrl: "https://api.example.com/v2/", apiPath: "/v2" });
74
+
75
+ expect(warn).toHaveBeenCalledTimes(1);
76
+ warn.mockRestore();
77
+ });
78
+
79
+ it("says nothing about a baseUrl that merely ends in a similar word", () => {
80
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
81
+ createTransport({ baseUrl: "https://rapid.example.com" });
82
+ createTransport({ baseUrl: "https://example.com/myapi" });
83
+ createTransport({ baseUrl: "https://example.com" });
84
+
85
+ expect(warn).not.toHaveBeenCalled();
86
+ warn.mockRestore();
87
+ });
88
+ });
89
+
90
+ describe("storageUrlOrigin that already contains the apiPath", () => {
91
+ it("warns, because storage composes it the same way baseUrl is composed", () => {
92
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
93
+ createTransport({
94
+ baseUrl: "https://example.com",
95
+ storageUrlOrigin: "https://files.example.com/api"
96
+ });
97
+
98
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("storageUrlOrigin"));
99
+ warn.mockRestore();
100
+ });
101
+ });
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
@@ -1288,6 +1288,9 @@ options }
1288
1288
 
1289
1289
  const obj = val as Record<string, unknown>;
1290
1290
  if (obj.__type === "relation") {
1291
+ // `data` is dropped on purpose: a relation compares by the
1292
+ // reference it holds, not by the row it happens to have loaded.
1293
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1291
1294
  const { data, ...rest } = obj;
1292
1295
  return rest;
1293
1296
  }
@@ -1803,16 +1806,22 @@ onError });
1803
1806
  }
1804
1807
 
1805
1808
  private createCollectionSubscriptionKey(props: FetchCollectionProps): string {
1806
- // Create a deterministic key based on subscription parameters
1809
+ // Derived from the props, not a hand-listed subset of them.
1810
+ //
1811
+ // Two subscriptions share one server subscription when their keys
1812
+ // match, so a field left off the key makes two different queries
1813
+ // collide and hands the second listener the first one's rows. `offset`
1814
+ // and `logical` were both missing: page two of a live list showed page
1815
+ // one, and two views filtered by different `or(...)` groups saw the
1816
+ // same rows. Listing fields by hand is what let that happen, so the key
1817
+ // now covers whatever `FetchCollectionProps` carries.
1818
+ //
1819
+ // `collection` is the exception: it is the whole collection config,
1820
+ // property thunks and all, so it contributes its name as before.
1821
+ const { collection, ...query } = props as FetchCollectionProps & Record<string, unknown>;
1807
1822
  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
1823
+ ...query,
1824
+ collection: collection?.name
1816
1825
  };
1817
1826
  // Use replacer function (not array) to sort keys at all levels for deterministic output
1818
1827
  return JSON.stringify(key, (_, value) => {