@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
package/src/collection.ts
CHANGED
|
@@ -9,12 +9,19 @@ import {
|
|
|
9
9
|
SDKQueryBuilderInterface,
|
|
10
10
|
WhereFilterOp,
|
|
11
11
|
WhereValue,
|
|
12
|
-
WriteOptions
|
|
12
|
+
WriteOptions,
|
|
13
|
+
type ComputedSortField
|
|
13
14
|
} from "@rebasepro/types";
|
|
14
|
-
import { collectAllPages, paginateFind } from "@rebasepro/common";
|
|
15
|
+
import { collectAllPages, paginateFind, resolveFindWindow } from "@rebasepro/common";
|
|
15
16
|
|
|
16
17
|
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Counts currently in flight, keyed by the exact request they issue. Entries
|
|
21
|
+
* live only for the duration of the request — see `count()` for why.
|
|
22
|
+
*/
|
|
23
|
+
const inflightCounts = new Map<string, Promise<number>>();
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* A live query result: a normal {@link FindResult} plus what an interface
|
|
20
27
|
* needs to decide whether to show a "saving…" or "offline" affordance over it.
|
|
@@ -155,7 +162,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
155
162
|
return raw as M;
|
|
156
163
|
},
|
|
157
164
|
|
|
158
|
-
async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
|
|
165
|
+
async createMany(data: Partial<M>[], options?: { upsert?: boolean } & WriteOptions) {
|
|
159
166
|
if (!Array.isArray(data)) {
|
|
160
167
|
throw new TypeError("createMany expects an array of records.");
|
|
161
168
|
}
|
|
@@ -166,11 +173,29 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
166
173
|
body: JSON.stringify({
|
|
167
174
|
rows: data,
|
|
168
175
|
...(options?.upsert ? { upsert: true } : {})
|
|
169
|
-
})
|
|
176
|
+
}),
|
|
177
|
+
...(options?.idempotencyKey
|
|
178
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
179
|
+
: {})
|
|
170
180
|
});
|
|
171
181
|
return (raw.data || []) as M[];
|
|
172
182
|
},
|
|
173
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Still `PUT`, deliberately, even though the server now serves `PATCH`
|
|
186
|
+
* on the same handler and `PATCH` is the honest verb for a merge.
|
|
187
|
+
*
|
|
188
|
+
* The two are interchangeable server-side, so switching buys nothing at
|
|
189
|
+
* runtime — and it costs compatibility in the direction that fails
|
|
190
|
+
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
191
|
+
* a route that does not exist and get a **404**, which is
|
|
192
|
+
* indistinguishable from "that row is gone". Every write would look like
|
|
193
|
+
* a missing record.
|
|
194
|
+
*
|
|
195
|
+
* `PATCH` is what the OpenAPI spec advertises, so anyone generating a
|
|
196
|
+
* client gets the correct verb; this stays on `PUT` until the oldest
|
|
197
|
+
* supported server is one that serves both.
|
|
198
|
+
*/
|
|
174
199
|
async update(id: string | number, data: Partial<M>) {
|
|
175
200
|
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
176
201
|
method: "PUT",
|
|
@@ -179,12 +204,54 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
179
204
|
return raw as M;
|
|
180
205
|
},
|
|
181
206
|
|
|
207
|
+
async updateMany(updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) {
|
|
208
|
+
if (!Array.isArray(updates)) {
|
|
209
|
+
throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
210
|
+
}
|
|
211
|
+
if (updates.length === 0) return [];
|
|
212
|
+
|
|
213
|
+
const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
|
|
214
|
+
method: "PATCH",
|
|
215
|
+
body: JSON.stringify({ updates }),
|
|
216
|
+
...(options?.idempotencyKey
|
|
217
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
218
|
+
: {})
|
|
219
|
+
});
|
|
220
|
+
return (raw.data || []) as M[];
|
|
221
|
+
},
|
|
222
|
+
|
|
182
223
|
async delete(id: string | number) {
|
|
183
224
|
await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
184
225
|
method: "DELETE"
|
|
185
226
|
});
|
|
186
227
|
},
|
|
187
228
|
|
|
229
|
+
/**
|
|
230
|
+
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
231
|
+
*
|
|
232
|
+
* The honest verb would take the ids in a DELETE body, and that is the
|
|
233
|
+
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
234
|
+
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
235
|
+
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
236
|
+
* operation, so a generated client would send the request without its
|
|
237
|
+
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
238
|
+
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
239
|
+
*/
|
|
240
|
+
async deleteMany(ids: (string | number)[], options?: WriteOptions) {
|
|
241
|
+
if (!Array.isArray(ids)) {
|
|
242
|
+
throw new TypeError("deleteMany expects an array of ids.");
|
|
243
|
+
}
|
|
244
|
+
if (ids.length === 0) return;
|
|
245
|
+
|
|
246
|
+
await transport.request<void>(`${basePath}/bulk/delete`, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
body: JSON.stringify({ ids }),
|
|
249
|
+
...(options?.idempotencyKey
|
|
250
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
251
|
+
: {})
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
|
|
188
255
|
async count(params?: FindParams<M>): Promise<number> {
|
|
189
256
|
const countParams: FindParams<M> = {
|
|
190
257
|
...params,
|
|
@@ -198,8 +265,34 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
198
265
|
include: undefined
|
|
199
266
|
};
|
|
200
267
|
const qs = buildQueryString(countParams);
|
|
201
|
-
|
|
202
|
-
|
|
268
|
+
|
|
269
|
+
// One count per query in flight, not one per caller.
|
|
270
|
+
//
|
|
271
|
+
// A count is a property of the query, and every concurrent caller
|
|
272
|
+
// asking the same question wants the same answer — so they can
|
|
273
|
+
// share the one request. This is not a micro-optimisation: a live
|
|
274
|
+
// subscription re-counts on every push (see `listen` below), and
|
|
275
|
+
// `listenCollection` deliberately collapses identical queries onto
|
|
276
|
+
// a single socket subscription while keeping one callback per
|
|
277
|
+
// subscriber. Each push therefore woke N subscribers, and each of
|
|
278
|
+
// them issued its own identical count. A table showing one relation
|
|
279
|
+
// column fired one count per visible cell, on every update.
|
|
280
|
+
//
|
|
281
|
+
// The entry is dropped as soon as it settles, so this merges
|
|
282
|
+
// concurrent calls only and never serves a cached total.
|
|
283
|
+
const key = basePath + "/count" + qs;
|
|
284
|
+
const inflight = inflightCounts.get(key);
|
|
285
|
+
if (inflight) return inflight;
|
|
286
|
+
|
|
287
|
+
const request = transport
|
|
288
|
+
.request<{ count: number }>(key, { method: "GET" })
|
|
289
|
+
.then((raw) => raw.count ?? 0);
|
|
290
|
+
inflightCounts.set(key, request);
|
|
291
|
+
try {
|
|
292
|
+
return await request;
|
|
293
|
+
} finally {
|
|
294
|
+
inflightCounts.delete(key);
|
|
295
|
+
}
|
|
203
296
|
},
|
|
204
297
|
|
|
205
298
|
// Reactive reads. Without the offline layer there is no local database
|
|
@@ -212,15 +305,36 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
212
305
|
options?: ObserveOptions
|
|
213
306
|
) {
|
|
214
307
|
let closed = false;
|
|
215
|
-
|
|
308
|
+
// Two sources race into one callback: the one-shot fetch below and
|
|
309
|
+
// the subscription beside it. Whichever resolves last used to win,
|
|
310
|
+
// so a socket update that landed first was overwritten by the
|
|
311
|
+
// fetch's older snapshot and stayed wrong until the next change.
|
|
312
|
+
// `listenCollection` replays cached rows synchronously to a second
|
|
313
|
+
// subscriber, so a second component observing the same query hit
|
|
314
|
+
// that ordering every time.
|
|
315
|
+
let liveDelivered = false;
|
|
316
|
+
let signature: string | undefined;
|
|
317
|
+
|
|
318
|
+
const deliver = (result: FindResult<M>, fromLive: boolean) => {
|
|
216
319
|
if (closed) return;
|
|
320
|
+
// Once the socket has spoken, the fetch issued alongside it is
|
|
321
|
+
// older news — delivering it would move the app backwards.
|
|
322
|
+
if (fromLive) liveDelivered = true;
|
|
323
|
+
else if (liveDelivered) return;
|
|
324
|
+
// The de-duplication `observe()` documents. Keyed on the rows
|
|
325
|
+
// and the total, the same two things the offline layer keys on,
|
|
326
|
+
// so both implementations mean the same thing by "changed".
|
|
327
|
+
const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
|
|
328
|
+
if (signature !== undefined && next === signature) return;
|
|
329
|
+
signature = next;
|
|
217
330
|
onResult({ ...result, fromCache: false, hasPendingWrites: false, partial: false });
|
|
218
331
|
};
|
|
219
|
-
|
|
332
|
+
|
|
333
|
+
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
220
334
|
if (!closed) onError?.(error as Error);
|
|
221
335
|
});
|
|
222
336
|
const live = options?.realtime !== false && client.listen
|
|
223
|
-
? client.listen(params,
|
|
337
|
+
? client.listen(params, (result) => deliver(result, true), onError)
|
|
224
338
|
: undefined;
|
|
225
339
|
return () => {
|
|
226
340
|
closed = true;
|
|
@@ -235,15 +349,25 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
235
349
|
options?: ObserveOptions
|
|
236
350
|
) {
|
|
237
351
|
let closed = false;
|
|
238
|
-
|
|
352
|
+
let liveDelivered = false;
|
|
353
|
+
let signature: string | undefined;
|
|
354
|
+
|
|
355
|
+
// Same ordering and de-duplication rules as `observe`, for one row.
|
|
356
|
+
const deliver = (row: M | undefined, fromLive: boolean) => {
|
|
239
357
|
if (closed) return;
|
|
358
|
+
if (fromLive) liveDelivered = true;
|
|
359
|
+
else if (liveDelivered) return;
|
|
360
|
+
const next = row === undefined ? "\u0000missing" : JSON.stringify(row);
|
|
361
|
+
if (signature !== undefined && next === signature) return;
|
|
362
|
+
signature = next;
|
|
240
363
|
onResult(row, { fromCache: false, hasPendingWrites: false });
|
|
241
364
|
};
|
|
242
|
-
|
|
365
|
+
|
|
366
|
+
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
243
367
|
if (!closed) onError?.(error as Error);
|
|
244
368
|
});
|
|
245
369
|
const live = options?.realtime !== false && client.listenById
|
|
246
|
-
? client.listenById(id,
|
|
370
|
+
? client.listenById(id, (row) => deliver(row, true), onError)
|
|
247
371
|
: undefined;
|
|
248
372
|
return () => {
|
|
249
373
|
closed = true;
|
|
@@ -259,7 +383,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
259
383
|
}
|
|
260
384
|
return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
|
|
261
385
|
},
|
|
262
|
-
orderBy(column: keyof M & string, direction?: "asc" | "desc") {
|
|
386
|
+
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc") {
|
|
263
387
|
return new SDKQueryBuilder<M>(client).orderBy(column, direction);
|
|
264
388
|
},
|
|
265
389
|
limit(count: number) {
|
|
@@ -268,8 +392,15 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
268
392
|
offset(count: number) {
|
|
269
393
|
return new SDKQueryBuilder<M>(client).offset(count);
|
|
270
394
|
},
|
|
271
|
-
search(searchString: string) {
|
|
272
|
-
return new SDKQueryBuilder<M>(client).search(searchString);
|
|
395
|
+
search(searchString: string, options?: { explain?: boolean }) {
|
|
396
|
+
return new SDKQueryBuilder<M>(client).search(searchString, options);
|
|
397
|
+
},
|
|
398
|
+
vectorSearch(
|
|
399
|
+
property: string,
|
|
400
|
+
vector: number[],
|
|
401
|
+
options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
|
|
402
|
+
) {
|
|
403
|
+
return new SDKQueryBuilder<M>(client).vectorSearch(property, vector, options);
|
|
273
404
|
},
|
|
274
405
|
include(...relations: string[]) {
|
|
275
406
|
return new SDKQueryBuilder<M>(client).include(...relations);
|
|
@@ -280,69 +411,81 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
280
411
|
client.listen = (params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
|
|
281
412
|
let active = true;
|
|
282
413
|
let lastUpdateId = 0;
|
|
414
|
+
// The last total a `count()` actually returned. A later count that
|
|
415
|
+
// fails says nothing about how big the collection is, so it must
|
|
416
|
+
// not be allowed to replace this with the length of one page.
|
|
417
|
+
let lastKnownTotal: number | undefined;
|
|
418
|
+
const window = resolveFindWindow(params);
|
|
283
419
|
const unsub = ws.listenCollection(
|
|
284
420
|
{
|
|
285
421
|
path: slug,
|
|
286
422
|
filter: params?.where,
|
|
423
|
+
// The group used to be dropped here, so a subscription
|
|
424
|
+
// filtered with `or(...)` was pushed every row instead.
|
|
425
|
+
logical: params?.logical,
|
|
287
426
|
limit: params?.limit,
|
|
288
|
-
|
|
427
|
+
// `offset` used to be stringified into `startAfter`, which
|
|
428
|
+
// is a cursor *row*, not a row count — so the server was
|
|
429
|
+
// handed "20" where it expected a keyset value, and the
|
|
430
|
+
// offset it does understand never arrived at all.
|
|
431
|
+
offset: window.driverOffset,
|
|
289
432
|
orderBy: params?.orderBy?.[0],
|
|
290
433
|
order: params?.orderBy?.[1],
|
|
291
|
-
searchString: params?.searchString
|
|
434
|
+
searchString: params?.searchString,
|
|
435
|
+
searchExplain: params?.searchExplain
|
|
292
436
|
},
|
|
293
437
|
(incomingRows: Record<string, unknown>[]) => {
|
|
294
438
|
const currentUpdateId = ++lastUpdateId;
|
|
295
|
-
|
|
296
|
-
|
|
439
|
+
// What the server pages by when the caller names no limit.
|
|
440
|
+
// A hardcoded 20 here described a window the rows had not
|
|
441
|
+
// come from, and any app sizing its next request off
|
|
442
|
+
// `meta.limit` was told the wrong number.
|
|
443
|
+
const requestedLimit = window.limit;
|
|
444
|
+
const offset = window.offset;
|
|
297
445
|
|
|
298
446
|
// WS client already delivers flat rows — just cast
|
|
299
447
|
const rows = incomingRows as M[];
|
|
300
448
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
449
|
+
const emit = (total: number, hasMore: boolean) => {
|
|
450
|
+
if (!active || currentUpdateId !== lastUpdateId) return;
|
|
451
|
+
onUpdate({
|
|
452
|
+
data: rows,
|
|
453
|
+
meta: {
|
|
454
|
+
total,
|
|
455
|
+
limit: requestedLimit,
|
|
456
|
+
offset,
|
|
457
|
+
hasMore
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// With no count to go on, the only defensible total is a
|
|
463
|
+
// lower bound: the rows on this page plus the ones paged
|
|
464
|
+
// past to reach them. Reporting `rows.length` claimed a
|
|
465
|
+
// collection read at offset 10 held two rows.
|
|
466
|
+
const emitWithoutCount = () => emit(
|
|
467
|
+
offset + rows.length,
|
|
468
|
+
rows.length >= requestedLimit
|
|
469
|
+
);
|
|
304
470
|
|
|
305
|
-
// Try to get authoritative count; fall back to heuristic
|
|
306
471
|
if (client.count) {
|
|
307
472
|
client.count(params)
|
|
308
473
|
.then((total) => {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
data: rows,
|
|
312
|
-
meta: {
|
|
313
|
-
total,
|
|
314
|
-
limit: requestedLimit,
|
|
315
|
-
offset,
|
|
316
|
-
hasMore: offset + rows.length < total
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
}
|
|
474
|
+
lastKnownTotal = total;
|
|
475
|
+
emit(total, offset + rows.length < total);
|
|
320
476
|
})
|
|
321
477
|
.catch(() => {
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
offset,
|
|
330
|
-
hasMore: heuristicHasMore
|
|
331
|
-
}
|
|
332
|
-
});
|
|
478
|
+
// A count that failed is not evidence about the
|
|
479
|
+
// size of the collection. Keep the last real
|
|
480
|
+
// answer; only guess if there has never been one.
|
|
481
|
+
if (lastKnownTotal !== undefined) {
|
|
482
|
+
emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
483
|
+
} else {
|
|
484
|
+
emitWithoutCount();
|
|
333
485
|
}
|
|
334
486
|
});
|
|
335
487
|
} else {
|
|
336
|
-
|
|
337
|
-
onUpdate({
|
|
338
|
-
data: rows,
|
|
339
|
-
meta: {
|
|
340
|
-
total: heuristicTotal,
|
|
341
|
-
limit: requestedLimit,
|
|
342
|
-
offset,
|
|
343
|
-
hasMore: heuristicHasMore
|
|
344
|
-
}
|
|
345
|
-
});
|
|
488
|
+
emitWithoutCount();
|
|
346
489
|
}
|
|
347
490
|
},
|
|
348
491
|
onError
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,9 @@ import { toSnakeCase } from "@rebasepro/utils";
|
|
|
42
42
|
// data-proxy's unknown-collection error.
|
|
43
43
|
export { RebaseApiError } from "./transport";
|
|
44
44
|
export { RebaseClientError } from "./errors";
|
|
45
|
+
// The codes `RebaseApiError.code` carries. An open union — routes add their own
|
|
46
|
+
// — so it gives completion on the common ones without pretending to be closed.
|
|
47
|
+
export type { RebaseErrorCode } from "@rebasepro/types";
|
|
45
48
|
|
|
46
49
|
// Query + collection types (annotate SDK results; construct via the fluent API).
|
|
47
50
|
export type { RebaseClientConfig, FindParams, FindResponse } from "./transport";
|
|
@@ -238,35 +241,48 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
|
|
|
238
241
|
/**
|
|
239
242
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
240
243
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
244
|
+
*
|
|
245
|
+
* A backend mounted under a path is the reason `baseUrl` accepts one, so the
|
|
246
|
+
* path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
|
|
247
|
+
* relative one — resolved through `.origin` — so one deployment dialled two
|
|
248
|
+
* different sockets depending on whether its config said `"/backend"` or
|
|
249
|
+
* `"https://app.example.com/backend"`.
|
|
250
|
+
*
|
|
251
|
+
* Returns `""` when there is nothing to resolve against: a relative `baseUrl`
|
|
252
|
+
* outside a browser has no origin, and inventing one would dial somewhere
|
|
253
|
+
* arbitrary. The caller warns rather than leaving that silent.
|
|
241
254
|
*/
|
|
242
255
|
function deriveWebSocketUrl(baseUrl?: string): string {
|
|
256
|
+
const toWsProtocol = (url: string): string => {
|
|
257
|
+
const secure = /^(https|wss):/i.test(url);
|
|
258
|
+
return url
|
|
259
|
+
.replace(/^https?:\/\//i, secure ? "wss://" : "ws://")
|
|
260
|
+
.replace(/^wss?:\/\//i, secure ? "wss://" : "ws://")
|
|
261
|
+
.replace(/\/$/, "");
|
|
262
|
+
};
|
|
263
|
+
|
|
243
264
|
if (typeof window !== "undefined") {
|
|
244
|
-
let absoluteUrl
|
|
265
|
+
let absoluteUrl: string;
|
|
245
266
|
if (!baseUrl) {
|
|
246
267
|
absoluteUrl = window.location.origin;
|
|
247
268
|
} else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) {
|
|
248
269
|
absoluteUrl = baseUrl;
|
|
249
270
|
} else {
|
|
250
271
|
try {
|
|
251
|
-
|
|
272
|
+
const resolved = new URL(baseUrl, window.location.href);
|
|
273
|
+
absoluteUrl = resolved.origin + resolved.pathname;
|
|
252
274
|
} catch {
|
|
253
275
|
absoluteUrl = window.location.origin;
|
|
254
276
|
}
|
|
255
277
|
}
|
|
256
|
-
|
|
257
|
-
return absoluteUrl
|
|
258
|
-
.replace(/^https?:\/\//i, `${protocol}//`)
|
|
259
|
-
.replace(/^wss?:\/\//i, `${protocol}//`)
|
|
260
|
-
.replace(/\/$/, "");
|
|
278
|
+
return toWsProtocol(absoluteUrl);
|
|
261
279
|
}
|
|
262
280
|
|
|
263
281
|
if (!baseUrl) return "";
|
|
264
282
|
if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) {
|
|
265
283
|
return "";
|
|
266
284
|
}
|
|
267
|
-
return baseUrl
|
|
268
|
-
.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://")
|
|
269
|
-
.replace(/\/$/, "");
|
|
285
|
+
return toWsProtocol(baseUrl);
|
|
270
286
|
}
|
|
271
287
|
|
|
272
288
|
export function createRebaseClient<DB = Record<string, unknown>>(options: CreateRebaseClientOptions): CreateRebaseClientResult<DB> {
|
|
@@ -332,6 +348,24 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
332
348
|
? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
|
|
333
349
|
: undefined;
|
|
334
350
|
|
|
351
|
+
// Realtime is on unless it was switched off, so "on, but no URL could be
|
|
352
|
+
// derived" is a misconfiguration and not a choice. It used to be silent:
|
|
353
|
+
// the client simply had no socket, `observe()` quietly degraded to a
|
|
354
|
+
// one-shot fetch, and `realtime.channel()` blamed `realtime: false` — an
|
|
355
|
+
// option the caller had not passed.
|
|
356
|
+
const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
|
|
357
|
+
const unreachableReason =
|
|
358
|
+
"no WebSocket URL could be derived from baseUrl " +
|
|
359
|
+
`${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin ` +
|
|
360
|
+
"to resolve a relative URL against. Pass an absolute `baseUrl`, set `websocketUrl` " +
|
|
361
|
+
"explicitly, or pass `realtime: false` to say this was intended.";
|
|
362
|
+
if (realtimeUnreachable) {
|
|
363
|
+
console.warn(
|
|
364
|
+
`[Rebase] Realtime is enabled but ${unreachableReason} ` +
|
|
365
|
+
"Live queries will fall back to a single fetch and channels will throw."
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
335
369
|
let ws: RebaseWebSocketClient | undefined;
|
|
336
370
|
/** One channel object per name — see `realtime.channel`. */
|
|
337
371
|
const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
|
|
@@ -529,13 +563,15 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
529
563
|
* cut off the others.
|
|
530
564
|
*/
|
|
531
565
|
channel: (name: string, options?: ChannelOptions): RebaseRealtimeChannel => {
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
566
|
+
// Being merely *unconnected* is not an error: the socket opens
|
|
567
|
+
// on the first channel operation, which is the whole point of
|
|
568
|
+
// asking for a channel before you use one. Having no socket at
|
|
569
|
+
// all is, and there are two reasons for it — say which.
|
|
536
570
|
if (!ws) {
|
|
537
571
|
throw new RebaseClientError(
|
|
538
|
-
|
|
572
|
+
realtimeUnreachable
|
|
573
|
+
? `Realtime is enabled but ${unreachableReason}`
|
|
574
|
+
: "Realtime is disabled on this client (realtime: false), so channels are unavailable."
|
|
539
575
|
);
|
|
540
576
|
}
|
|
541
577
|
let existing = realtimeChannels.get(name);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { matchesOperator } from "./offline-query";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `like` pattern is user input, and it becomes a regular expression.
|
|
5
|
+
*
|
|
6
|
+
* `%` translates to `[\s\S]*`, so `%%%%X` becomes four adjacent unbounded
|
|
7
|
+
* quantifiers followed by a literal. On a subject that does not match, the
|
|
8
|
+
* engine has to try every way of splitting the subject between them — which is
|
|
9
|
+
* exponential. Twelve `%` against a forty-character value took **100 seconds**
|
|
10
|
+
* on this machine before answering `false`.
|
|
11
|
+
*
|
|
12
|
+
* The pattern arrives over HTTP: `?title=like.%25%25%25…` is a public filter
|
|
13
|
+
* operator, listed in the operator table. On the offline evaluator that freezes
|
|
14
|
+
* the tab; the same translation in the Mongo driver hands the expression to the
|
|
15
|
+
* database, where it occupies a server thread instead.
|
|
16
|
+
*
|
|
17
|
+
* Consecutive `%` mean exactly what one `%` means, so collapsing a run is
|
|
18
|
+
* semantics-preserving and removes the ambiguity the backtracking feeds on.
|
|
19
|
+
*/
|
|
20
|
+
const HOSTILE = "%".repeat(14) + "X";
|
|
21
|
+
const SUBJECT = "a".repeat(48);
|
|
22
|
+
|
|
23
|
+
describe("like patterns cannot be made to backtrack", () => {
|
|
24
|
+
it("answers a hostile pattern promptly", () => {
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
const result = matchesOperator(SUBJECT, "like", HOSTILE);
|
|
27
|
+
const elapsed = Date.now() - started;
|
|
28
|
+
|
|
29
|
+
expect(result).toBe(false);
|
|
30
|
+
expect(elapsed).toBeLessThan(1000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("answers the case-insensitive form promptly too", () => {
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
matchesOperator(SUBJECT, "ilike", HOSTILE);
|
|
36
|
+
|
|
37
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("still means what LIKE means", () => {
|
|
41
|
+
expect(matchesOperator("post-1", "like", "post-%")).toBe(true);
|
|
42
|
+
expect(matchesOperator("post-1", "like", "%1")).toBe(true);
|
|
43
|
+
expect(matchesOperator("post-1", "like", "%st-%")).toBe(true);
|
|
44
|
+
expect(matchesOperator("post-1", "like", "other-%")).toBe(false);
|
|
45
|
+
// A run of wildcards is the same query as one wildcard.
|
|
46
|
+
expect(matchesOperator("post-1", "like", "post%%%%1")).toBe(true);
|
|
47
|
+
expect(matchesOperator("abc", "like", "a_c")).toBe(true);
|
|
48
|
+
expect(matchesOperator("abbc", "like", "a_c")).toBe(false);
|
|
49
|
+
// `_` is fixed-width, so a run of them still counts.
|
|
50
|
+
expect(matchesOperator("abc", "like", "a__")).toBe(true);
|
|
51
|
+
expect(matchesOperator("ab", "like", "a__")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps an escaped percent literal", () => {
|
|
55
|
+
expect(matchesOperator("50%", "like", "50\\%")).toBe(true);
|
|
56
|
+
expect(matchesOperator("500", "like", "50\\%")).toBe(false);
|
|
57
|
+
// An escaped percent next to a wildcard is still a literal.
|
|
58
|
+
expect(matchesOperator("50%off", "like", "50\\%%")).toBe(true);
|
|
59
|
+
expect(matchesOperator("50off", "like", "50\\%%")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
runLocalQuery,
|
|
11
11
|
sortRows
|
|
12
12
|
} from "./offline-query";
|
|
13
|
-
import { EntityRelation } from "@rebasepro/types";
|
|
13
|
+
import { DEFAULT_LIST_LIMIT, EntityRelation } from "@rebasepro/types";
|
|
14
|
+
import { resolveFindWindow } from "@rebasepro/common";
|
|
14
15
|
import type { FindParams } from "./transport";
|
|
15
16
|
|
|
16
17
|
type Row = Record<string, unknown>;
|
|
@@ -172,12 +173,37 @@ describe("local query engine", () => {
|
|
|
172
173
|
|
|
173
174
|
describe("pagination", () => {
|
|
174
175
|
it("resolves page over offset the way the server does", () => {
|
|
175
|
-
expect(resolvePagination()).toEqual({ limit: 20, offset: 0 });
|
|
176
176
|
expect(resolvePagination({ limit: 10, offset: 30 })).toEqual({ limit: 10, offset: 30 });
|
|
177
177
|
expect(resolvePagination({ limit: 10, page: 3 })).toEqual({ limit: 10, offset: 20 });
|
|
178
178
|
// `page` wins over `offset`, and the first page is not negative.
|
|
179
179
|
expect(resolvePagination({ limit: 10, page: 1, offset: 99 })).toEqual({ limit: 10, offset: 0 });
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
it("defaults to the same page size the server would apply", () => {
|
|
183
|
+
// Asserted against the constant, not against a number written here.
|
|
184
|
+
// This test used to say `{ limit: 20 }` — restating a default this
|
|
185
|
+
// module had invented, while `/api/data` paged by 50. The local
|
|
186
|
+
// answer and the network answer to one `observe()` were therefore
|
|
187
|
+
// different lengths, and the test agreed with the wrong one.
|
|
188
|
+
expect(resolvePagination()).toEqual({ limit: DEFAULT_LIST_LIMIT, offset: 0 });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("agrees with the shared resolver on every shape of window", () => {
|
|
192
|
+
// The one that matters: the local evaluator and every other
|
|
193
|
+
// transport must resolve identical windows, or a cached list and a
|
|
194
|
+
// fetched list disagree about which rows page two holds.
|
|
195
|
+
for (const params of [
|
|
196
|
+
undefined,
|
|
197
|
+
{ limit: 10 },
|
|
198
|
+
{ offset: 30 },
|
|
199
|
+
{ page: 4 },
|
|
200
|
+
{ limit: 25, page: 2 },
|
|
201
|
+
{ limit: 10, page: 1, offset: 99 }
|
|
202
|
+
]) {
|
|
203
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
204
|
+
expect(resolvePagination(params)).toEqual({ limit, offset });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
181
207
|
});
|
|
182
208
|
|
|
183
209
|
describe("runLocalQuery", () => {
|