@rebasepro/client 0.25.1 → 0.26.0

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.
@@ -1,6 +1,6 @@
1
1
  import { a as byIdRead, c as invalidBulkBody, d as RebaseApiError$1, f as buildQueryString, g as rebaseReviver, l as SDKQueryBuilder, n as MemoryOfflineStore, r as createMutationId, s as etagOf, t as IndexedDBOfflineStore, u as RebaseClientError$1 } from "./offline-store-BhYXViha.js";
2
2
  import { EntityReference, EntityRelation, GeoPoint, Vector, hasFieldOperation, isUnsupported, parseRelationAggregateSort, toCanonicalOp } from "@rebasepro/types";
3
- import { collectAllPages, normalizeOrderBy, paginateFind, resolveFindWindow } from "@rebasepro/common";
3
+ import { collectAllPages, normalizeOrderBy, paginateFind, resolveFindWindow, withETag } from "@rebasepro/common";
4
4
  import { splitSearchTerms, unref } from "@rebasepro/utils";
5
5
  //#region src/offline-codec.ts
6
6
  /**
@@ -903,6 +903,13 @@ function isPlainObject(value) {
903
903
  const proto = Object.getPrototypeOf(value);
904
904
  return proto === null || Object.getPrototypeOf(proto) === null;
905
905
  }
906
+ /**
907
+ * The key a folded run is sent under: its first and last member and its
908
+ * length, which fixes its composition.
909
+ */
910
+ function runKey(run) {
911
+ return `${run[0].mutationId}..${run[run.length - 1].mutationId}:${run.length}`;
912
+ }
906
913
  /** Whether a queued write's payload mentions `id` anywhere — see {@link replaceMintedId}. */
