@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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Compile-time assertions about the query surface.
3
+ *
4
+ * ## Read this before adding a `.test.ts` for a type
5
+ *
6
+ * These assertions are **not** in a test file, on purpose. In this repo a jest
7
+ * test cannot check a type at all:
8
+ *
9
+ * - `ts-jest` is configured transpile-only. Verified: a test containing
10
+ * `const n: number = "nope"` passes. `@ts-expect-error` in a `.test.ts` is
11
+ * therefore inert — it asserts nothing and never fails.
12
+ * - `tsconfig.typecheck.json` — the gate CI runs as `pnpm run typecheck` —
13
+ * covers every package's `src` directory but **excludes every `*.test.ts`**.
14
+ *
15
+ * So a type assertion written as a test is checked by nothing, twice over. This
16
+ * file is a plain module under `src`, which is exactly what the gate does read.
17
+ * It is imported by nothing and emits no runtime code.
18
+ *
19
+ * ## What went wrong that this exists to prevent
20
+ *
21
+ * `_score` was accepted by the runtime, documented in the SDK docs and skills,
22
+ * and rejected by `orderBy`'s type, which was `keyof M`. On a project with a
23
+ * generated SDK — where `M` is a concrete row type — the documented call was a
24
+ * compile error. Nothing in this repo noticed; a downstream application did.
25
+ */
26
+ import type { FindParams, FindResult, SDKQueryBuilderInterface, WhereFilterOp } from "@rebasepro/types";
27
+
28
+ /**
29
+ * A row shaped the way a **generated** SDK shapes one: a type alias with a
30
+ * finite key set.
31
+ *
32
+ * This detail is the whole test. An `interface … extends Record<string,
33
+ * unknown>` also satisfies the constraint, but its index signature makes
34
+ * `keyof M` collapse to `string` — so every assertion below would pass no
35
+ * matter what `orderBy` accepted, typos included. That is how the first draft
36
+ * of this file was written, and every `@ts-expect-error` in it reported
37
+ * "unused directive": the fixture proved nothing.
38
+ *
39
+ * A generated row type has no index signature, which is exactly why a real
40
+ * project caught what this repo did not.
41
+ */
42
+ type ContractRow = {
43
+ id: string;
44
+ title: string;
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;
50
+ };
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
+
56
+ // ── orderBy accepts relevance, and still rejects nonsense ───────────────────
57
+
58
+ /** The documented relevance sort must compile. */
59
+ export const orderByScore: FindParams<ContractRow> = {
60
+ searchString: "auditor",
61
+ orderBy: ["_score", "desc"]
62
+ };
63
+
64
+ /** An ordinary column must keep compiling. */
65
+ export const orderByColumn: FindParams<ContractRow> = { orderBy: ["created_at", "desc"] };
66
+
67
+ /**
68
+ * A column that does not exist must still be refused. Widening `orderBy` to
69
+ * `string` would have fixed the `_score` error and silently given up this,
70
+ * turning every typo into an unsorted 200 in production.
71
+ */
72
+ // @ts-expect-error - "nope" is neither a column of ContractRow nor computed
73
+ export const orderByTypo: FindParams<ContractRow> = { orderBy: ["nope", "desc"] };
74
+
75
+ // ── the fluent builder agrees with FindParams ──────────────────────────────
76
+
77
+ export const fluentScore = (qb: SDKQueryBuilderInterface<ContractRow>) =>
78
+ qb.search("auditor").orderBy("_score", "desc");
79
+
80
+ export const fluentColumn = (qb: SDKQueryBuilderInterface<ContractRow>) =>
81
+ qb.orderBy("created_at", "asc");
82
+
83
+ export const fluentTypo = (qb: SDKQueryBuilderInterface<ContractRow>) =>
84
+ // @ts-expect-error - the fluent signature must reject what FindParams rejects
85
+ qb.orderBy("_scoer", "desc");
86
+
87
+ /** Vector search must be reachable from the builder, and chain. */
88
+ export const fluentVector = (qb: SDKQueryBuilderInterface<ContractRow>) =>
89
+ qb.vectorSearch("embedding", [0.1, 0.2], { threshold: 0.3 }).limit(10);
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
+
175
+ // ── what a query computes is readable off the row ──────────────────────────
176
+
177
+ /**
178
+ * Sorting by relevance and then being unable to read it was the other half of
179
+ * the same bug — the e2e cast around it, which should have been the tell.
180
+ */
181
+ export const readComputed = (result: FindResult<ContractRow>) => {
182
+ const row = result.data[0];
183
+ const score: number | undefined = row._score;
184
+ const distance: number | undefined = row._distance;
185
+ const title: string = row.title;
186
+ return { score, distance, title };
187
+ };
188
+
189
+ /** Widening the row must not have turned it into `any`. */
190
+ export const readUnknown = (result: FindResult<ContractRow>) =>
191
+ // @ts-expect-error - `nope` is neither a column nor computed
192
+ result.data[0].nope;
193
+
194
+ /**
195
+ * A result row must stay assignable to `Record<string, unknown>`.
196
+ *
197
+ * Widening the row to `M & QueryComputedFields` broke this in seven places in
198
+ * one downstream app, because `QueryComputedFields` was first written as an
199
+ * `interface`: TypeScript grants an implicit index signature to a type alias
200
+ * and withholds it from an interface, so the intersection stopped overlapping
201
+ * with `Record<string, unknown>` and every `as Record<string, unknown>` cast
202
+ * became an error. Nothing in this repo casts a row that way, which is why
203
+ * nothing here noticed.
204
+ */
205
+ export const rowStaysIndexable = (result: FindResult<ContractRow>) =>
206
+ result.data.map(row => row as Record<string, unknown>);
@@ -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
+ });
@@ -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
+ WhereValueFor,
9
+ type ComputedSortField
9
10
  } from "@rebasepro/types";
10
11
 
11
12
  /**
@@ -36,7 +37,7 @@ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string,
36
37
  * @example
37
38
  * client.data.users.where('age', '>=', 18).find()
38
39
  */
39
- 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;
40
41
  where(logicalCondition: LogicalCondition): this;
41
42
  where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
42
43
  if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
@@ -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
+ });