@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.
- package/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- 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
|
-
});
|