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