907
914
  function mentionsMintedId(op, id) {
908
915
  return [op.data, ...(op.updates ?? []).map((u) => u.data)].some((payload) => replaceMintedId(payload, id, "").changed);
@@ -1326,19 +1333,45 @@ var OfflineManager = class {
1326
1333
  */
1327
1334
  ticket() {
1328
1335
  if (this.scopeHold) return void 0;
1329
- return {
1330
- scope: this.scope,
1331
- epoch: this.scopeEpoch
1332
- };
1336
+ return this.currentTicket();
1333
1337
  }
1334
1338
  /** The ticket of whoever the held scope turns out to be. */
1335
1339
  async nextTicket() {
1336
1340
  await this.scopeKnown();
1337
- return {
1341
+ return this.currentTicket();
1342
+ }
1343
+ currentTicket() {
1344
+ return this.unverified ? {
1345
+ scope: this.scope,
1346
+ epoch: this.scopeEpoch,
1347
+ unverified: true
1348
+ } : {
1338
1349
  scope: this.scope,
1339
1350
  epoch: this.scopeEpoch
1340
1351
  };
1341
1352
  }
1353
+ /**
1354
+ * Whether a write may go to the server now. Not while the scope is the last
1355
+ * user's, unconfirmed: it would go out with no credentials, as nobody. It
1356
+ * queues for its author instead, as it does offline.
1357
+ */
1358
+ sendsWrites() {
1359
+ return this.connectivity.shouldAttempt() && !this.unverified;
1360
+ }
1361
+ reachedUnconfirmedListeners = /* @__PURE__ */ new Set();
1362
+ /**
1363
+ * `listener` runs when a request made while the scope is unconfirmed
1364
+ * reached the server: the connection is back, so the session can be asked
1365
+ * about. The client wires it to the same confirmation the status listener
1366
+ * runs — the status may never have said the connection was gone.
1367
+ */
1368
+ onReachedUnconfirmed(listener) {
1369
+ this.reachedUnconfirmedListeners.add(listener);
1370
+ return () => this.reachedUnconfirmedListeners.delete(listener);
1371
+ }
1372
+ reachedUnconfirmed() {
1373
+ for (const listener of this.reachedUnconfirmedListeners) callSafely(listener);
1374
+ }
1342
1375
  /** Whether an operation's user is still the signed-in one. */
1343
1376
  isCurrent(ticket) {
1344
1377
  return ticket.epoch === this.scopeEpoch;
@@ -1395,15 +1428,18 @@ var OfflineManager = class {
1395
1428
  const res = await inner.find(params);
1396
1429
  this.connectivity.markSuccess();
1397
1430
  if (!this.isCurrent(ticket)) return res;
1398
- await this.ingest(slug, res.data ?? [], ticket, isProjection(params));
1399
- if (!this.isCurrent(ticket)) return res;
1400
- const snapshot = this.recordSnapshot(slug, params, res, ticket);
1401
- const answer = this.answer(slug, params, snapshot);
1402
- this.notifyCollection(slug, false);
1403
- return {
1404
- data: answer.data,
1405
- meta: answer.meta
1406
- };
1431
+ if (ticket.unverified) this.reachedUnconfirmed();
1432
+ else {
1433
+ await this.ingest(slug, res.data ?? [], ticket, isProjection(params));
1434
+ if (!this.isCurrent(ticket)) return res;
1435
+ const snapshot = this.recordSnapshot(slug, params, res, ticket);
1436
+ const answer = this.answer(slug, params, snapshot);
1437
+ this.notifyCollection(slug, false);
1438
+ return {
1439
+ data: answer.data,
1440
+ meta: answer.meta
1441
+ };
1442
+ }
1407
1443
  } catch (error) {
1408
1444
  if (!isNetworkError(error)) {
1409
1445
  if (isRetryableError(error) && this.hasLocalAnswer(state, slug, params) && !(walk && !this.snapshotFor(slug, params))) {
@@ -1437,10 +1473,16 @@ var OfflineManager = class {
1437
1473
  const row = await inner.findById(id, options);
1438
1474
  this.connectivity.markSuccess();
1439
1475
  if (!this.isCurrent(ticket)) return row;
1476
+ if (ticket.unverified) {
1477
+ this.reachedUnconfirmed();
1478
+ return this.localRow(slug, id);
1479
+ }
1440
1480
  if (row !== void 0) await this.ingest(slug, [row], ticket);
1441
1481
  else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1442
1482
  this.notifyCollection(slug, false);
1443
- return this.localRow(slug, id);
1483
+ const local = this.localRow(slug, id);
1484
+ const etag = etagOf(row);
1485
+ return etag && local !== void 0 && !this.hasPending(slug, id) ? withETag({ ...local }, etag) : local;
1444
1486
  } catch (error) {
1445
1487
  if (!isNetworkError(error)) throw error;
1446
1488
  this.connectivity.markFailure();
@@ -1463,7 +1505,7 @@ var OfflineManager = class {
1463
1505
  await this.ensureCollection(slug);
1464
1506
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1465
1507
  let sent = false;
1466
- if (this.connectivity.shouldAttempt()) try {
1508
+ if (this.sendsWrites()) try {
1467
1509
  const row = await inner.create(data, id, {
1468
1510
  ...options,
1469
1511
  idempotencyKey
@@ -1505,7 +1547,7 @@ var OfflineManager = class {
1505
1547
  if (data.length === 0) return [];
1506
1548
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1507
1549
  let sent = false;
1508
- if (this.connectivity.shouldAttempt()) try {
1550
+ if (this.sendsWrites()) try {
1509
1551
  const rows = await inner.createMany(data, {
1510
1552
  ...options,
1511
1553
  idempotencyKey
@@ -1557,7 +1599,7 @@ var OfflineManager = class {
1557
1599
  upsert: async (data, options) => {
1558
1600
  const ticket = this.ticket() ?? await this.nextTicket();
1559
1601
  await this.ensureCollection(slug);
1560
- if (this.connectivity.shouldAttempt()) try {
1602
+ if (this.sendsWrites()) try {
1561
1603
  const row = await inner.upsert(data, options);
1562
1604
  this.connectivity.markSuccess();
1563
1605
  await this.ingest(slug, [row], ticket);
@@ -1588,7 +1630,7 @@ var OfflineManager = class {
1588
1630
  if (!Array.isArray(updates)) throw invalidBulkBody("updateMany expects an array of { id, data } entries.");
1589
1631
  if (updates.length === 0) return [];
1590
1632
  const anyPending = updates.some((u) => this.hasPending(slug, u.id));
1591
- if (this.connectivity.shouldAttempt() && !anyPending) try {
1633
+ if (this.sendsWrites() && !anyPending) try {
1592
1634
  const rows = await inner.updateMany(updates, options);
1593
1635
  this.connectivity.markSuccess();
1594
1636
  await this.ingest(slug, rows, ticket);
@@ -1627,7 +1669,7 @@ var OfflineManager = class {
1627
1669
  if (!Array.isArray(ids)) throw invalidBulkBody("deleteMany expects an array of ids.");
1628
1670
  if (ids.length === 0) return;
1629
1671
  const anyPending = ids.some((id) => this.hasPending(slug, id));
1630
- if (this.connectivity.shouldAttempt() && !anyPending) try {
1672
+ if (this.sendsWrites() && !anyPending) try {
1631
1673
  await inner.deleteMany(ids, options);
1632
1674
  this.connectivity.markSuccess();
1633
1675
  for (const id of ids) this.removeLocalRow(slug, id, ticket, true);
@@ -1655,7 +1697,7 @@ var OfflineManager = class {
1655
1697
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1656
1698
  let sent = false;
1657
1699
  let sendFailure;
1658
- if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
1700
+ if (this.sendsWrites() && !this.hasPending(slug, id)) try {
1659
1701
  const row = await inner.update(id, data, {
1660
1702
  ...options,
1661
1703
  idempotencyKey
@@ -1680,7 +1722,7 @@ var OfflineManager = class {
1680
1722
  throw new RebaseClientError$1(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
1681
1723
  }
1682
1724
  const base = this.rawLocalRow(slug, id);
1683
- const ifMatch = this.onConflict ? options?.ifMatch ?? (this.hasPending(slug, id) ? void 0 : this.etagFor(slug, id)) : void 0;
1725
+ const ifMatch = options?.ifMatch ?? (this.onConflict && !this.hasPending(slug, id) ? this.etagFor(slug, id) : void 0);
1684
1726
  await this.enqueue({
1685
1727
  collection: slug,
1686
1728
  type: "update",
@@ -1703,7 +1745,7 @@ var OfflineManager = class {
1703
1745
  await this.ensureCollection(slug);
1704
1746
  const idempotencyKey = options?.idempotencyKey ?? createMutationId();
1705
1747
  let sent = false;
1706
- if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
1748
+ if (this.sendsWrites() && !this.hasPending(slug, id)) try {
1707
1749
  await inner.delete(id, {
1708
1750
  ...options,
1709
1751
  idempotencyKey
@@ -1723,6 +1765,7 @@ var OfflineManager = class {
1723
1765
  type: "delete",
1724
1766
  id,
1725
1767
  ...sent ? { sent: { idempotencyKey } } : {},
1768
+ ...options?.ifMatch ? { ifMatch: options.ifMatch } : {},
1726
1769
  rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
1727
1770
  }, ticket);
1728
1771
  this.removeLocalRow(slug, id, ticket);
@@ -1735,6 +1778,12 @@ var OfflineManager = class {
1735
1778
  const n = await inner.count(params);
1736
1779
  this.connectivity.markSuccess();
1737
1780
  if (!this.isCurrent(ticket)) return n;
1781
+ if (ticket.unverified) {
1782
+ this.reachedUnconfirmed();
1783
+ const cached = await this.readCache(this.countKey(slug, params));
1784
+ if (cached !== void 0) return Math.max(0, cached + this.pendingDelta(slug, params));
1785
+ throw offlineError(`No count of "${slug}" for the last user is cached, and their session is not confirmed yet.`);
1786
+ }
1738
1787
  this.writeCache(this.countKey(slug, params), n);
1739
1788
  return Math.max(0, n + this.pendingDelta(slug, params));
1740
1789
  } catch (error) {
@@ -1773,6 +1822,15 @@ var OfflineManager = class {
1773
1822
  onUpdate(response);
1774
1823
  return;
1775
1824
  }
1825
+ if (ticket.unverified) {
1826
+ const answer = this.localFind(slug, params);
1827
+ onUpdate({
1828
+ ...response,
1829
+ data: answer.data,
1830
+ meta: answer.meta
1831
+ });
1832
+ return;
1833
+ }
1776
1834
  this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
1777
1835
  if (!this.isCurrent(ticket)) return;
1778
1836
  const snapshot = this.recordSnapshot(slug, params, response, ticket);
@@ -1791,6 +1849,10 @@ var OfflineManager = class {
1791
1849
  onUpdate(row);
1792
1850
  return;
1793
1851
  }
1852
+ if (ticket.unverified) {
1853
+ onUpdate(this.localRow(slug, id));
1854
+ return;
1855
+ }
1794
1856
  if (!row) {
1795
1857
  if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1796
1858
  this.notifyCollection(slug, false);
@@ -1822,7 +1884,7 @@ var OfflineManager = class {
1822
1884
  wrapTrash(slug, inner) {
1823
1885
  const online = async (what, call) => {
1824
1886
  const message = `Offline: ${what} on the trash of "${slug}" needs the server; the trash is never cached on this device.`;
1825
- if (!this.connectivity.shouldAttempt()) throw offlineError(message);
1887
+ if (!this.sendsWrites()) throw offlineError(message);
1826
1888
  try {
1827
1889
  const result = await call();
1828
1890
  this.connectivity.markSuccess();
@@ -1936,7 +1998,7 @@ var OfflineManager = class {
1936
1998
  })();
1937
1999
  if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
1938
2000
  const ticket = this.ticket();
1939
- if (!ticket) return;
2001
+ if (!ticket || ticket.unverified) return;
1940
2002
  this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
1941
2003
  this.recordSnapshot(slug, params, response, ticket);
1942
2004
  this.notifyCollection(slug, false);
@@ -1991,7 +2053,7 @@ var OfflineManager = class {
1991
2053
  })();
1992
2054
  if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
1993
2055
  const ticket = this.ticket();
1994
- if (!ticket) return;
2056
+ if (!ticket || ticket.unverified) return;
1995
2057
  if (!row) {
1996
2058
  if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
1997
2059
  this.notifyCollection(slug, false);
@@ -2316,6 +2378,7 @@ var OfflineManager = class {
2316
2378
  */
2317
2379
  removeLocalRow(slug, id, ticket, known = false) {
2318
2380
  if (!this.isCurrent(ticket)) return;
2381
+ if (known && ticket.unverified) return;
2319
2382
  const state = this.collectionState(slug);
2320
2383
  const key = String(id);
2321
2384
  const existed = state.rows.delete(key);
@@ -2348,7 +2411,7 @@ var OfflineManager = class {
2348
2411
  * dropdown elsewhere asked for titles.
2349
2412
  */
2350
2413
  async ingest(slug, rows, ticket, projection = false) {
2351
- if (rows.length === 0 || !this.isCurrent(ticket)) return;
2414
+ if (rows.length === 0 || !this.isCurrent(ticket) || ticket.unverified) return;
2352
2415
  const state = await this.ensureCollection(slug);
2353
2416
  if (!this.isCurrent(ticket)) return;
2354
2417
  const cachedAt = Date.now();
@@ -2452,7 +2515,7 @@ var OfflineManager = class {
2452
2515
  hasMore: meta.hasMore ?? false,
2453
2516
  ...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
2454
2517
  };
2455
- if (!this.isCurrent(ticket)) return snapshot;
2518
+ if (!this.isCurrent(ticket) || ticket.unverified) return snapshot;
2456
2519
  const state = this.collectionState(slug);
2457
2520
  const key = buildQueryString(params);
2458
2521
  state.snapshots.set(key, snapshot);
@@ -2550,7 +2613,7 @@ var OfflineManager = class {
2550
2613
  }
2551
2614
  if (mutation.type === "update") {
2552
2615
  const tail = this.queue[this.queue.length - 1];
2553
- if (tail && !this.inFlight.has(tail.mutationId) && tail.sent === void 0 && (tail.schemaVersion ?? 1) <= 1 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
2616
+ if (tail && !this.inFlight.has(tail.mutationId) && tail.sent === void 0 && (tail.schemaVersion ?? 1) <= 1 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id && (mutation.ifMatch === void 0 || mutation.ifMatch === tail.ifMatch)) {
2554
2617
  if (tail.type === "update") tail.parts = [...tail.parts ?? [{ ...tail.data }], { ...mutation.data }];
2555
2618
  tail.data = {
2556
2619
  ...tail.data,
@@ -2731,7 +2794,7 @@ var OfflineManager = class {
2731
2794
  this.connectivity.markFailure();
2732
2795
  break;
2733
2796
  }
2734
- if (op.type === "update" && op.ifMatch && isPreconditionFailed(error)) {
2797
+ if ((op.type === "update" || op.type === "delete") && op.ifMatch && this.onConflict && isPreconditionFailed(error)) {
2735
2798
  this.connectivity.markSuccess();
2736
2799
  await this.resolveConflict(op, error, ticket);
2737
2800
  continue;
@@ -2802,16 +2865,18 @@ var OfflineManager = class {
2802
2865
  */
2803
2866
  foldableRun(unbatched) {
2804
2867
  if (this.replayBatchSize < 2) return [];
2805
- const foldable = (op, collection) => op !== void 0 && op.type === "create" && op.generatedId === true && typeof op.id === "string" && op.sent === void 0 && !op.upsert && (op.schemaVersion ?? 1) <= 1 && !unbatched.has(op.mutationId) && (collection === void 0 || op.collection === collection);
2806
2868
  const head = this.queue[0];
2869
+ const resent = head?.sent?.run;
2870
+ const foldable = (op, collection) => op !== void 0 && op.type === "create" && op.generatedId === true && typeof op.id === "string" && (resent === void 0 ? op.sent === void 0 : op.sent?.run === resent) && !op.upsert && (op.schemaVersion ?? 1) <= 1 && !unbatched.has(op.mutationId) && (collection === void 0 || op.collection === collection);
2807
2871
  if (!foldable(head)) return [];
2808
2872
  const run = [head];
2809
- for (let i = 1; i < this.queue.length && run.length < this.replayBatchSize; i++) {
2873
+ for (let i = 1; i < this.queue.length && (resent !== void 0 || run.length < this.replayBatchSize); i++) {
2810
2874
  const next = this.queue[i];
2811
2875
  if (!foldable(next, head.collection)) break;
2812
2876
  if (run.some((member) => mentionsMintedId(next, member.id))) break;
2813
2877
  run.push(next);
2814
2878
  }
2879
+ if (resent !== void 0 && runKey(run) !== resent) return [];
2815
2880
  return run;
2816
2881
  }
2817
2882
  /**
@@ -2832,9 +2897,10 @@ var OfflineManager = class {
2832
2897
  */
2833
2898
  async replayRun(run, ticket) {
2834
2899
  const inner = this.innerFor(run[0].collection);
2835
- const idempotencyKey = `${run[0].mutationId}..${run[run.length - 1].mutationId}:${run.length}`;
2900
+ const idempotencyKey = runKey(run);
2836
2901
  for (const member of run) this.inFlight.add(member.mutationId);
2837
2902
  try {
2903
+ for (const member of run) await this.markCreateSent(member, ticket, idempotencyKey);
2838
2904
  let rows;
2839
2905
  try {
2840
2906
  rows = await inner.createMany(run.map((member) => member.data), { idempotencyKey });
@@ -2856,6 +2922,7 @@ var OfflineManager = class {
2856
2922
  async replay(op, ticket) {
2857
2923
  const inner = this.innerFor(op.collection);
2858
2924
  if (op.type === "create") {
2925
+ await this.markCreateSent(op, ticket);
2859
2926
  let row;
2860
2927
  try {
2861
2928
  row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
@@ -2908,11 +2975,30 @@ var OfflineManager = class {
2908
2975
  }
2909
2976
  for (const id of ids) this.removeLocalRow(op.collection, id, ticket, true);
2910
2977
  } else if (op.type === "delete") {
2911
- await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
2978
+ await inner.delete(op.id, {
2979
+ idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId,
2980
+ ...op.ifMatch ? { ifMatch: op.ifMatch } : {}
2981
+ });
2912
2982
  this.removeLocalRow(op.collection, op.id, ticket, true);
2913
2983
  }
2914
2984
  }
2915
2985
  /**
2986
+ * Record a queued create as sent, with the request about to go out, and
2987
+ * persist it — before the request, so a lost answer cannot leave it
2988
+ * unmarked. One already marked (sent online before it was queued, or by
2989
+ * an earlier replay) keeps what it holds: that is the request the server
2990
+ * may have stored.
2991
+ */
2992
+ async markCreateSent(op, ticket, run) {
2993
+ if (op.sent) return;
2994
+ op.sent = {
2995
+ idempotencyKey: op.mutationId,
2996
+ data: { ...op.data },
2997
+ ...run ? { run } : {}
2998
+ };
2999
+ await this.persistMutation(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
3000
+ }
3001
+ /**
2916
3002
  * Send a queued write under its key.
2917
3003
  *
2918
3004
  * A write the queue sent once already, before it was queued, carries that
@@ -3304,4 +3390,4 @@ var OfflineManager = class {
3304
3390
  //#endregion
3305
3391
  export { OfflineManager };
3306
3392
 
3307
- //# sourceMappingURL=offline-BrqCRgih.js.map
3393
+ //# sourceMappingURL=offline-AvuEcg7u.js.map