@rebasepro/client 0.13.0 → 0.13.1-canary.g18cfeb7
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 +263 -73
- 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/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 +184 -50
- 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 +35 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +114 -1
- package/src/offline.ts +121 -7
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +22 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +18 -9
package/src/collection.ts
CHANGED
|
@@ -11,10 +11,16 @@ import {
|
|
|
11
11
|
WhereValue,
|
|
12
12
|
WriteOptions
|
|
13
13
|
} from "@rebasepro/types";
|
|
14
|
-
import { collectAllPages, paginateFind } from "@rebasepro/common";
|
|
14
|
+
import { collectAllPages, paginateFind, resolveFindWindow } from "@rebasepro/common";
|
|
15
15
|
|
|
16
16
|
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Counts currently in flight, keyed by the exact request they issue. Entries
|
|
20
|
+
* live only for the duration of the request — see `count()` for why.
|
|
21
|
+
*/
|
|
22
|
+
const inflightCounts = new Map<string, Promise<number>>();
|
|
23
|
+
|
|
18
24
|
/**
|
|
19
25
|
* A live query result: a normal {@link FindResult} plus what an interface
|
|
20
26
|
* needs to decide whether to show a "saving…" or "offline" affordance over it.
|
|
@@ -155,7 +161,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
155
161
|
return raw as M;
|
|
156
162
|
},
|
|
157
163
|
|
|
158
|
-
async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
|
|
164
|
+
async createMany(data: Partial<M>[], options?: { upsert?: boolean } & WriteOptions) {
|
|
159
165
|
if (!Array.isArray(data)) {
|
|
160
166
|
throw new TypeError("createMany expects an array of records.");
|
|
161
167
|
}
|
|
@@ -166,11 +172,29 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
166
172
|
body: JSON.stringify({
|
|
167
173
|
rows: data,
|
|
168
174
|
...(options?.upsert ? { upsert: true } : {})
|
|
169
|
-
})
|
|
175
|
+
}),
|
|
176
|
+
...(options?.idempotencyKey
|
|
177
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
178
|
+
: {})
|
|
170
179
|
});
|
|
171
180
|
return (raw.data || []) as M[];
|
|
172
181
|
},
|
|
173
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Still `PUT`, deliberately, even though the server now serves `PATCH`
|
|
185
|
+
* on the same handler and `PATCH` is the honest verb for a merge.
|
|
186
|
+
*
|
|
187
|
+
* The two are interchangeable server-side, so switching buys nothing at
|
|
188
|
+
* runtime — and it costs compatibility in the direction that fails
|
|
189
|
+
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
190
|
+
* a route that does not exist and get a **404**, which is
|
|
191
|
+
* indistinguishable from "that row is gone". Every write would look like
|
|
192
|
+
* a missing record.
|
|
193
|
+
*
|
|
194
|
+
* `PATCH` is what the OpenAPI spec advertises, so anyone generating a
|
|
195
|
+
* client gets the correct verb; this stays on `PUT` until the oldest
|
|
196
|
+
* supported server is one that serves both.
|
|
197
|
+
*/
|
|
174
198
|
async update(id: string | number, data: Partial<M>) {
|
|
175
199
|
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
176
200
|
method: "PUT",
|
|
@@ -179,12 +203,54 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
179
203
|
return raw as M;
|
|
180
204
|
},
|
|
181
205
|
|
|
206
|
+
async updateMany(updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) {
|
|
207
|
+
if (!Array.isArray(updates)) {
|
|
208
|
+
throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
209
|
+
}
|
|
210
|
+
if (updates.length === 0) return [];
|
|
211
|
+
|
|
212
|
+
const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
|
|
213
|
+
method: "PATCH",
|
|
214
|
+
body: JSON.stringify({ updates }),
|
|
215
|
+
...(options?.idempotencyKey
|
|
216
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
217
|
+
: {})
|
|
218
|
+
});
|
|
219
|
+
return (raw.data || []) as M[];
|
|
220
|
+
},
|
|
221
|
+
|
|
182
222
|
async delete(id: string | number) {
|
|
183
223
|
await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
184
224
|
method: "DELETE"
|
|
185
225
|
});
|
|
186
226
|
},
|
|
187
227
|
|
|
228
|
+
/**
|
|
229
|
+
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
230
|
+
*
|
|
231
|
+
* The honest verb would take the ids in a DELETE body, and that is the
|
|
232
|
+
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
233
|
+
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
234
|
+
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
235
|
+
* operation, so a generated client would send the request without its
|
|
236
|
+
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
237
|
+
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
238
|
+
*/
|
|
239
|
+
async deleteMany(ids: (string | number)[], options?: WriteOptions) {
|
|
240
|
+
if (!Array.isArray(ids)) {
|
|
241
|
+
throw new TypeError("deleteMany expects an array of ids.");
|
|
242
|
+
}
|
|
243
|
+
if (ids.length === 0) return;
|
|
244
|
+
|
|
245
|
+
await transport.request<void>(`${basePath}/bulk/delete`, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
body: JSON.stringify({ ids }),
|
|
248
|
+
...(options?.idempotencyKey
|
|
249
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
250
|
+
: {})
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
|
|
188
254
|
async count(params?: FindParams<M>): Promise<number> {
|
|
189
255
|
const countParams: FindParams<M> = {
|
|
190
256
|
...params,
|
|
@@ -198,8 +264,34 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
198
264
|
include: undefined
|
|
199
265
|
};
|
|
200
266
|
const qs = buildQueryString(countParams);
|
|
201
|
-
|
|
202
|
-
|
|
267
|
+
|
|
268
|
+
// One count per query in flight, not one per caller.
|
|
269
|
+
//
|
|
270
|
+
// A count is a property of the query, and every concurrent caller
|
|
271
|
+
// asking the same question wants the same answer — so they can
|
|
272
|
+
// share the one request. This is not a micro-optimisation: a live
|
|
273
|
+
// subscription re-counts on every push (see `listen` below), and
|
|
274
|
+
// `listenCollection` deliberately collapses identical queries onto
|
|
275
|
+
// a single socket subscription while keeping one callback per
|
|
276
|
+
// subscriber. Each push therefore woke N subscribers, and each of
|
|
277
|
+
// them issued its own identical count. A table showing one relation
|
|
278
|
+
// column fired one count per visible cell, on every update.
|
|
279
|
+
//
|
|
280
|
+
// The entry is dropped as soon as it settles, so this merges
|
|
281
|
+
// concurrent calls only and never serves a cached total.
|
|
282
|
+
const key = basePath + "/count" + qs;
|
|
283
|
+
const inflight = inflightCounts.get(key);
|
|
284
|
+
if (inflight) return inflight;
|
|
285
|
+
|
|
286
|
+
const request = transport
|
|
287
|
+
.request<{ count: number }>(key, { method: "GET" })
|
|
288
|
+
.then((raw) => raw.count ?? 0);
|
|
289
|
+
inflightCounts.set(key, request);
|
|
290
|
+
try {
|
|
291
|
+
return await request;
|
|
292
|
+
} finally {
|
|
293
|
+
inflightCounts.delete(key);
|
|
294
|
+
}
|
|
203
295
|
},
|
|
204
296
|
|
|
205
297
|
// Reactive reads. Without the offline layer there is no local database
|
|
@@ -212,15 +304,36 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
212
304
|
options?: ObserveOptions
|
|
213
305
|
) {
|
|
214
306
|
let closed = false;
|
|
215
|
-
|
|
307
|
+
// Two sources race into one callback: the one-shot fetch below and
|
|
308
|
+
// the subscription beside it. Whichever resolves last used to win,
|
|
309
|
+
// so a socket update that landed first was overwritten by the
|
|
310
|
+
// fetch's older snapshot and stayed wrong until the next change.
|
|
311
|
+
// `listenCollection` replays cached rows synchronously to a second
|
|
312
|
+
// subscriber, so a second component observing the same query hit
|
|
313
|
+
// that ordering every time.
|
|
314
|
+
let liveDelivered = false;
|
|
315
|
+
let signature: string | undefined;
|
|
316
|
+
|
|
317
|
+
const deliver = (result: FindResult<M>, fromLive: boolean) => {
|
|
216
318
|
if (closed) return;
|
|
319
|
+
// Once the socket has spoken, the fetch issued alongside it is
|
|
320
|
+
// older news — delivering it would move the app backwards.
|
|
321
|
+
if (fromLive) liveDelivered = true;
|
|
322
|
+
else if (liveDelivered) return;
|
|
323
|
+
// The de-duplication `observe()` documents. Keyed on the rows
|
|
324
|
+
// and the total, the same two things the offline layer keys on,
|
|
325
|
+
// so both implementations mean the same thing by "changed".
|
|
326
|
+
const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
|
|
327
|
+
if (signature !== undefined && next === signature) return;
|
|
328
|
+
signature = next;
|
|
217
329
|
onResult({ ...result, fromCache: false, hasPendingWrites: false, partial: false });
|
|
218
330
|
};
|
|
219
|
-
|
|
331
|
+
|
|
332
|
+
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
220
333
|
if (!closed) onError?.(error as Error);
|
|
221
334
|
});
|
|
222
335
|
const live = options?.realtime !== false && client.listen
|
|
223
|
-
? client.listen(params,
|
|
336
|
+
? client.listen(params, (result) => deliver(result, true), onError)
|
|
224
337
|
: undefined;
|
|
225
338
|
return () => {
|
|
226
339
|
closed = true;
|
|
@@ -235,15 +348,25 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
235
348
|
options?: ObserveOptions
|
|
236
349
|
) {
|
|
237
350
|
let closed = false;
|
|
238
|
-
|
|
351
|
+
let liveDelivered = false;
|
|
352
|
+
let signature: string | undefined;
|
|
353
|
+
|
|
354
|
+
// Same ordering and de-duplication rules as `observe`, for one row.
|
|
355
|
+
const deliver = (row: M | undefined, fromLive: boolean) => {
|
|
239
356
|
if (closed) return;
|
|
357
|
+
if (fromLive) liveDelivered = true;
|
|
358
|
+
else if (liveDelivered) return;
|
|
359
|
+
const next = row === undefined ? "\u0000missing" : JSON.stringify(row);
|
|
360
|
+
if (signature !== undefined && next === signature) return;
|
|
361
|
+
signature = next;
|
|
240
362
|
onResult(row, { fromCache: false, hasPendingWrites: false });
|
|
241
363
|
};
|
|
242
|
-
|
|
364
|
+
|
|
365
|
+
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
243
366
|
if (!closed) onError?.(error as Error);
|
|
244
367
|
});
|
|
245
368
|
const live = options?.realtime !== false && client.listenById
|
|
246
|
-
? client.listenById(id,
|
|
369
|
+
? client.listenById(id, (row) => deliver(row, true), onError)
|
|
247
370
|
: undefined;
|
|
248
371
|
return () => {
|
|
249
372
|
closed = true;
|
|
@@ -280,69 +403,80 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
280
403
|
client.listen = (params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
|
|
281
404
|
let active = true;
|
|
282
405
|
let lastUpdateId = 0;
|
|
406
|
+
// The last total a `count()` actually returned. A later count that
|
|
407
|
+
// fails says nothing about how big the collection is, so it must
|
|
408
|
+
// not be allowed to replace this with the length of one page.
|
|
409
|
+
let lastKnownTotal: number | undefined;
|
|
410
|
+
const window = resolveFindWindow(params);
|
|
283
411
|
const unsub = ws.listenCollection(
|
|
284
412
|
{
|
|
285
413
|
path: slug,
|
|
286
414
|
filter: params?.where,
|
|
415
|
+
// The group used to be dropped here, so a subscription
|
|
416
|
+
// filtered with `or(...)` was pushed every row instead.
|
|
417
|
+
logical: params?.logical,
|
|
287
418
|
limit: params?.limit,
|
|
288
|
-
|
|
419
|
+
// `offset` used to be stringified into `startAfter`, which
|
|
420
|
+
// is a cursor *row*, not a row count — so the server was
|
|
421
|
+
// handed "20" where it expected a keyset value, and the
|
|
422
|
+
// offset it does understand never arrived at all.
|
|
423
|
+
offset: window.driverOffset,
|
|
289
424
|
orderBy: params?.orderBy?.[0],
|
|
290
425
|
order: params?.orderBy?.[1],
|
|
291
426
|
searchString: params?.searchString
|
|
292
427
|
},
|
|
293
428
|
(incomingRows: Record<string, unknown>[]) => {
|
|
294
429
|
const currentUpdateId = ++lastUpdateId;
|
|
295
|
-
|
|
296
|
-
|
|
430
|
+
// What the server pages by when the caller names no limit.
|
|
431
|
+
// A hardcoded 20 here described a window the rows had not
|
|
432
|
+
// come from, and any app sizing its next request off
|
|
433
|
+
// `meta.limit` was told the wrong number.
|
|
434
|
+
const requestedLimit = window.limit;
|
|
435
|
+
const offset = window.offset;
|
|
297
436
|
|
|
298
437
|
// WS client already delivers flat rows — just cast
|
|
299
438
|
const rows = incomingRows as M[];
|
|
300
439
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
440
|
+
const emit = (total: number, hasMore: boolean) => {
|
|
441
|
+
if (!active || currentUpdateId !== lastUpdateId) return;
|
|
442
|
+
onUpdate({
|
|
443
|
+
data: rows,
|
|
444
|
+
meta: {
|
|
445
|
+
total,
|
|
446
|
+
limit: requestedLimit,
|
|
447
|
+
offset,
|
|
448
|
+
hasMore
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// With no count to go on, the only defensible total is a
|
|
454
|
+
// lower bound: the rows on this page plus the ones paged
|
|
455
|
+
// past to reach them. Reporting `rows.length` claimed a
|
|
456
|
+
// collection read at offset 10 held two rows.
|
|
457
|
+
const emitWithoutCount = () => emit(
|
|
458
|
+
offset + rows.length,
|
|
459
|
+
rows.length >= requestedLimit
|
|
460
|
+
);
|
|
304
461
|
|
|
305
|
-
// Try to get authoritative count; fall back to heuristic
|
|
306
462
|
if (client.count) {
|
|
307
463
|
client.count(params)
|
|
308
464
|
.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
|
-
}
|
|
465
|
+
lastKnownTotal = total;
|
|
466
|
+
emit(total, offset + rows.length < total);
|
|
320
467
|
})
|
|
321
468
|
.catch(() => {
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
offset,
|
|
330
|
-
hasMore: heuristicHasMore
|
|
331
|
-
}
|
|
332
|
-
});
|
|
469
|
+
// A count that failed is not evidence about the
|
|
470
|
+
// size of the collection. Keep the last real
|
|
471
|
+
// answer; only guess if there has never been one.
|
|
472
|
+
if (lastKnownTotal !== undefined) {
|
|
473
|
+
emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
474
|
+
} else {
|
|
475
|
+
emitWithoutCount();
|
|
333
476
|
}
|
|
334
477
|
});
|
|
335
478
|
} else {
|
|
336
|
-
|
|
337
|
-
onUpdate({
|
|
338
|
-
data: rows,
|
|
339
|
-
meta: {
|
|
340
|
-
total: heuristicTotal,
|
|
341
|
-
limit: requestedLimit,
|
|
342
|
-
offset,
|
|
343
|
-
hasMore: heuristicHasMore
|
|
344
|
-
}
|
|
345
|
-
});
|
|
479
|
+
emitWithoutCount();
|
|
346
480
|
}
|
|
347
481
|
},
|
|
348
482
|
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", () => {
|
package/src/offline-query.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
toCanonicalOp
|
|
10
10
|
} from "@rebasepro/types";
|
|
11
11
|
import { FindParams } from "./transport";
|
|
12
|
+
import { resolveFindWindow } from "@rebasepro/common";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* A local evaluator for `FindParams`, so cached rows can answer a query the
|
|
@@ -38,8 +39,15 @@ const collator = typeof Intl !== "undefined" && typeof Intl.Collator === "functi
|
|
|
38
39
|
? new Intl.Collator(undefined, { numeric: false, sensitivity: "variant" })
|
|
39
40
|
: undefined;
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The server's page size when the caller does not ask for one.
|
|
44
|
+
*
|
|
45
|
+
* Re-exported rather than redeclared. This was its own `= 20` — a third
|
|
46
|
+
* constant of this name in the workspace, next to `@rebasepro/common`'s 200 and
|
|
47
|
+
* the 50 the REST layer actually applies — and a local copy of a number that
|
|
48
|
+
* belongs to another process is a number that goes stale silently.
|
|
49
|
+
*/
|
|
50
|
+
export { DEFAULT_LIST_LIMIT as DEFAULT_PAGE_SIZE } from "@rebasepro/types";
|
|
43
51
|
|
|
44
52
|
function isNullish(value: unknown): boolean {
|
|
45
53
|
return value === null || value === undefined;
|
|
@@ -137,17 +145,31 @@ export function looseEquals(a: unknown, b: unknown): boolean {
|
|
|
137
145
|
*/
|
|
138
146
|
function likeToRegExp(pattern: string, caseInsensitive: boolean): RegExp {
|
|
139
147
|
let source = "^";
|
|
148
|
+
// Runs of `%` collapse to one. `%%%%X` means exactly what `%X` means, but
|
|
149
|
+
// as a regular expression it is four adjacent unbounded quantifiers, and on
|
|
150
|
+
// a subject that does not match the engine tries every way of splitting the
|
|
151
|
+
// subject between them. Fourteen of them against a forty-eight character
|
|
152
|
+
// value took eighty-seven seconds to answer `false`.
|
|
153
|
+
//
|
|
154
|
+
// The pattern is user input — `?title=like.%25%25%25…` over HTTP — so that
|
|
155
|
+
// is a request that pins a CPU. Collapsing is semantics-preserving and
|
|
156
|
+
// removes the ambiguity the backtracking feeds on.
|
|
157
|
+
let lastWasWildcard = false;
|
|
140
158
|
for (let i = 0; i < pattern.length; i++) {
|
|
141
159
|
const char = pattern[i];
|
|
142
160
|
if (char === "\\" && i + 1 < pattern.length) {
|
|
143
161
|
source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
144
162
|
i++;
|
|
163
|
+
lastWasWildcard = false;
|
|
145
164
|
} else if (char === "%") {
|
|
146
|
-
source += "[\\s\\S]*";
|
|
165
|
+
if (!lastWasWildcard) source += "[\\s\\S]*";
|
|
166
|
+
lastWasWildcard = true;
|
|
147
167
|
} else if (char === "_") {
|
|
148
168
|
source += "[\\s\\S]";
|
|
169
|
+
lastWasWildcard = false;
|
|
149
170
|
} else {
|
|
150
171
|
source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
172
|
+
lastWasWildcard = false;
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
175
|
return new RegExp(source + "$", caseInsensitive ? "i" : "");
|
|
@@ -311,12 +333,17 @@ function tiebreak(a: Record<string, unknown>, b: Record<string, unknown>): numbe
|
|
|
311
333
|
return cmp ?? 0;
|
|
312
334
|
}
|
|
313
335
|
|
|
314
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* Resolve `page`/`offset`/`limit` the way the server does.
|
|
338
|
+
*
|
|
339
|
+
* It did not: this defaulted an absent limit to 20 while `/api/data` pages by
|
|
340
|
+
* 50, so the same `observe()` answered with 20 rows from the local database and
|
|
341
|
+
* 50 from the network — a list that changed length depending on which side
|
|
342
|
+
* answered, with `page` striding differently on each. Delegated now, so the
|
|
343
|
+
* sentence above is true by construction rather than by agreement.
|
|
344
|
+
*/
|
|
315
345
|
export function resolvePagination(params?: FindParams): { limit: number; offset: number } {
|
|
316
|
-
const limit = params
|
|
317
|
-
const offset = params?.page != null
|
|
318
|
-
? Math.max(0, (params.page - 1) * limit)
|
|
319
|
-
: (params?.offset ?? 0);
|
|
346
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
320
347
|
return { limit, offset };
|
|
321
348
|
}
|
|
322
349
|
|
package/src/offline-store.ts
CHANGED
|
@@ -52,9 +52,13 @@ export interface PendingMutation {
|
|
|
52
52
|
/** Unique, lexicographically sortable identity — also the queue key suffix. */
|
|
53
53
|
mutationId: string;
|
|
54
54
|
collection: string;
|
|
55
|
-
type: "create" | "createMany" | "update" | "delete";
|
|
55
|
+
type: "create" | "createMany" | "update" | "updateMany" | "delete" | "deleteMany";
|
|
56
56
|
/** Target row id for update/delete, and the (client-generated) id of an offline create. */
|
|
57
57
|
id?: string | number;
|
|
58
|
+
/** Target row ids for `deleteMany`. */
|
|
59
|
+
ids?: (string | number)[];
|
|
60
|
+
/** `{ id, data }` entries for `updateMany`. */
|
|
61
|
+
updates?: { id: string | number; data: Record<string, unknown> }[];
|
|
58
62
|
/**
|
|
59
63
|
* True when the SDK minted this create's id itself. Only such creates may
|
|
60
64
|
* cancel out against a later offline delete: a freshly generated UUID
|