@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 CHANGED
@@ -12,6 +12,7 @@ import { type OfflineApi, type OfflineConfig } from "./offline";
12
12
  import { InsertOf, RebaseClient, RebaseSdkData, RowOf, StorageSource, StorageSourceDefinition, StorageSourceRegistry, UpdateOf } from "@rebasepro/types";
13
13
  export { RebaseApiError } from "./transport";
14
14
  export { RebaseClientError } from "./errors";
15
+ export type { RebaseErrorCode } from "@rebasepro/types";
15
16
  export type { RebaseClientConfig, FindParams, FindResponse } from "./transport";
16
17
  export type { CollectionClient } from "./collection";
17
18
  export type { FindResult, SDKCollectionClient, SDKQueryBuilderInterface, PaginationMeta } from "@rebasepro/types";
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) {
@@ -131,6 +131,13 @@ function resolveBaseUrl(configured) {
131
131
  function createTransport(config, environment) {
132
132
  const fetchFn = config.fetch || globalThis.fetch;
133
133
  const apiPath = config.apiPath || "/api";
134
+ for (const field of ["baseUrl", "storageUrlOrigin"]) {
135
+ const value = config[field];
136
+ if (!value || !apiPath) continue;
137
+ const trimmed = value.replace(/\/+$/, "");
138
+ if (!trimmed.endsWith(apiPath)) continue;
139
+ 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.`);
140
+ }
134
141
  let token = config.token;
135
142
  let tokenGetter;
136
143
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -316,6 +323,11 @@ function createAuth(transport, options) {
316
323
  const authFlowMode = opts.authFlowMode || "json";
317
324
  const STORAGE_KEY = "rebase_auth";
318
325
  const REFRESH_BUFFER_MS = 12e4;
326
+ /**
327
+ * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
328
+ * Anything larger is silently clamped to 1ms by Node and every browser.
329
+ */
330
+ const MAX_TIMER_DELAY_MS = 2147483647;
319
331
  const MAX_REFRESH_RETRIES = 5;
320
332
  const REFRESH_RETRY_BASE_MS = 1e3;
321
333
  const REFRESH_RETRY_MAX_MS = 3e4;
@@ -343,7 +355,9 @@ function createAuth(transport, options) {
343
355
  function emit(event, session) {
344
356
  for (const fn of listeners) try {
345
357
  fn(event, session);
346
- } catch (e) {}
358
+ } catch (e) {
359
+ console.error("Error in auth state change listener:", e);
360
+ }
347
361
  }
348
362
  function saveSession(session) {
349
363
  if (!persistSession || authFlowMode === "cookie") return;
@@ -447,6 +461,10 @@ function createAuth(transport, options) {
447
461
  attemptScheduledRefresh(0);
448
462
  return;
449
463
  }
464
+ if (delay > MAX_TIMER_DELAY_MS) {
465
+ refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
466
+ return;
467
+ }
450
468
  refreshTimeout = setTimeout(() => {
451
469
  attemptScheduledRefresh(0);
452
470
  }, delay);
@@ -1270,6 +1288,11 @@ var SDKQueryBuilder = class {
1270
1288
  };
1271
1289
  //#endregion
1272
1290
  //#region src/collection.ts
1291
+ /**
1292
+ * Counts currently in flight, keyed by the exact request they issue. Entries
1293
+ * live only for the duration of the request — see `count()` for why.
1294
+ */
1295
+ var inflightCounts = /* @__PURE__ */ new Map();
1273
1296
  function createCollectionClient(transport, slug, ws) {
1274
1297
  const basePath = `/data/${slug}`;
1275
1298
  const client = {
@@ -1314,18 +1337,63 @@ function createCollectionClient(transport, slug, ws) {
1314
1337
  body: JSON.stringify({
1315
1338
  rows: data,
1316
1339
  ...options?.upsert ? { upsert: true } : {}
1317
- })
1340
+ }),
1341
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1318
1342
  })).data || [];
1319
1343
  },
1344
+ /**
1345
+ * Still `PUT`, deliberately, even though the server now serves `PATCH`
1346
+ * on the same handler and `PATCH` is the honest verb for a merge.
1347
+ *
1348
+ * The two are interchangeable server-side, so switching buys nothing at
1349
+ * runtime — and it costs compatibility in the direction that fails
1350
+ * quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
1351
+ * a route that does not exist and get a **404**, which is
1352
+ * indistinguishable from "that row is gone". Every write would look like
1353
+ * a missing record.
1354
+ *
1355
+ * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
1356
+ * client gets the correct verb; this stays on `PUT` until the oldest
1357
+ * supported server is one that serves both.
1358
+ */
1320
1359
  async update(id, data) {
1321
1360
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1322
1361
  method: "PUT",
1323
1362
  body: JSON.stringify(data)
1324
1363
  });
1325
1364
  },
1365
+ async updateMany(updates, options) {
1366
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
1367
+ if (updates.length === 0) return [];
1368
+ return (await transport.request(`${basePath}/bulk`, {
1369
+ method: "PATCH",
1370
+ body: JSON.stringify({ updates }),
1371
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1372
+ })).data || [];
1373
+ },
1326
1374
  async delete(id) {
1327
1375
  await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
1328
1376
  },
1377
+ /**
1378
+ * `POST .../bulk/delete`, not `DELETE .../bulk`.
1379
+ *
1380
+ * The honest verb would take the ids in a DELETE body, and that is the
1381
+ * one request shape the HTTP ecosystem handles unreliably: bodies on
1382
+ * DELETE are permitted but widely dropped by proxies and CDNs, and
1383
+ * several OpenAPI generators ignore `requestBody` on a DELETE
1384
+ * operation, so a generated client would send the request without its
1385
+ * ids. A backend deployed behind arbitrary ingress cannot take that
1386
+ * bet. Same reason `:batchDelete` exists in Google's API guidelines.
1387
+ */
1388
+ async deleteMany(ids, options) {
1389
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
1390
+ if (ids.length === 0) return;
1391
+ await transport.request(`${basePath}/bulk/delete`, {
1392
+ method: "POST",
1393
+ body: JSON.stringify({ ids }),
1394
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1395
+ });
1396
+ },
1329
1397
  async count(params) {
1330
1398
  const qs = buildQueryString({
1331
1399
  ...params,
@@ -1333,12 +1401,28 @@ function createCollectionClient(transport, slug, ws) {
1333
1401
  offset: void 0,
1334
1402
  include: void 0
1335
1403
  });
1336
- return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
1404
+ const key = basePath + "/count" + qs;
1405
+ const inflight = inflightCounts.get(key);
1406
+ if (inflight) return inflight;
1407
+ const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
1408
+ inflightCounts.set(key, request);
1409
+ try {
1410
+ return await request;
1411
+ } finally {
1412
+ inflightCounts.delete(key);
1413
+ }
1337
1414
  },
1338
1415
  observe(params, onResult, onError, options) {
1339
1416
  let closed = false;
1340
- const emit = (result) => {
1417
+ let liveDelivered = false;
1418
+ let signature;
1419
+ const deliver = (result, fromLive) => {
1341
1420
  if (closed) return;
1421
+ if (fromLive) liveDelivered = true;
1422
+ else if (liveDelivered) return;
1423
+ const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
1424
+ if (signature !== void 0 && next === signature) return;
1425
+ signature = next;
1342
1426
  onResult({
1343
1427
  ...result,
1344
1428
  fromCache: false,
@@ -1346,10 +1430,10 @@ function createCollectionClient(transport, slug, ws) {
1346
1430
  partial: false
1347
1431
  });
1348
1432
  };
1349
- client.find(params).then(emit).catch((error) => {
1433
+ client.find(params).then((result) => deliver(result, false)).catch((error) => {
1350
1434
  if (!closed) onError?.(error);
1351
1435
  });
1352
- const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
1436
+ const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
1353
1437
  return () => {
1354
1438
  closed = true;
1355
1439
  live?.();
@@ -1357,17 +1441,24 @@ function createCollectionClient(transport, slug, ws) {
1357
1441
  },
1358
1442
  observeById(id, onResult, onError, options) {
1359
1443
  let closed = false;
1360
- const emit = (row) => {
1444
+ let liveDelivered = false;
1445
+ let signature;
1446
+ const deliver = (row, fromLive) => {
1361
1447
  if (closed) return;
1448
+ if (fromLive) liveDelivered = true;
1449
+ else if (liveDelivered) return;
1450
+ const next = row === void 0 ? "\0missing" : JSON.stringify(row);
1451
+ if (signature !== void 0 && next === signature) return;
1452
+ signature = next;
1362
1453
  onResult(row, {
1363
1454
  fromCache: false,
1364
1455
  hasPendingWrites: false
1365
1456
  });
1366
1457
  };
1367
- client.findById(id).then(emit).catch((error) => {
1458
+ client.findById(id).then((row) => deliver(row, false)).catch((error) => {
1368
1459
  if (!closed) onError?.(error);
1369
1460
  });
1370
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
1461
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
1371
1462
  return () => {
1372
1463
  closed = true;
1373
1464
  live?.();
@@ -1398,51 +1489,43 @@ function createCollectionClient(transport, slug, ws) {
1398
1489
  client.listen = (params, onUpdate, onError) => {
1399
1490
  let active = true;
1400
1491
  let lastUpdateId = 0;
1492
+ let lastKnownTotal;
1493
+ const window = resolveFindWindow(params);
1401
1494
  const unsub = ws.listenCollection({
1402
1495
  path: slug,
1403
1496
  filter: params?.where,
1497
+ logical: params?.logical,
1404
1498
  limit: params?.limit,
1405
- startAfter: params?.offset ? String(params.offset) : void 0,
1499
+ offset: window.driverOffset,
1406
1500
  orderBy: params?.orderBy?.[0],
1407
1501
  order: params?.orderBy?.[1],
1408
1502
  searchString: params?.searchString
1409
1503
  }, (incomingRows) => {
1410
1504
  const currentUpdateId = ++lastUpdateId;
1411
- const requestedLimit = params?.limit || 20;
1412
- const offset = params?.offset || 0;
1505
+ const requestedLimit = window.limit;
1506
+ const offset = window.offset;
1413
1507
  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({
1508
+ const emit = (total, hasMore) => {
1509
+ if (!active || currentUpdateId !== lastUpdateId) return;
1510
+ onUpdate({
1418
1511
  data: rows,
1419
1512
  meta: {
1420
1513
  total,
1421
1514
  limit: requestedLimit,
1422
1515
  offset,
1423
- hasMore: offset + rows.length < total
1516
+ hasMore
1424
1517
  }
1425
1518
  });
1519
+ };
1520
+ const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
1521
+ if (client.count) client.count(params).then((total) => {
1522
+ lastKnownTotal = total;
1523
+ emit(total, offset + rows.length < total);
1426
1524
  }).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
- }
1525
+ if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
1526
+ else emitWithoutCount();
1445
1527
  });
1528
+ else emitWithoutCount();
1446
1529
  }, onError);
1447
1530
  return () => {
1448
1531
  active = false;
@@ -2833,15 +2916,10 @@ var RebaseWebSocketClient = class {
2833
2916
  }
2834
2917
  }
2835
2918
  createCollectionSubscriptionKey(props) {
2919
+ const { collection, ...query } = props;
2836
2920
  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
2921
+ ...query,
2922
+ collection: collection?.name
2845
2923
  };
2846
2924
  return JSON.stringify(key, (_, value) => {
2847
2925
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
@@ -3764,14 +3842,23 @@ function looseEquals(a, b) {
3764
3842
  */
3765
3843
  function likeToRegExp(pattern, caseInsensitive) {
3766
3844
  let source = "^";
3845
+ let lastWasWildcard = false;
3767
3846
  for (let i = 0; i < pattern.length; i++) {
3768
3847
  const char = pattern[i];
3769
3848
  if (char === "\\" && i + 1 < pattern.length) {
3770
3849
  source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3771
3850
  i++;
3772
- } else if (char === "%") source += "[\\s\\S]*";
3773
- else if (char === "_") source += "[\\s\\S]";
3774
- else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3851
+ lastWasWildcard = false;
3852
+ } else if (char === "%") {
3853
+ if (!lastWasWildcard) source += "[\\s\\S]*";
3854
+ lastWasWildcard = true;
3855
+ } else if (char === "_") {
3856
+ source += "[\\s\\S]";
3857
+ lastWasWildcard = false;
3858
+ } else {
3859
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3860
+ lastWasWildcard = false;
3861
+ }
3775
3862
  }
3776
3863
  return new RegExp(source + "$", caseInsensitive ? "i" : "");
3777
3864
  }
@@ -3903,12 +3990,20 @@ function sortRows(rows, orderBy) {
3903
3990
  function tiebreak(a, b) {
3904
3991
  return compareValues(a.id, b.id) ?? 0;
3905
3992
  }
3906
- /** Resolve `page`/`offset`/`limit` the way the server does. */
3993
+ /**
3994
+ * Resolve `page`/`offset`/`limit` the way the server does.
3995
+ *
3996
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
3997
+ * 50, so the same `observe()` answered with 20 rows from the local database and
3998
+ * 50 from the network — a list that changed length depending on which side
3999
+ * answered, with `page` striding differently on each. Delegated now, so the
4000
+ * sentence above is true by construction rather than by agreement.
4001
+ */
3907
4002
  function resolvePagination(params) {
3908
- const limit = params?.limit ?? 20;
4003
+ const { limit, offset } = resolveFindWindow(params);
3909
4004
  return {
3910
4005
  limit,
3911
- offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
4006
+ offset
3912
4007
  };
3913
4008
  }
3914
4009
  /**
@@ -4238,6 +4333,72 @@ var OfflineManager = class {
4238
4333
  this.notifyCollection(slug);
4239
4334
  return rows;
4240
4335
  },
4336
+ updateMany: async (updates, options) => {
4337
+ await this.ensureCollection(slug);
4338
+ if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
4339
+ if (updates.length === 0) return [];
4340
+ const anyPending = updates.some((u) => this.hasPending(slug, u.id));
4341
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
4342
+ const rows = await inner.updateMany(updates, options);
4343
+ this.connectivity.markSuccess();
4344
+ await this.ingest(slug, rows);
4345
+ this.notifyCollection(slug);
4346
+ return rows;
4347
+ } catch (error) {
4348
+ if (!isNetworkError(error)) throw error;
4349
+ this.connectivity.markFailure();
4350
+ }
4351
+ const rollback = {};
4352
+ const optimistic = [];
4353
+ for (const { id, data } of updates) {
4354
+ const base = this.rawLocalRow(slug, id);
4355
+ rollback[String(id)] = base ?? null;
4356
+ optimistic.push({
4357
+ ...base ?? {},
4358
+ ...data,
4359
+ id
4360
+ });
4361
+ }
4362
+ await this.enqueue({
4363
+ collection: slug,
4364
+ type: "updateMany",
4365
+ updates: updates.map((u) => ({
4366
+ id: u.id,
4367
+ data: u.data
4368
+ })),
4369
+ rollback: { rows: rollback }
4370
+ });
4371
+ for (const row of optimistic) this.setLocalRow(slug, row.id, row);
4372
+ this.notifyCollection(slug);
4373
+ return optimistic;
4374
+ },
4375
+ deleteMany: async (ids, options) => {
4376
+ await this.ensureCollection(slug);
4377
+ if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
4378
+ if (ids.length === 0) return;
4379
+ const anyPending = ids.some((id) => this.hasPending(slug, id));
4380
+ if (this.connectivity.shouldAttempt() && !anyPending) try {
4381
+ await inner.deleteMany(ids, options);
4382
+ this.connectivity.markSuccess();
4383
+ for (const id of ids) this.removeLocalRow(slug, id, true);
4384
+ this.notifyCollection(slug);
4385
+ this.scheduleRefresh(slug);
4386
+ return;
4387
+ } catch (error) {
4388
+ if (!isNetworkError(error)) throw error;
4389
+ this.connectivity.markFailure();
4390
+ }
4391
+ const rollback = {};
4392
+ for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
4393
+ await this.enqueue({
4394
+ collection: slug,
4395
+ type: "deleteMany",
4396
+ ids,
4397
+ rollback: { rows: rollback }
4398
+ });
4399
+ for (const id of ids) this.removeLocalRow(slug, id, false);
4400
+ this.notifyCollection(slug);
4401
+ },
4241
4402
  update: async (id, data) => {
4242
4403
  await this.ensureCollection(slug);
4243
4404
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
@@ -4536,9 +4697,8 @@ var OfflineManager = class {
4536
4697
  if (!state) return {
4537
4698
  data: [],
4538
4699
  meta: {
4700
+ ...resolvePagination(params),
4539
4701
  total: 0,
4540
- limit: params?.limit ?? 20,
4541
- offset: params?.offset ?? 0,
4542
4702
  hasMore: false
4543
4703
  },
4544
4704
  fromCache: true,
@@ -4574,16 +4734,14 @@ var OfflineManager = class {
4574
4734
  }
4575
4735
  let added = 0;
4576
4736
  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);
4737
+ if (exact && offset === 0) for (const [key, entry] of state.rows) {
4738
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
4739
+ if (!this.isLocallyCreated(slug, key)) continue;
4740
+ if (!matchesParams(entry.row, params)) continue;
4741
+ rows.push(entry.row);
4742
+ added++;
4586
4743
  }
4744
+ if (params?.orderBy) sortRows(rows, params.orderBy);
4587
4745
  return {
4588
4746
  data: rows,
4589
4747
  meta: {
@@ -4730,17 +4888,17 @@ var OfflineManager = class {
4730
4888
  return row;
4731
4889
  }
4732
4890
  recordSnapshot(slug, params, result) {
4891
+ const window = resolvePagination(params);
4733
4892
  const meta = result.meta ?? {
4734
4893
  total: result.data?.length ?? 0,
4735
- limit: 20,
4736
- offset: 0,
4894
+ ...window,
4737
4895
  hasMore: false
4738
4896
  };
4739
4897
  const snapshot = {
4740
4898
  ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
4741
4899
  total: meta.total ?? result.data?.length ?? 0,
4742
- limit: meta.limit ?? params?.limit ?? 20,
4743
- offset: meta.offset ?? params?.offset ?? 0,
4900
+ limit: meta.limit ?? window.limit,
4901
+ offset: meta.offset ?? window.offset,
4744
4902
  hasMore: meta.hasMore ?? false
4745
4903
  };
4746
4904
  const state = this.collectionState(slug);
@@ -4955,11 +5113,25 @@ var OfflineManager = class {
4955
5113
  await this.adoptServerRow(op, op.id, row);
4956
5114
  } else if (op.type === "createMany") {
4957
5115
  const queued = op.data ?? [];
4958
- const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
5116
+ const rows = await inner.createMany(queued, {
5117
+ ...op.upsert ? { upsert: true } : {},
5118
+ idempotencyKey: op.mutationId
5119
+ });
4959
5120
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
5121
+ } else if (op.type === "updateMany") {
5122
+ const queued = op.updates ?? [];
5123
+ const rows = await inner.updateMany(queued.map((u) => ({
5124
+ id: u.id,
5125
+ data: u.data
5126
+ })), { idempotencyKey: op.mutationId });
5127
+ for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
4960
5128
  } else if (op.type === "update") {
4961
5129
  const row = await inner.update(op.id, op.data);
4962
5130
  await this.ingestReplaced(op, op.id, row);
5131
+ } else if (op.type === "deleteMany") {
5132
+ const ids = op.ids ?? [];
5133
+ await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
5134
+ for (const id of ids) this.removeLocalRow(op.collection, id, true);
4963
5135
  } else if (op.type === "delete") {
4964
5136
  await inner.delete(op.id);
4965
5137
  this.removeLocalRow(op.collection, op.id, true);
@@ -5204,23 +5376,37 @@ var OfflineManager = class {
5204
5376
  /**
5205
5377
  * Derive a WebSocket URL from an HTTP base URL.
5206
5378
  * `http://` → `ws://`, `https://` → `wss://`.
5379
+ *
5380
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
5381
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
5382
+ * relative one — resolved through `.origin` — so one deployment dialled two
5383
+ * different sockets depending on whether its config said `"/backend"` or
5384
+ * `"https://app.example.com/backend"`.
5385
+ *
5386
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
5387
+ * outside a browser has no origin, and inventing one would dial somewhere
5388
+ * arbitrary. The caller warns rather than leaving that silent.
5207
5389
  */
5208
5390
  function deriveWebSocketUrl(baseUrl) {
5391
+ const toWsProtocol = (url) => {
5392
+ const secure = /^(https|wss):/i.test(url);
5393
+ return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
5394
+ };
5209
5395
  if (typeof window !== "undefined") {
5210
- let absoluteUrl = "";
5396
+ let absoluteUrl;
5211
5397
  if (!baseUrl) absoluteUrl = window.location.origin;
5212
5398
  else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
5213
5399
  else try {
5214
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
5400
+ const resolved = new URL(baseUrl, window.location.href);
5401
+ absoluteUrl = resolved.origin + resolved.pathname;
5215
5402
  } catch {
5216
5403
  absoluteUrl = window.location.origin;
5217
5404
  }
5218
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
5219
- return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
5405
+ return toWsProtocol(absoluteUrl);
5220
5406
  }
5221
5407
  if (!baseUrl) return "";
5222
5408
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
5223
- return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
5409
+ return toWsProtocol(baseUrl);
5224
5410
  }
5225
5411
  function createRebaseClient(options) {
5226
5412
  const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
@@ -5248,7 +5434,11 @@ function createRebaseClient(options) {
5248
5434
  });
5249
5435
  return storageSourcesPromise;
5250
5436
  };
5251
- const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
5437
+ const realtimeEnabled = options.realtime !== false;
5438
+ const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
5439
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
5440
+ 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.`;
5441
+ if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
5252
5442
  let ws;
5253
5443
  /** One channel object per name — see `realtime.channel`. */
5254
5444
  const realtimeChannels = /* @__PURE__ */ new Map();
@@ -5368,7 +5558,7 @@ function createRebaseClient(options) {
5368
5558
  * cut off the others.
5369
5559
  */
5370
5560
  channel: (name, options) => {
5371
- if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
5561
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
5372
5562
  let existing = realtimeChannels.get(name);
5373
5563
  if (!existing) {
5374
5564
  existing = new RebaseRealtimeChannel(name, ws, options);