@rebasepro/client 0.13.0 → 0.13.1-canary.g06dbe5b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, Vector, isPublicStoragePath, toCanonicalOp } from "@rebasepro/types";
2
- import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, or, paginateFind, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
2
+ import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, or, paginateFind, resolveFindWindow, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
3
  import { toSnakeCase } from "@rebasepro/utils";
4
4
  //#region src/reviver.ts
5
5
  function rebaseReviver(_key, value) {
@@ -91,7 +91,17 @@ function buildQueryString(params) {
91
91
  const wire = serializeOrderBy(params.orderBy);
92
92
  if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
93
93
  }
94
- if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
94
+ if (params.searchString) {
95
+ parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
96
+ if (params.searchExplain) parts.push("searchExplain=true");
97
+ }
98
+ if (params.vectorSearch) {
99
+ const vs = params.vectorSearch;
100
+ parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
101
+ parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
102
+ if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
103
+ if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
104
+ }
95
105
  if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
96
106
  if (params.logical) {
97
107
  const root = params.logical;
@@ -131,6 +141,13 @@ function resolveBaseUrl(configured) {
131
141
  function createTransport(config, environment) {
132
142
  const fetchFn = config.fetch || globalThis.fetch;
133
143
  const apiPath = config.apiPath || "/api";
144
+ for (const field of ["baseUrl", "storageUrlOrigin"]) {
145
+ const value = config[field];
146
+ if (!value || !apiPath) continue;
147
+ const trimmed = value.replace(/\/+$/, "");
148
+ if (!trimmed.endsWith(apiPath)) continue;
149
+ console.warn(`[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ${JSON.stringify(apiPath)}, which is appended to it — requests will go to ${trimmed}${apiPath}/… and 404. Pass the origin only (${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set \`apiPath\` if the server really does mount the API one level deeper.`);
150
+ }
134
151
  let token = config.token;
135
152
  let tokenGetter;
136
153
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -316,6 +333,11 @@ function createAuth(transport, options) {
316
333
  const authFlowMode = opts.authFlowMode || "json";
317
334
  const STORAGE_KEY = "rebase_auth";
318
335
  const REFRESH_BUFFER_MS = 12e4;
336
+ /**
337
+ * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
338
+ * Anything larger is silently clamped to 1ms by Node and every browser.
339
+ */
340
+ const MAX_TIMER_DELAY_MS = 2147483647;
319
341
  const MAX_REFRESH_RETRIES = 5;
320
342
  const REFRESH_RETRY_BASE_MS = 1e3;
321
343
  const REFRESH_RETRY_MAX_MS = 3e4;
@@ -343,7 +365,9 @@ function createAuth(transport, options) {
343
365
  function emit(event, session) {
344
366
  for (const fn of listeners) try {
345
367
  fn(event, session);
346
- } catch (e) {}
368
+ } catch (e) {
369
+ console.error("Error in auth state change listener:", e);
370
+ }
347
371
  }
348
372
  function saveSession(session) {
349
373
  if (!persistSession || authFlowMode === "cookie") return;
@@ -447,6 +471,10 @@ function createAuth(transport, options) {
447
471
  attemptScheduledRefresh(0);
448
472
  return;
449
473
  }
474
+ if (delay > MAX_TIMER_DELAY_MS) {
475
+ refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
476
+ return;
477
+ }
450
478
  refreshTimeout = setTimeout(() => {
451
479
  attemptScheduledRefresh(0);
452
480
  }, delay);
@@ -1230,9 +1258,55 @@ var SDKQueryBuilder = class {
1230
1258
  }
1231
1259
  /**
1232
1260
  * Set a free-text search string if supported by the backend.
1261
+ *
1262
+ * By default this is a substring match across the collection's top-level
1263
+ * string properties. A Postgres collection that declares a `search` block
1264
+ * gets ranked full-text matching over the fields it named instead, and each
1265
+ * row comes back with a `_score` you can sort on:
1266
+ *
1267
+ * ```ts
1268
+ * client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
1269
+ * ```
1270
+ *
1271
+ * Pass `{ explain: true }` to have each row report which of the declared
1272
+ * fields matched, with a highlighted snippet, on `_matches`:
1273
+ *
1274
+ * ```ts
1275
+ * const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
1276
+ * data[0]._matches
1277
+ * // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
1278
+ * ```
1233
1279
  */
1234
- search(searchString) {
1280
+ search(searchString, options) {
1235
1281
  this.params.searchString = searchString;
1282
+ if (options?.explain !== void 0) this.params.searchExplain = options.explain;
1283
+ return this;
1284
+ }
1285
+ /**
1286
+ * Order rows by nearest-neighbour distance to `vector`.
1287
+ *
1288
+ * The server has supported this from the REST layer since vectors landed;
1289
+ * this is the SDK reaching it. Results come back closest-first with a
1290
+ * `_distance` on each row, and any `where` / `orderBy` on the same query is
1291
+ * a filter applied before the ordering — distance decides the order.
1292
+ *
1293
+ * You supply the query vector. Rebase stores and searches embeddings; it
1294
+ * does not produce them, so this is where whatever model you already use
1295
+ * for the stored vectors gets called.
1296
+ *
1297
+ * @param property - Name of the `vector` property to compare against.
1298
+ * @param vector - The query embedding. Its length must match the property's
1299
+ * declared `dimensions`, or the server answers 400.
1300
+ * @example
1301
+ * client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
1302
+ */
1303
+ vectorSearch(property, vector, options) {
1304
+ this.params.vectorSearch = {
1305
+ property,
1306
+ vector,
1307
+ ...options?.distance !== void 0 && { distance: options.distance },
1308
+ ...options?.threshold !== void 0 && { threshold: options.threshold }
1309
+ };
1236
1310
  return this;
1237
1311
  }
1238
1312
  /**
@@ -1270,6 +1344,11 @@ var SDKQueryBuilder = class {
1270
1344
  };
1271
1345
  //#endregion
1272
1346
  //#region src/collection.ts
1347
+ /**
1348
+ * Counts currently in flight, keyed by the exact request they issue. Entries
1349
+ * live only for the duration of the request — see `count()` for why.
1350
+ */
1351
+ var inflightCounts = /* @__PURE__ */ new Map();
1273
1352
  function createCollectionClient(transport, slug, ws) {
1274
1353
  const basePath = `/data/${slug}`;
1275
1354
  const client = {
@@ -1314,18 +1393,63 @@ function createCollectionClient(transport, slug, ws) {
1314
1393
  body: JSON.stringify({
1315
1394
  rows: data,
1316
1395
  ...options?.upsert ? { upsert: true } : {}
1317
- })
1396
+ }),
1397
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1318
1398
  })).data || [];
1319
1399
  },
1400
+ /**
1401
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
1402
+ * on the same handler and `PATCH` is the honest verb for a merge.
1403
+ *
1404
+ * The two are interchangeable server-side, so switching buys nothing at
1405
+ * runtime — and it costs compatibility in the direction that fails
1406
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
1407
+ * a route that does not exist and get a **404**, which is
1408
+ * indistinguishable from "that row is gone". Every write would look like
1409
+ * a missing record.
1410
+ *
1411
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
1412
+ * client gets the correct verb; this stays on `PUT` until the oldest
1413
+ * supported server is one that serves both.
1414
+ */
1320
1415
  async update(id, data) {
1321
1416
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1322
1417
  method: "PUT",
1323
1418
  body: JSON.stringify(data)
1324
1419
  });
1325
1420
  },
1421
+ async updateMany(updates, options) {
1422
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
1423
+ if (updates.length === 0) return [];
1424
+ return (await transport.request(`${basePath}/bulk`, {
1425
+ method: "PATCH",
1426
+ body: JSON.stringify({ updates }),
1427
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1428
+ })).data || [];
1429
+ },
1326
1430
  async delete(id) {
1327
1431
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
1328
1432
  },
1433
+ /**
1434
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
1435
+ *
1436
+ * The honest verb would take the ids in a DELETE body, and that is the
1437
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
1438
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
1439
+ * several OpenAPI generators ignore `requestBody` on a DELETE
1440
+ * operation, so a generated client would send the request without its
1441
+ * ids. A backend deployed behind arbitrary ingress cannot take that
1442
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
1443
+ */
1444
+ async deleteMany(ids, options) {
1445
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
1446
+ if (ids.length === 0) return;
1447
+ await transport.request(`${basePath}/bulk/delete`, {
1448
+ method: "POST",
1449
+ body: JSON.stringify({ ids }),
1450
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1451
+ });
1452
+ },
1329
1453
  async count(params) {
1330
1454
  const qs = buildQueryString({
1331
1455
  ...params,
@@ -1333,12 +1457,28 @@ function createCollectionClient(transport, slug, ws) {
1333
1457
  offset: void 0,
1334
1458
  include: void 0
1335
1459
  });
1336
- return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
1460
+ const key = basePath + "/count" + qs;
1461
+ const inflight = inflightCounts.get(key);
1462
+ if (inflight) return inflight;
1463
+ const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
1464
+ inflightCounts.set(key, request);
1465
+ try {
1466
+ return await request;
1467
+ } finally {
1468
+ inflightCounts.delete(key);
1469
+ }
1337
1470
  },
1338
1471
  observe(params, onResult, onError, options) {
1339
1472
  let closed = false;
1340
- const emit = (result) => {
1473
+ let liveDelivered = false;
1474
+ let signature;
1475
+ const deliver = (result, fromLive) => {
1341
1476
  if (closed) return;
1477
+ if (fromLive) liveDelivered = true;
1478
+ else if (liveDelivered) return;
1479
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
1480
+ if (signature !== void 0 && next === signature) return;
1481
+ signature = next;
1342
1482
  onResult({
1343
1483
  ...result,
1344
1484
  fromCache: false,
@@ -1346,10 +1486,10 @@ function createCollectionClient(transport, slug, ws) {
1346
1486
  partial: false
1347
1487
  });
1348
1488
  };
1349
- client.find(params).then(emit).catch((error) => {
1489
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
1350
1490
  if (!closed) onError?.(error);
1351
1491
  });
1352
- const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
1492
+ const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
1353
1493
  return () => {
1354
1494
  closed = true;
1355
1495
  live?.();
@@ -1357,17 +1497,24 @@ function createCollectionClient(transport, slug, ws) {
1357
1497
  },
1358
1498
  observeById(id, onResult, onError, options) {
1359
1499
  let closed = false;
1360
- const emit = (row) => {
1500
+ let liveDelivered = false;
1501
+ let signature;
1502
+ const deliver = (row, fromLive) => {
1361
1503
  if (closed) return;
1504
+ if (fromLive) liveDelivered = true;
1505
+ else if (liveDelivered) return;
1506
+ const next = row === void 0 ? "\0missing" : JSON.stringify(row);
1507
+ if (signature !== void 0 && next === signature) return;
1508
+ signature = next;
1362
1509
  onResult(row, {
1363
1510
  fromCache: false,
1364
1511
  hasPendingWrites: false
1365
1512
  });
1366
1513
  };
1367
- client.findById(id).then(emit).catch((error) => {
1514
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
1368
1515
  if (!closed) onError?.(error);
1369
1516
  });
1370
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
1517
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
1371
1518
  return () => {
1372
1519
  closed = true;
1373
1520
  live?.();
@@ -1387,8 +1534,11 @@ function createCollectionClient(transport, slug, ws) {
1387
1534
  offset(count) {
1388
1535
  return new SDKQueryBuilder(client).offset(count);
1389
1536
  },
1390
- search(searchString) {
1391
- return new SDKQueryBuilder(client).search(searchString);
1537
+ search(searchString, options) {
1538
+ return new SDKQueryBuilder(client).search(searchString, options);
1539
+ },
1540
+ vectorSearch(property, vector, options) {
1541
+ return new SDKQueryBuilder(client).vectorSearch(property, vector, options);
1392
1542
  },
1393
1543
  include(...relations) {
1394
1544
  return new SDKQueryBuilder(client).include(...relations);
@@ -1398,51 +1548,44 @@ function createCollectionClient(transport, slug, ws) {
1398
1548
  client.listen = (params, onUpdate, onError) => {
1399
1549
  let active = true;
1400
1550
  let lastUpdateId = 0;
1551
+ let lastKnownTotal;
1552
+ const window = resolveFindWindow(params);
1401
1553
  const unsub = ws.listenCollection({
1402
1554
  path: slug,
1403
1555
  filter: params?.where,
1556
+ logical: params?.logical,
1404
1557
  limit: params?.limit,
1405
- startAfter: params?.offset ? String(params.offset) : void 0,
1558
+ offset: window.driverOffset,
1406
1559
  orderBy: params?.orderBy?.[0],
1407
1560
  order: params?.orderBy?.[1],
1408
- searchString: params?.searchString
1561
+ searchString: params?.searchString,
1562
+ searchExplain: params?.searchExplain
1409
1563
  }, (incomingRows) => {
1410
1564
  const currentUpdateId = ++lastUpdateId;
1411
- const requestedLimit = params?.limit || 20;
1412
- const offset = params?.offset || 0;
1565
+ const requestedLimit = window.limit;
1566
+ const offset = window.offset;
1413
1567
  const rows = incomingRows;
1414
- const heuristicTotal = rows.length;
1415
- const heuristicHasMore = rows.length >= requestedLimit;
1416
- if (client.count) client.count(params).then((total) => {
1417
- if (active && currentUpdateId === lastUpdateId) onUpdate({
1568
+ const emit = (total, hasMore) => {
1569
+ if (!active || currentUpdateId !== lastUpdateId) return;
1570
+ onUpdate({
1418
1571
  data: rows,
1419
1572
  meta: {
1420
1573
  total,
1421
1574
  limit: requestedLimit,
1422
1575
  offset,
1423
- hasMore: offset + rows.length < total
1576
+ hasMore
1424
1577
  }
1425
1578
  });
1579
+ };
1580
+ const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
1581
+ if (client.count) client.count(params).then((total) => {
1582
+ lastKnownTotal = total;
1583
+ emit(total, offset + rows.length < total);
1426
1584
  }).catch(() => {
1427
- if (active && currentUpdateId === lastUpdateId) onUpdate({
1428
- data: rows,
1429
- meta: {
1430
- total: heuristicTotal,
1431
- limit: requestedLimit,
1432
- offset,
1433
- hasMore: heuristicHasMore
1434
- }
1435
- });
1436
- });
1437
- else onUpdate({
1438
- data: rows,
1439
- meta: {
1440
- total: heuristicTotal,
1441
- limit: requestedLimit,
1442
- offset,
1443
- hasMore: heuristicHasMore
1444
- }
1585
+ if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
1586
+ else emitWithoutCount();
1445
1587
  });
1588
+ else emitWithoutCount();
1446
1589
  }, onError);
1447
1590
  return () => {
1448
1591
  active = false;
@@ -2218,6 +2361,11 @@ var RebaseWebSocketClient = class {
2218
2361
  callback.onError(new RebaseApiError$1(errorMessage, { code: errorCode }));
2219
2362
  }
2220
2363
  } else callback.onUpdate(message);
2364
+ return;
2365
+ }
2366
+ if (type === "ERROR" || type === "error" || message.error) {
2367
+ const { errorMessage, errorCode } = extractMessageError(message);
2368
+ console.warn(`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`);
2221
2369
  }
2222
2370
  }
2223
2371
  async ensureAuthenticated(retryCount = 3) {
@@ -2833,15 +2981,10 @@ var RebaseWebSocketClient = class {
2833
2981
  }
2834
2982
  }
2835
2983
  createCollectionSubscriptionKey(props) {
2984
+ const { collection, ...query } = props;
2836
2985
  const key = {
2837
- path: props.path,
2838
- filter: props.filter,
2839
- limit: props.limit,
2840
- startAfter: props.startAfter,
2841
- orderBy: props.orderBy,
2842
- order: props.order,
2843
- searchString: props.searchString,
2844
- collection: props.collection?.name
2986
+ ...query,
2987
+ collection: collection?.name
2845
2988
  };
2846
2989
  return JSON.stringify(key, (_, value) => {
2847
2990
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
@@ -3335,11 +3478,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
3335
3478
  503,
3336
3479
  504
3337
3480
  ]);
3481
+ /**
3482
+ * The server holds this key for a request it has not answered yet.
3483
+ *
3484
+ * It is a 409 like a duplicate row is a 409, and nothing but the code separates
3485
+ * them — one means "your write is already there", the other means "your write
3486
+ * may not have happened at all, ask again".
3487
+ */
3488
+ var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
3489
+ /**
3490
+ * Is the server still answering an earlier attempt of this same write?
3491
+ *
3492
+ * The only correct response is to ask again — which is exactly what the
3493
+ * server's own message says, and exactly what this SDK used not to do.
3494
+ */
3495
+ function isIdempotencyInProgressError(error) {
3496
+ return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
3497
+ }
3338
3498
  /** Is this failure worth another attempt later? */
3339
3499
  function isRetryableError(error) {
3340
3500
  if (isNetworkError(error)) return true;
3341
- if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
3342
- return false;
3501
+ if (!(error instanceof RebaseApiError)) return false;
3502
+ if (isIdempotencyInProgressError(error)) return true;
3503
+ return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
3343
3504
  }
3344
3505
  /**
3345
3506
  * Did this write fail because the row is already there?
@@ -3351,10 +3512,16 @@ function isRetryableError(error) {
3351
3512
  * The queue uses this to recognise its own earlier attempt. A create whose
3352
3513
  * response was lost is replayed, and for a row carrying an id the SDK generated
3353
3514
  * the server can only be rejecting it because the first attempt actually landed.
3515
+ *
3516
+ * Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
3517
+ * is a 409 that means the opposite — the row may not exist at all. Read as a
3518
+ * duplicate, the queue looked for a row that was never written, found nothing,
3519
+ * concluded there was nothing left to do and deleted the write from the queue.
3354
3520
  */
3355
3521
  function isDuplicateKeyError(error) {
3356
3522
  if (!(error instanceof RebaseApiError)) return false;
3357
- return error.code === "23505" || error.status === 409;
3523
+ if (error.code === "23505") return true;
3524
+ return error.status === 409 && !isIdempotencyInProgressError(error);
3358
3525
  }
3359
3526
  var ConnectivityMonitor = class {
3360
3527
  state = "online";
@@ -3764,14 +3931,23 @@ function looseEquals(a, b) {
3764
3931
  */
3765
3932
  function likeToRegExp(pattern, caseInsensitive) {
3766
3933
  let source = "^";
3934
+ let lastWasWildcard = false;
3767
3935
  for (let i = 0; i < pattern.length; i++) {
3768
3936
  const char = pattern[i];
3769
3937
  if (char === "\\" && i + 1 < pattern.length) {
3770
3938
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3771
3939
  i++;
3772
- } else if (char === "%") source += "[\\s\\S]*";
3773
- else if (char === "_") source += "[\\s\\S]";
3774
- else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3940
+ lastWasWildcard = false;
3941
+ } else if (char === "%") {
3942
+ if (!lastWasWildcard) source += "[\\s\\S]*";
3943
+ lastWasWildcard = true;
3944
+ } else if (char === "_") {
3945
+ source += "[\\s\\S]";
3946
+ lastWasWildcard = false;
3947
+ } else {
3948
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3949
+ lastWasWildcard = false;
3950
+ }
3775
3951
  }
3776
3952
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
3777
3953
  }
@@ -3903,14 +4079,41 @@ function sortRows(rows, orderBy) {
3903
4079
  function tiebreak(a, b) {
3904
4080
  return compareValues(a.id, b.id) ?? 0;
3905
4081
  }
3906
- /** Resolve `page`/`offset`/`limit` the way the server does. */
4082
+ /**
4083
+ * Resolve `page`/`offset`/`limit` the way the server does.
4084
+ *
4085
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
4086
+ * 50, so the same `observe()` answered with 20 rows from the local database and
4087
+ * 50 from the network — a list that changed length depending on which side
4088
+ * answered, with `page` striding differently on each. Delegated now, so the
4089
+ * sentence above is true by construction rather than by agreement.
4090
+ */
3907
4091
  function resolvePagination(params) {
3908
- const limit = params?.limit ?? 20;
4092
+ const { limit, offset } = resolveFindWindow(params);
3909
4093
  return {
3910
4094
  limit,
3911
- offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
4095
+ offset
3912
4096
  };
3913
4097
  }
4098
+ /** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
4099
+ var ORDERING_OPS = /* @__PURE__ */ new Set([
4100
+ "<",
4101
+ "<=",
4102
+ ">",
4103
+ ">="
4104
+ ]);
4105
+ /** Does any condition in this `where` clause order its operands? */
4106
+ function whereOrders(where) {
4107
+ if (!where) return false;
4108
+ for (const condition of Object.values(where)) if ((isTuple(condition) ? [condition] : condition.filter(isTuple)).some(([op]) => ORDERING_OPS.has(op))) return true;
4109
+ return false;
4110
+ }
4111
+ /** The same question, through an `and(...)`/`or(...)` tree. */
4112
+ function logicalOrders(condition, depth = 0) {
4113
+ if (!condition || depth > 32) return false;
4114
+ if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
4115
+ return ORDERING_OPS.has(condition.operator);
4116
+ }
3914
4117
  /**
3915
4118
  * Can a locally evaluated answer to `params` be trusted to match the server's,
3916
4119
  * assuming the cache holds every row of the collection?
@@ -3918,11 +4121,67 @@ function resolvePagination(params) {
3918
4121
  * `include` pulls in rows from other collections that this evaluator never
3919
4122
  * sees, and `searchString` is only approximated — both make the local answer a
3920
4123
  * best effort rather than an equivalent one.
4124
+ *
4125
+ * **Ordering comparisons are refused, and that is the interesting one.**
4126
+ * `compareValues` falls back to an `Intl.Collator` for operands it cannot read
4127
+ * as numbers or instants. PostgreSQL orders text by the *database's* collation,
4128
+ * which is a property of the server this process has never been told: under the
4129
+ * C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
4130
+ * and the collator says true. So `["<", "Banana"]` selects a different set here
4131
+ * than it does there — silently, and in whichever direction the deployment
4132
+ * happens to have been created.
4133
+ *
4134
+ * The refusal covers *every* ordering comparison rather than only the ones with
4135
+ * a string operand, because the operand type does not settle it: a numeric
4136
+ * bound against a text column (`["<", 10]` on a `varchar`) also reaches the
4137
+ * collator, and nothing in `params` says what the column holds. Conservative on
4138
+ * purpose — the cost is that a query combining an ordering filter with
4139
+ * *unsynced local writes* stops placing those writes optimistically, which is a
4140
+ * degraded answer rather than a wrong one. Claiming exactness we do not have is
4141
+ * the other way round.
4142
+ *
4143
+ * This says nothing about ordering *results*; that is a separate claim with a
4144
+ * separate answer, because a sort changes which rows come first and not which
4145
+ * rows match. See {@link isLocallySortable}.
3921
4146
  */
3922
4147
  function isExactlyEvaluable(params) {
3923
4148
  if (!params) return true;
3924
4149
  if (params.include && params.include.length > 0) return false;
3925
4150
  if (params.searchString) return false;
4151
+ if (params.vectorSearch) return false;
4152
+ if (whereOrders(params.where)) return false;
4153
+ if (logicalOrders(params.logical)) return false;
4154
+ return true;
4155
+ }
4156
+ /**
4157
+ * Would sorting `rows` locally reproduce the order the server would have sent?
4158
+ *
4159
+ * Asked of the rows rather than of the query, because unlike a filter this one
4160
+ * *is* decidable from the data in hand: {@link compareValues} reaches the
4161
+ * collator only when it cannot read both operands as numbers, and `toComparable`
4162
+ * has already turned dates and relations into numbers and ids by then. If every
4163
+ * value on the sort column normalises to a number, the collator is unreachable
4164
+ * and the local order is the server's order.
4165
+ *
4166
+ * A text column is therefore refused — see {@link isExactlyEvaluable} for why
4167
+ * the two cannot be made to agree — and so is a column this page happens to see
4168
+ * only as strings, which is the same thing from here.
4169
+ *
4170
+ * Nulls are fine either way: they are ordered by an explicit rule (last
4171
+ * ascending, first descending) that matches Postgres and never reaches the
4172
+ * comparator.
4173
+ */
4174
+ function isLocallySortable(rows, orderBy) {
4175
+ if (!orderBy) return true;
4176
+ const [field] = orderBy;
4177
+ for (const row of rows) {
4178
+ const value = toComparable(row[field]);
4179
+ if (isNullish(value)) continue;
4180
+ if (typeof value === "number" || typeof value === "boolean") continue;
4181
+ if (typeof value === "bigint") continue;
4182
+ if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
4183
+ return false;
4184
+ }
3926
4185
  return true;
3927
4186
  }
3928
4187
  /** Run a full query — filter, sort, paginate — over a set of rows. */
@@ -3958,6 +4217,17 @@ function generateOfflineId() {
3958
4217
  return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
3959
4218
  }
3960
4219
  var MISSING = "\0missing";
4220
+ /**
4221
+ * Replays to spend on a mutation whose idempotency key the server is still
4222
+ * holding, when the app has not asked for more.
4223
+ *
4224
+ * Retries double from a second and cap at the sync interval, so the default
4225
+ * budget of five covers about half a minute — less than the lease a server
4226
+ * gives a claim nobody came back for. This many outlast it with room for a slow
4227
+ * batch, and the count is what stops a server that never releases the key from
4228
+ * blocking the queue behind it indefinitely.
4229
+ */
4230
+ var IN_PROGRESS_MIN_RETRIES = 12;
3961
4231
  var OfflineManager = class {
3962
4232
  store;
3963
4233
  maxCachedQueries;
@@ -4238,6 +4508,72 @@ var OfflineManager = class {
4238
4508
  this.notifyCollection(slug);
4239
4509
  return rows;
4240
4510
  },
4511
+ updateMany: async (updates, options) => {
4512
+ await this.ensureCollection(slug);
4513
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4514
+ if (updates.length === 0) return [];
4515
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
4516
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
4517
+ const rows = await inner.updateMany(updates, options);
4518
+ this.connectivity.markSuccess();
4519
+ await this.ingest(slug, rows);
4520
+ this.notifyCollection(slug);
4521
+ return rows;
4522
+ } catch (error) {
4523
+ if (!isNetworkError(error)) throw error;
4524
+ this.connectivity.markFailure();
4525
+ }
4526
+ const rollback = {};
4527
+ const optimistic = [];
4528
+ for (const { id, data } of updates) {
4529
+ const base = this.rawLocalRow(slug, id);
4530
+ rollback[String(id)] = base ?? null;
4531
+ optimistic.push({
4532
+ ...base ?? {},
4533
+ ...data,
4534
+ id
4535
+ });
4536
+ }
4537
+ await this.enqueue({
4538
+ collection: slug,
4539
+ type: "updateMany",
4540
+ updates: updates.map((u) => ({
4541
+ id: u.id,
4542
+ data: u.data
4543
+ })),
4544
+ rollback: { rows: rollback }
4545
+ });
4546
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row);
4547
+ this.notifyCollection(slug);
4548
+ return optimistic;
4549
+ },
4550
+ deleteMany: async (ids, options) => {
4551
+ await this.ensureCollection(slug);
4552
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4553
+ if (ids.length === 0) return;
4554
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
4555
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
4556
+ await inner.deleteMany(ids, options);
4557
+ this.connectivity.markSuccess();
4558
+ for (const id of ids) this.removeLocalRow(slug, id, true);
4559
+ this.notifyCollection(slug);
4560
+ this.scheduleRefresh(slug);
4561
+ return;
4562
+ } catch (error) {
4563
+ if (!isNetworkError(error)) throw error;
4564
+ this.connectivity.markFailure();
4565
+ }
4566
+ const rollback = {};
4567
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
4568
+ await this.enqueue({
4569
+ collection: slug,
4570
+ type: "deleteMany",
4571
+ ids,
4572
+ rollback: { rows: rollback }
4573
+ });
4574
+ for (const id of ids) this.removeLocalRow(slug, id, false);
4575
+ this.notifyCollection(slug);
4576
+ },
4241
4577
  update: async (id, data) => {
4242
4578
  await this.ensureCollection(slug);
4243
4579
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -4316,7 +4652,8 @@ var OfflineManager = class {
4316
4652
  orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
4317
4653
  limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
4318
4654
  offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
4319
- search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
4655
+ search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
4656
+ vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
4320
4657
  include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
4321
4658
  };
4322
4659
  if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
@@ -4536,9 +4873,8 @@ var OfflineManager = class {
4536
4873
  if (!state) return {
4537
4874
  data: [],
4538
4875
  meta: {
4876
+ ...resolvePagination(params),
4539
4877
  total: 0,
4540
- limit: params?.limit ?? 20,
4541
- offset: params?.offset ?? 0,
4542
4878
  hasMore: false
4543
4879
  },
4544
4880
  fromCache: true,
@@ -4574,16 +4910,15 @@ var OfflineManager = class {
4574
4910
  }
4575
4911
  let added = 0;
4576
4912
  const offset = snapshot.offset ?? 0;
4577
- if (exact && offset === 0) {
4578
- for (const [key, entry] of state.rows) {
4579
- if (seen.has(key) || !this.hasPending(slug, key)) continue;
4580
- if (!this.isLocallyCreated(slug, key)) continue;
4581
- if (!matchesParams(entry.row, params)) continue;
4582
- rows.push(entry.row);
4583
- added++;
4584
- }
4585
- if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
4913
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
4914
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
4915
+ if (!this.isLocallyCreated(slug, key)) continue;
4916
+ if (!matchesParams(entry.row, params)) continue;
4917
+ rows.push(entry.row);
4918
+ added++;
4586
4919
  }
4920
+ const orderIsLocal = isLocallySortable(rows, params?.orderBy);
4921
+ if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
4587
4922
  return {
4588
4923
  data: rows,
4589
4924
  meta: {
@@ -4594,7 +4929,7 @@ var OfflineManager = class {
4594
4929
  },
4595
4930
  fromCache,
4596
4931
  hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
4597
- partial: !exact
4932
+ partial: !exact || !orderIsLocal
4598
4933
  };
4599
4934
  }
4600
4935
  localFind(slug, params) {
@@ -4730,17 +5065,17 @@ var OfflineManager = class {
4730
5065
  return row;
4731
5066
  }
4732
5067
  recordSnapshot(slug, params, result) {
5068
+ const window = resolvePagination(params);
4733
5069
  const meta = result.meta ?? {
4734
5070
  total: result.data?.length ?? 0,
4735
- limit: 20,
4736
- offset: 0,
5071
+ ...window,
4737
5072
  hasMore: false
4738
5073
  };
4739
5074
  const snapshot = {
4740
5075
  ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
4741
5076
  total: meta.total ?? result.data?.length ?? 0,
4742
- limit: meta.limit ?? params?.limit ?? 20,
4743
- offset: meta.offset ?? params?.offset ?? 0,
5077
+ limit: meta.limit ?? window.limit,
5078
+ offset: meta.offset ?? window.offset,
4744
5079
  hasMore: meta.hasMore ?? false
4745
5080
  };
4746
5081
  const state = this.collectionState(slug);
@@ -4909,7 +5244,8 @@ var OfflineManager = class {
4909
5244
  }
4910
5245
  op.attempts = (op.attempts ?? 0) + 1;
4911
5246
  op.lastError = error?.message ?? String(error);
4912
- if (isRetryableError(error) && op.attempts < this.maxRetries) {
5247
+ const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
5248
+ if (isRetryableError(error) && op.attempts < limit) {
4913
5249
  await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
4914
5250
  this.connectivity.deferRetry();
4915
5251
  this.patchStatus({ lastError: op.lastError });
@@ -4955,11 +5291,25 @@ var OfflineManager = class {
4955
5291
  await this.adoptServerRow(op, op.id, row);
4956
5292
  } else if (op.type === "createMany") {
4957
5293
  const queued = op.data ?? [];
4958
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
5294
+ const rows = await inner.createMany(queued, {
5295
+ ...op.upsert ? { upsert: true } : {},
5296
+ idempotencyKey: op.mutationId
5297
+ });
4959
5298
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
5299
+ } else if (op.type === "updateMany") {
5300
+ const queued = op.updates ?? [];
5301
+ const rows = await inner.updateMany(queued.map((u) => ({
5302
+ id: u.id,
5303
+ data: u.data
5304
+ })), { idempotencyKey: op.mutationId });
5305
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
4960
5306
  } else if (op.type === "update") {
4961
5307
  const row = await inner.update(op.id, op.data);
4962
5308
  await this.ingestReplaced(op, op.id, row);
5309
+ } else if (op.type === "deleteMany") {
5310
+ const ids = op.ids ?? [];
5311
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
5312
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
4963
5313
  } else if (op.type === "delete") {
4964
5314
  await inner.delete(op.id);
4965
5315
  this.removeLocalRow(op.collection, op.id, true);
@@ -5204,23 +5554,37 @@ var OfflineManager = class {
5204
5554
  /**
5205
5555
  * Derive a WebSocket URL from an HTTP base URL.
5206
5556
  * `http://` → `ws://`, `https://` → `wss://`.
5557
+ *
5558
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
5559
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
5560
+ * relative one — resolved through `.origin` — so one deployment dialled two
5561
+ * different sockets depending on whether its config said `"/backend"` or
5562
+ * `"https://app.example.com/backend"`.
5563
+ *
5564
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
5565
+ * outside a browser has no origin, and inventing one would dial somewhere
5566
+ * arbitrary. The caller warns rather than leaving that silent.
5207
5567
  */
5208
5568
  function deriveWebSocketUrl(baseUrl) {
5569
+ const toWsProtocol = (url) => {
5570
+ const secure = /^(https|wss):/i.test(url);
5571
+ return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
5572
+ };
5209
5573
  if (typeof window !== "undefined") {
5210
- let absoluteUrl = "";
5574
+ let absoluteUrl;
5211
5575
  if (!baseUrl) absoluteUrl = window.location.origin;
5212
5576
  else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
5213
5577
  else try {
5214
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
5578
+ const resolved = new URL(baseUrl, window.location.href);
5579
+ absoluteUrl = resolved.origin + resolved.pathname;
5215
5580
  } catch {
5216
5581
  absoluteUrl = window.location.origin;
5217
5582
  }
5218
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
5219
- return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
5583
+ return toWsProtocol(absoluteUrl);
5220
5584
  }
5221
5585
  if (!baseUrl) return "";
5222
5586
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
5223
- return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
5587
+ return toWsProtocol(baseUrl);
5224
5588
  }
5225
5589
  function createRebaseClient(options) {
5226
5590
  const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
@@ -5248,7 +5612,11 @@ function createRebaseClient(options) {
5248
5612
  });
5249
5613
  return storageSourcesPromise;
5250
5614
  };
5251
- const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
5615
+ const realtimeEnabled = options.realtime !== false;
5616
+ const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
5617
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
5618
+ const unreachableReason = `no WebSocket URL could be derived from baseUrl ${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin to resolve a relative URL against. Pass an absolute \`baseUrl\`, set \`websocketUrl\` explicitly, or pass \`realtime: false\` to say this was intended.`;
5619
+ if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
5252
5620
  let ws;
5253
5621
  /** One channel object per name — see `realtime.channel`. */
5254
5622
  const realtimeChannels = /* @__PURE__ */ new Map();
@@ -5368,7 +5736,7 @@ function createRebaseClient(options) {
5368
5736
  * cut off the others.
5369
5737
  */
5370
5738
  channel: (name, options) => {
5371
- if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
5739
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
5372
5740
  let existing = realtimeChannels.get(name);
5373
5741
  if (!existing) {
5374
5742
  existing = new RebaseRealtimeChannel(name, ws, options);