@rebasepro/client 0.17.3 → 0.18.1

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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
package/src/transport.ts DELETED
@@ -1,505 +0,0 @@
1
- import { FindParams as TypesFindParams, FindResponse as TypesFindResponse, RebaseApiError } from "@rebasepro/types";
2
- import { serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
- import { rebaseReviver } from "./reviver";
4
-
5
- // The canonical client error now lives in `@rebasepro/types` so every package
6
- // (client, auth, …) throws one type. Re-exported here to preserve the historical
7
- // `import { RebaseApiError } from ".../transport"` path used across the SDK.
8
- export { RebaseApiError } from "@rebasepro/types";
9
- export type { RebaseErrorInit } from "@rebasepro/types";
10
- import { RebaseClientError } from "@rebasepro/types";
11
-
12
- export interface RebaseClientConfig {
13
- /**
14
- * Origin of the Rebase server — scheme, host and port **only**.
15
- *
16
- * {@link apiPath} is appended to this, so do not include it here:
17
- * `"http://localhost:3001"` is correct, while `"http://localhost:3001/api"`
18
- * silently builds `/api/api/…` and every request 404s. Omit entirely for
19
- * same-origin requests from the browser.
20
- */
21
- baseUrl?: string;
22
- /**
23
- * Bearer token sent as `Authorization` on every request.
24
- *
25
- * In the browser this is the signed-in user's access token, so row-level
26
- * security applies. Server-side callers — scripts, cron jobs, ETL — pass the
27
- * service key instead, which resolves to `{ uid: "service", roles: ["admin"] }`
28
- * and **bypasses RLS**: there is no user to constrain those queries, so scope
29
- * them explicitly.
30
- */
31
- token?: string;
32
- /**
33
- * Path the API is mounted under, appended to {@link baseUrl}.
34
- * Defaults to `"/api"`; override only if the server mounts it elsewhere.
35
- */
36
- apiPath?: string;
37
- /**
38
- * Origin to use instead of {@link baseUrl} for URLs that are handed to the
39
- * browser to fetch on its own — storage file downloads and previews.
40
- *
41
- * API *requests* always go to `baseUrl`; this only changes URLs the SDK
42
- * *returns* (e.g. `storage.getSignedUrl`). It exists for proxied setups:
43
- * when `baseUrl` routes through an authenticated middleman (the Rebase
44
- * console's Studio proxy), a plain `<img src>` or a copied link cannot
45
- * satisfy the middleman's auth — but the file route itself is reachable
46
- * directly at the origin server and secured by its own scoped `?token=`.
47
- * Set this to that server's public origin (no path; {@link apiPath} is
48
- * appended) and returned file URLs point straight at it.
49
- */
50
- storageUrlOrigin?: string;
51
- fetch?: typeof globalThis.fetch;
52
- onUnauthorized?: () => Promise<boolean>;
53
- websocketUrl?: string; // Optional real-time WebSocket connection
54
- /**
55
- * Open the realtime WebSocket. **Defaults to `true`.**
56
- *
57
- * The socket connects as soon as the client is constructed and keeps the
58
- * Node event loop alive, so a one-shot script (CLI, cron job, ETL) will not
59
- * exit on its own. Set this to `false` for any process that reads or writes
60
- * and then terminates — `.listen()` and `.listenById()` then throw instead
61
- * of silently doing nothing.
62
- *
63
- * Long-lived processes that do want realtime can instead call
64
- * `client.close()` when shutting down.
65
- */
66
- realtime?: boolean;
67
- /**
68
- * "Yes, I meant to be anonymous."
69
- *
70
- * Off-browser, a client with no credential can only ever call as an
71
- * anonymous user, and row-level security answers it with whatever is
72
- * public — usually nothing. That is almost always a mistake in a script or
73
- * cron job, so the SDK warns once on the first request (see
74
- * {@link ANONYMOUS_SERVER_CLIENT_WARNING}). Anonymous is a legitimate
75
- * choice for public reads, though; set this to `true` to say so and
76
- * silence the warning.
77
- *
78
- * Has no effect in the browser, where anonymous-before-sign-in is normal
79
- * and nothing is ever warned about.
80
- */
81
- anonymous?: boolean;
82
- }
83
-
84
- /**
85
- * Facts about the surrounding client that the transport cannot read off its own
86
- * config, but needs in order to decide whether a request is *meaningfully*
87
- * credential-less.
88
- */
89
- export interface TransportEnvironment {
90
- /**
91
- * The credential reaches the server without an `Authorization` header —
92
- * i.e. `auth.authFlowMode: "cookie"`, where the refresh token lives in an
93
- * httpOnly cookie. Such a client looks tokenless to the transport but is
94
- * not anonymous, so it must never trip the guard.
95
- */
96
- credentialOutOfBand?: boolean;
97
- }
98
-
99
- /**
100
- * True when there is no browser to have signed a user in — a Node script, a
101
- * cron job, an edge worker.
102
- *
103
- * Anonymous is an ordinary, correct state in a browser: before sign-in, on a
104
- * marketing page, for public reads. Warning there would be noise that teaches
105
- * people to ignore warnings, so the guard is off entirely. This uses the same
106
- * `typeof window` test as {@link resolveBaseUrl}, and additionally treats a
107
- * defined `document` as a browser so an SSR shim or test harness that installs
108
- * only one of the two is still excluded.
109
- */
110
- function isServerLikeEnvironment(): boolean {
111
- return typeof window === "undefined" && typeof document === "undefined";
112
- }
113
-
114
- /**
115
- * Emitted once per client. Kept as a constant so the wording is testable and
116
- * greppable — this is the string a user will paste into a search.
117
- */
118
- export const ANONYMOUS_SERVER_CLIENT_WARNING =
119
- "[rebase] This client was created outside a browser with no credential — no `token`, no auth token getter, "
120
- + "and no cookie auth flow — so every request runs as an anonymous caller. Row-level security will return only "
121
- + "publicly readable rows, which is usually nothing and occasionally the wrong thing. "
122
- + "Inside a cron or function handler, use the `rebase` you were handed instead of building a new one: its data "
123
- + "plane is already admin-scoped. In a standalone script or job, pass the service key as `token`. "
124
- + "If you really do want anonymous access, pass `anonymous: true` to silence this.";
125
-
126
- /**
127
- * Re-exported from `@rebasepro/types` so an SDK consumer can name the type of a
128
- * call it is already making without a second dependency.
129
- *
130
- * Forwards the row type: without the parameter this alias flattened
131
- * `FindParams<M>` back to its `Record<string, unknown>` default, and `where` /
132
- * `orderBy` went back to accepting any column name — the alias, not the
133
- * definition, was where the typing was lost.
134
- */
135
- export type FindParams<M extends Record<string, unknown> = Record<string, unknown>> = TypesFindParams<M>;
136
- export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
137
-
138
- /**
139
- * Refuse a filter whose *value* is missing.
140
- *
141
- * `where: { status: ["==", undefined] }` used to serialize to the literal
142
- * string, so `status=eq.undefined` went out on the wire and the server dutifully
143
- * looked for rows whose status is the four-letter word "undefined". The caller
144
- * saw an empty page, not an error — the classic shape of a variable that was
145
- * never set.
146
- *
147
- * Dropping the condition instead would be worse than sending it: the query
148
- * would come back *unfiltered*, which for an ownership or tenant filter means
149
- * returning rows the caller never asked to see. So this is a hard error, and
150
- * both correct spellings are named in the message: omit the key to skip the
151
- * filter, or use `["is-null", null]` to match SQL NULL (which still
152
- * serializes — `null` is a value, `undefined` is the absence of one).
153
- */
154
- function assertNoUndefinedFilterValues(where: Record<string, unknown>): void {
155
- const reject = (field: string, op: unknown): never => {
156
- throw new RebaseClientError(
157
- `Filter on "${field}" has an undefined value (["${String(op)}", undefined]). `
158
- + `Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL.`
159
- );
160
- };
161
-
162
- for (const [field, condition] of Object.entries(where)) {
163
- // An entirely absent condition is the documented way to skip a filter.
164
- if (condition === undefined) continue;
165
- if (!Array.isArray(condition)) continue;
166
-
167
- // Either one `[op, value]` tuple or an array of them.
168
- const tuples = Array.isArray(condition[0]) ? condition as unknown[][] : [condition as unknown[]];
169
- for (const tuple of tuples) {
170
- if (!Array.isArray(tuple) || tuple.length !== 2) continue;
171
- const [op, value] = tuple;
172
- if (value === undefined) reject(field, op);
173
- // `["in", [...]]` — a hole in the list is the same mistake.
174
- if (Array.isArray(value) && value.some(v => v === undefined)) reject(field, op);
175
- }
176
- }
177
- }
178
-
179
- export function buildQueryString(params?: FindParams): string {
180
- if (!params) return "";
181
- const parts: string[] = [];
182
-
183
- if (params.limit != null) parts.push(`limit=${params.limit}`);
184
- if (params.offset != null) parts.push(`offset=${params.offset}`);
185
- if (params.page != null) parts.push(`page=${params.page}`);
186
-
187
- if (params.orderBy) {
188
- const wire = serializeOrderBy(params.orderBy);
189
- if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
190
- }
191
-
192
- if (params.searchString) {
193
- parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
194
- if (params.searchExplain) parts.push("searchExplain=true");
195
- }
196
-
197
- // The server keys vector search off `vector_search` naming the property and
198
- // `vector` carrying the embedding as a JSON array; both must be present or
199
- // it ignores the pair entirely.
200
- if (params.vectorSearch) {
201
- const vs = params.vectorSearch;
202
- parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
203
- parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
204
- if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
205
- if (vs.threshold !== undefined) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
206
- }
207
-
208
- if (params.include && params.include.length > 0) {
209
- parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
210
- }
211
-
212
- if (params.logical) {
213
- const root = params.logical;
214
- const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
215
- parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
216
- }
217
-
218
- if (params.where) {
219
- assertNoUndefinedFilterValues(params.where);
220
- const serialized = serializeFilter(params.where);
221
- for (const [field, value] of Object.entries(serialized)) {
222
- if (Array.isArray(value)) {
223
- for (const v of value) {
224
- parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
225
- }
226
- } else {
227
- parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
228
- }
229
- }
230
- }
231
-
232
- return parts.length > 0 ? "?" + parts.join("&") : "";
233
- }
234
-
235
- export interface Transport {
236
- request: <T = unknown>(path: string, init?: RequestInit) => Promise<T>;
237
- setToken: (newToken: string | null) => void;
238
- setAuthTokenGetter: (getter: () => Promise<string | null>) => void;
239
- setOnUnauthorized: (handler: () => Promise<boolean>) => void;
240
- readonly baseUrl: string;
241
- readonly apiPath: string;
242
- /** See {@link RebaseClientConfig.storageUrlOrigin}. Undefined = use `baseUrl`. */
243
- readonly storageUrlOrigin?: string;
244
- readonly fetchFn: typeof globalThis.fetch;
245
- getHeaders: (init?: RequestInit) => Record<string, string>;
246
- resolveToken: () => Promise<string | null>;
247
- }
248
-
249
- /**
250
- * The base every request and every caller-built URL resolves against.
251
- *
252
- * `baseUrl` is optional because the common production shape is a Rebase
253
- * backend serving its own SPA, where the API is simply the page's origin.
254
- * Leaving it unset is therefore the *correct* configuration there — and the
255
- * one that keeps working when a second hostname (a custom domain) points at
256
- * the same app.
257
- *
258
- * When unset in a browser this resolves to the page origin rather than "".
259
- * Requests behave identically either way, but the empty string is a trap for
260
- * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
261
- * throws, so apps "fixed" it by baking an absolute host into their bundle —
262
- * which is exactly what breaks the day a custom domain is added, and which no
263
- * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
264
- * not sent cross-site either.
265
- */
266
- function resolveBaseUrl(configured?: string): string {
267
- if (configured) return configured.replace(/\/$/, "");
268
- if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
269
- return "";
270
- }
271
-
272
- export function createTransport(config: RebaseClientConfig, environment?: TransportEnvironment): Transport {
273
- const fetchFn = config.fetch || globalThis.fetch;
274
- const apiPath = config.apiPath || "/api";
275
-
276
- // `apiPath` is appended to `baseUrl`, so a `baseUrl` that already ends in it
277
- // builds `/api/api/…` and every request 404s. That was documented on
278
- // `baseUrl` and left to be discovered at runtime — including by this
279
- // package's own tests, which configured it that way a dozen times. A 404 on
280
- // every call looks like a server that is down, not like a doubled path.
281
- // `storageUrlOrigin` is checked alongside it because `storage.ts` composes
282
- // it the same way — `${storageUrlOrigin ?? baseUrl}${apiPath}` — and its own
283
- // docblock carries the same "no path" caveat.
284
- for (const field of ["baseUrl", "storageUrlOrigin"] as const) {
285
- const value = config[field];
286
- if (!value || !apiPath) continue;
287
- const trimmed = value.replace(/\/+$/, "");
288
- if (!trimmed.endsWith(apiPath)) continue;
289
- console.warn(
290
- `[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ` +
291
- `${JSON.stringify(apiPath)}, which is appended to it — requests will go to ` +
292
- `${trimmed}${apiPath}/… and 404. Pass the origin only ` +
293
- `(${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set ` +
294
- "`apiPath` if the server really does mount the API one level deeper."
295
- );
296
- }
297
- let token = config.token;
298
- let tokenGetter: (() => Promise<string | null>) | undefined;
299
- let onUnauthorizedHandler = config.onUnauthorized;
300
- /** Once per client, never per request — log spam is its own bug. */
301
- let anonymousWarningIssued = false;
302
-
303
- /**
304
- * Warn a server-side caller that it built a client that can only ever be
305
- * anonymous. Deliberately checked at the *first request* rather than at
306
- * construction: `setToken()` / `setAuthTokenGetter()` and a server-side
307
- * `auth.signIn…()` (which calls `transport.setToken`) all land after the
308
- * constructor, and warning at construction would fire on every one of them.
309
- */
310
- function warnIfAnonymousServerClient(activeToken: string | undefined): void {
311
- if (anonymousWarningIssued) return;
312
- if (activeToken) return; // a credential is being sent
313
- if (tokenGetter) return; // a credential is being fetched per request
314
- if (config.anonymous) return; // "yes, I meant this"
315
- if (environment?.credentialOutOfBand) return; // cookie auth flow — credential is not a header
316
- if (!isServerLikeEnvironment()) return; // browsers are legitimately anonymous
317
- anonymousWarningIssued = true;
318
- console.warn(ANONYMOUS_SERVER_CLIENT_WARNING);
319
- }
320
-
321
- function getHeaders(activeToken: string | undefined, init?: RequestInit) {
322
- return {
323
- "Content-Type": "application/json",
324
- ...(activeToken ? { Authorization: `Bearer ${activeToken}` } : {}),
325
- ...((init?.headers as Record<string, string>) || {})
326
- };
327
- }
328
-
329
- /**
330
- * The refusal for a success status carrying a body this client cannot read.
331
- *
332
- * The first 120 characters go in the message because they identify the
333
- * sender at a glance: `<!doctype html>` says "you are talking to a web
334
- * server, not to this API" faster than any wording here could.
335
- *
336
- * One function for both the first attempt and the post-refresh retry — the
337
- * retry is a second copy of this whole response-reading path, and copies
338
- * are how one of them ends up fixed and the other not.
339
- */
340
- function unreadableResponse(status: number, text: string): RebaseApiError {
341
- return new RebaseApiError(
342
- `The server answered ${status} with a body that is not JSON, so there is nothing to return. ` +
343
- "This usually means the request reached something other than the Rebase API — a single-page-app " +
344
- "fallback serving index.html, or a proxy error page — so check the API URL configuration " +
345
- `(e.g. VITE_API_URL). The body began: ${JSON.stringify(text.slice(0, 120))}`,
346
- { status, code: "INVALID_JSON_RESPONSE" }
347
- );
348
- }
349
-
350
- async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
351
- const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
352
-
353
- let activeToken = token;
354
- if (tokenGetter) {
355
- try {
356
- const fetched = await tokenGetter();
357
- if (fetched !== null && fetched !== undefined) {
358
- activeToken = fetched;
359
- }
360
- } catch (e) {
361
- // Ignore error, fallback to static token if any
362
- }
363
- }
364
-
365
- warnIfAnonymousServerClient(activeToken);
366
-
367
- const headers = getHeaders(activeToken, init);
368
-
369
- // If passing FormData, we MUST let fetch set the boundary, so remove Content-Type
370
- if (init?.body instanceof FormData) {
371
- delete (headers as Record<string, string>)["Content-Type"];
372
- }
373
-
374
- const res = await fetchFn(url, { ...init,
375
- headers });
376
-
377
- if (res.status === 204) return undefined as T; // SAFETY: HTTP 204 No Content has no body
378
-
379
- const text = await res.text().catch(() => "");
380
- let body: Record<string, unknown> = {};
381
- /**
382
- * Whether the body was there and could not be read as JSON.
383
- *
384
- * On an error status this does not matter — the status is the answer
385
- * and the message falls back to `statusText`. On a *success* status it
386
- * is the whole answer, and `{}` was being returned as though the server
387
- * had sent it: `find()` answered `{}` instead of an array, `getOne()`
388
- * an empty object, with nothing thrown.
389
- *
390
- * The case that produces it is not exotic. Point `VITE_API_URL` at the
391
- * frontend's own host and `/api/data/posts` lands on the SPA fallback,
392
- * which answers `200` with `index.html` — so the misconfiguration the
393
- * 404 branch below spends four lines explaining reaches the caller, in
394
- * its most common form, as an empty success.
395
- */
396
- let unreadableBody = false;
397
- if (text) {
398
- try {
399
- body = JSON.parse(text, rebaseReviver) as Record<string, unknown>;
400
- } catch (e) {
401
- unreadableBody = true;
402
- }
403
- }
404
-
405
- // The server always emits the canonical `{ error: { message, code, details? } }`
406
- // envelope (formatted by the central errorHandler), so we read strictly
407
- // from `body.error.*`.
408
- const getErrorField = (obj: Record<string, unknown>, field: string): unknown => {
409
- const err = obj?.error;
410
- if (err && typeof err === "object" && err !== null) {
411
- return (err as Record<string, unknown>)[field];
412
- }
413
- return undefined;
414
- };
415
-
416
- if (res.status === 401 && onUnauthorizedHandler) {
417
- const retried = await onUnauthorizedHandler();
418
- if (retried) {
419
- let retryToken = token;
420
- if (tokenGetter) {
421
- try {
422
- const fetched = await tokenGetter();
423
- if (fetched !== null && fetched !== undefined) {
424
- retryToken = fetched;
425
- }
426
- } catch (e) { /* ignore */ }
427
- }
428
- const retryHeaders = getHeaders(retryToken, init) as Record<string, string>;
429
- const retryRes = await fetchFn(url, { ...init,
430
- headers: retryHeaders });
431
- if (retryRes.status === 204) return undefined as T; // SAFETY: HTTP 204 No Content has no body
432
- const retryText = await retryRes.text().catch(() => "");
433
- let retryBody: Record<string, unknown> = {};
434
- let retryUnreadable = false;
435
- if (retryText) {
436
- try {
437
- retryBody = JSON.parse(retryText, rebaseReviver);
438
- } catch (e) {
439
- retryUnreadable = true;
440
- }
441
- }
442
- if (!retryRes.ok) {
443
- let fallbackMessage = retryRes.statusText;
444
- if (retryRes.status === 404 && !fallbackMessage) {
445
- const method = init?.method || "GET";
446
- fallbackMessage = `Endpoint not found (${method} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
447
- }
448
- throw new RebaseApiError(
449
- String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`),
450
- {
451
- status: retryRes.status,
452
- code: getErrorField(retryBody, "code") as string | undefined,
453
- details: getErrorField(retryBody, "details")
454
- }
455
- );
456
- }
457
- if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
458
- return retryBody as T;
459
- }
460
- }
461
-
462
- if (!res.ok) {
463
- let fallbackMessage = res.statusText;
464
- if (res.status === 404 && !fallbackMessage) {
465
- const method = init?.method || "GET";
466
- fallbackMessage = `Endpoint not found (${method} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
467
- }
468
- throw new RebaseApiError(
469
- String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`),
470
- {
471
- status: res.status,
472
- code: getErrorField(body, "code") as string | undefined,
473
- details: getErrorField(body, "details")
474
- }
475
- );
476
- }
477
-
478
- if (unreadableBody) throw unreadableResponse(res.status, text);
479
-
480
- return body as T;
481
- }
482
-
483
- return {
484
- request,
485
- setToken(newToken: string | null) { token = newToken || undefined; },
486
- setAuthTokenGetter(getter: () => Promise<string | null>) { tokenGetter = getter; },
487
- setOnUnauthorized(handler: () => Promise<boolean>) { onUnauthorizedHandler = handler; },
488
- get baseUrl() { return resolveBaseUrl(config.baseUrl); },
489
- get apiPath() { return apiPath; },
490
- get storageUrlOrigin() { return config.storageUrlOrigin?.replace(/\/$/, "") || undefined; },
491
- get fetchFn() { return fetchFn; },
492
- getHeaders: (init?: RequestInit) => getHeaders(token, init) as Record<string, string>,
493
- resolveToken: async () => {
494
- if (tokenGetter) {
495
- try {
496
- const fetched = await tokenGetter();
497
- if (fetched !== null && fetched !== undefined) {
498
- return fetched;
499
- }
500
- } catch (e) { /* ignore */ }
501
- }
502
- return token || null;
503
- }
504
- };
505
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * `.vectorSearch(…).listen()` must reach the server's refusal.
3
- *
4
- * `realtimeService` rejects a subscription carrying `vectorSearch` — a
5
- * subscription is re-run on every matching write and nothing there computes
6
- * distances — and the documentation promises that refusal. Both producers of a
7
- * subscription request hand-list their fields, and both omitted this one, so
8
- * the guard could not fire: the call returned an ordinary `id DESC` listing,
9
- * with no `_distance` and no error. Through `observe()` it was worse, because
10
- * the correct initial snapshot was then overwritten by the wrong socket
11
- * listing.
12
- *
13
- * Asserted on the request the client BUILDS rather than on a live socket: what
14
- * was missing is a field, and the refusal it has to reach is tested where it
15
- * lives.
16
- */
17
- import { readFileSync } from "node:fs";
18
- import { resolve } from "node:path";
19
-
20
- const listenRequestBlock = (file: string, marker: string) => {
21
- const source = readFileSync(resolve(__dirname, file), "utf-8");
22
- const start = source.indexOf(marker);
23
- expect(start).toBeGreaterThan(-1);
24
- return source.slice(start, start + 3000);
25
- };
26
-
27
- describe("a subscription request carries every field the server refuses on", () => {
28
- it("the client's listenCollection forwards vectorSearch", () => {
29
- const block = listenRequestBlock("./collection.ts", "ws.listenCollection(");
30
- expect(block).toContain("vectorSearch: params?.vectorSearch");
31
- });
32
-
33
- it("forwards it beside the fields it already forwarded", () => {
34
- // Guards against the assertion above passing on a stray mention: the
35
- // field has to be in the same object literal as the rest.
36
- const block = listenRequestBlock("./collection.ts", "ws.listenCollection(");
37
- const objectLiteral = block.slice(0, block.indexOf("},"));
38
- for (const field of ["path:", "filter:", "orderBy:", "searchString:", "vectorSearch:"]) {
39
- expect(objectLiteral).toContain(field);
40
- }
41
- });
42
- });
@@ -1,55 +0,0 @@
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
- });