@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +331 -79
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +18 -3
- package/dist/offline-store.d.ts +8 -1
- package/dist/query-contract.types.d.ts +84 -0
- package/dist/sdk_query_builder.d.ts +45 -3
- 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 +198 -55
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-query.test.ts +28 -2
- package/src/offline-query.ts +40 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +114 -1
- package/src/offline.ts +123 -8
- package/src/query-contract.types.ts +114 -0
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/sdk_query_builder.ts +55 -3
- 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 +18 -9
|
@@ -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
|
-
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
|
-
//
|
|
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
|
-
|
|
1809
|
-
|
|
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) => {
|