@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/dist/index.d.ts +1 -0
- package/dist/index.es.js +452 -84
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +59 -3
- package/dist/offline-store.d.ts +8 -1
- package/dist/query-contract.types.d.ts +144 -0
- package/dist/sdk_query_builder.d.ts +46 -4
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth-refresh-overflow.test.ts +89 -0
- package/src/auth.ts +30 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +200 -57
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +116 -2
- package/src/offline-query.ts +123 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +180 -2
- package/src/offline.ts +169 -12
- package/src/query-contract.types.ts +206 -0
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/sdk_query_builder.ts +56 -4
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +34 -0
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +34 -9
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/sdk_query_builder.ts
CHANGED
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
SDKCollectionClient,
|
|
6
6
|
SDKQueryBuilderInterface,
|
|
7
7
|
WhereFilterOp,
|
|
8
|
-
|
|
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:
|
|
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
|
+
});
|