@absolutejs/sync 1.18.0 → 1.18.2

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.
@@ -51,15 +51,26 @@ export type SubscribeArgs<T, P, Ctx> = {
51
51
  params: P;
52
52
  /** Caller context (e.g. session); passed to hydrate/match/authorize. */
53
53
  ctx: Ctx;
54
- /** Receives every non-empty diff (with its version) after the initial reply. */
55
- onDiff: (diff: ViewDiff<T>, version: number) => void;
56
54
  /**
57
- * Resume from a version the client already applied. When the change log still
58
- * covers `(since, now]` for a single-table collection, the engine replies with
59
- * a catch-up diff instead of a full snapshot; otherwise it falls back to a
60
- * snapshot.
55
+ * Receives every non-empty diff (with its version) after the initial
56
+ * reply. 1.18.0+: a third optional `cursor` argument carries the
57
+ * cross-instance resume cursor as of this diff. Callers that ignore
58
+ * the 3rd arg keep working unchanged.
61
59
  */
62
- since?: number;
60
+ onDiff: (diff: ViewDiff<T>, version: number, cursor?: string) => void;
61
+ /**
62
+ * Resume from a point the client already applied. When the change log still
63
+ * covers `(since, now]` for a single-table collection, the engine replies
64
+ * with a catch-up diff instead of a full snapshot; otherwise it falls back
65
+ * to a snapshot.
66
+ *
67
+ * Accepts `number` (legacy pre-1.17 — interpreted as the version of THIS
68
+ * engine instance) or a `string` cursor (1.17.0+ — opaque vector of
69
+ * `(instanceId, version)` per origin, returned by the engine on every
70
+ * subscription/diff and round-tripped by the client unmodified). Use the
71
+ * cursor form for cross-instance resume.
72
+ */
73
+ since?: number | string;
63
74
  /**
64
75
  * Cancellation handle (1.15.0). Two effects:
65
76
  * 1. If the signal is already aborted when `subscribe` is called, the
@@ -79,8 +90,18 @@ export type Subscription<T> = {
79
90
  initial: T[];
80
91
  /** Catch-up diff when resuming via `since` (instead of `initial`). */
81
92
  catchup?: ViewDiff<T>;
82
- /** The engine version this reply brings the client up to. */
93
+ /** The engine's local version this reply brings the client up to. */
83
94
  version: number;
95
+ /**
96
+ * Opaque cross-instance resume cursor (1.17.0+). Encodes the per-origin
97
+ * vector of `(instanceId, version)` the client is now up-to-date with;
98
+ * pass it back as `SubscribeArgs.since` on reconnect. Works for resume
99
+ * against ANY instance in a cluster, not just the one that issued it —
100
+ * the receiving instance decodes the cursor, walks its log for entries
101
+ * the client hasn't seen yet, and either replies with a catch-up diff
102
+ * or a fresh snapshot.
103
+ */
104
+ cursor: string;
84
105
  /** Stop receiving diffs and release the view. */
85
106
  unsubscribe: () => void;
86
107
  };
@@ -295,6 +316,7 @@ export type SyncEngine = {
295
316
  * all sharing the same `version`.
296
317
  */
297
318
  export type LoggedChange = {
319
+ /** This engine's local monotonic version when the change was logged. */
298
320
  version: number;
299
321
  table: string;
300
322
  change: RowChange<unknown>;
@@ -305,6 +327,24 @@ export type LoggedChange = {
305
327
  * Added in 1.13.0; pre-1.13.0 consumers of `LoggedChange` ignore it.
306
328
  */
307
329
  at: number;
330
+ /**
331
+ * Instance id that originated this change. For locally-committed changes
332
+ * this is the engine's own `instanceId`; for cluster-received changes,
333
+ * the originating peer's id.
334
+ *
335
+ * Added in 1.17.0; pre-1.17 consumers ignore it.
336
+ */
337
+ origin: string;
338
+ /**
339
+ * The ORIGINATOR's local version at commit time. For locally-committed
340
+ * changes this equals `version`; for cluster-received changes, the
341
+ * peer's version. Resume cursors (1.17.0+) carry `(origin, originVersion)`
342
+ * pairs so a client's last-seen point matches against peer entries this
343
+ * engine has logged via the bus.
344
+ *
345
+ * Added in 1.17.0; pre-1.17 consumers ignore it.
346
+ */
347
+ originVersion: number;
308
348
  };
309
349
  /** Thrown by {@link SyncEngine.streamChanges} when `since` is older than the
310
350
  * oldest entry retained in the bounded change log (i.e. the consumer was
@@ -345,6 +385,16 @@ export declare class CdcConsumerSlowError extends Error {
345
385
  constructor(maxBuffer: number, lastDeliveredVersion: number);
346
386
  }
347
387
  export type SyncEngineOptions = {
388
+ /**
389
+ * Stable identifier for this engine instance. Defaults to a per-process
390
+ * random UUID. Pass a stable value (e.g. `${hostname}:${shardId}`) when
391
+ * running a fleet of engines behind a cluster bus — 1.17.0+ resume
392
+ * cursors carry the originating `instanceId`, so a client that reconnects
393
+ * to a different shard can request a catch-up against the original's
394
+ * change feed only if that instance's id matches a peer the new shard
395
+ * knows about.
396
+ */
397
+ instanceId?: string;
348
398
  /**
349
399
  * How many recent changes to retain for resumable reconnects. A client that
350
400
  * reconnects within this window gets a catch-up diff; beyond it, a fresh
package/dist/index.js CHANGED
@@ -230,12 +230,13 @@ var parseFrame = (raw, serializer) => {
230
230
  }
231
231
  const frame = value;
232
232
  if (frame.type === "subscribe") {
233
+ const since = typeof frame.since === "number" || typeof frame.since === "string" ? frame.since : undefined;
233
234
  return typeof frame.id === "string" && typeof frame.collection === "string" ? {
234
235
  type: "subscribe",
235
236
  id: frame.id,
236
237
  collection: frame.collection,
237
238
  params: frame.params,
238
- since: typeof frame.since === "number" ? frame.since : undefined
239
+ since
239
240
  } : undefined;
240
241
  }
241
242
  if (frame.type === "unsubscribe") {
@@ -287,6 +288,7 @@ var createSyncConnection = ({
287
288
  };
288
289
  let pending = [];
289
290
  let pendingVersion;
291
+ let pendingCursor;
290
292
  let flushScheduled = false;
291
293
  const flush = () => {
292
294
  if (pending.length === 0) {
@@ -294,8 +296,10 @@ var createSyncConnection = ({
294
296
  }
295
297
  const diffs = pending;
296
298
  const version = pendingVersion;
299
+ const cursor = pendingCursor;
297
300
  pending = [];
298
301
  pendingVersion = undefined;
302
+ pendingCursor = undefined;
299
303
  if (diffs.length === 1) {
300
304
  const only = diffs[0];
301
305
  send({
@@ -304,10 +308,11 @@ var createSyncConnection = ({
304
308
  added: only.added,
305
309
  removed: only.removed,
306
310
  changed: only.changed,
307
- version
311
+ version,
312
+ cursor
308
313
  });
309
314
  } else {
310
- send({ type: "frame", diffs, version });
315
+ send({ type: "frame", diffs, version, cursor });
311
316
  }
312
317
  };
313
318
  const scheduleFlush = () => {
@@ -320,12 +325,14 @@ var createSyncConnection = ({
320
325
  flush();
321
326
  });
322
327
  };
323
- const bufferDiff = (diff, diffVersion) => {
328
+ const bufferDiff = (diff, diffVersion, cursor) => {
324
329
  if (pending.length > 0 && pendingVersion !== diffVersion) {
325
330
  flush();
326
331
  }
327
332
  pending.push(diff);
328
333
  pendingVersion = diffVersion;
334
+ if (cursor !== undefined)
335
+ pendingCursor = cursor;
329
336
  scheduleFlush();
330
337
  };
331
338
  const handle = async (raw) => {
@@ -401,13 +408,13 @@ var createSyncConnection = ({
401
408
  params: frame.params,
402
409
  ctx,
403
410
  since: frame.since,
404
- onDiff: (diff, diffVersion) => {
411
+ onDiff: (diff, diffVersion, cursor) => {
405
412
  bufferDiff({
406
413
  id: frame.id,
407
414
  added: diff.added,
408
415
  removed: diff.removed,
409
416
  changed: diff.changed
410
- }, diffVersion);
417
+ }, diffVersion, cursor);
411
418
  }
412
419
  });
413
420
  subscriptions.set(frame.id, subscription);
@@ -418,14 +425,16 @@ var createSyncConnection = ({
418
425
  added: subscription.catchup.added,
419
426
  removed: subscription.catchup.removed,
420
427
  changed: subscription.catchup.changed,
421
- version: subscription.version
428
+ version: subscription.version,
429
+ cursor: subscription.cursor
422
430
  });
423
431
  } else {
424
432
  send({
425
433
  type: "snapshot",
426
434
  id: frame.id,
427
435
  rows: subscription.initial,
428
- version: subscription.version
436
+ version: subscription.version,
437
+ cursor: subscription.cursor
429
438
  });
430
439
  }
431
440
  } catch (error) {
@@ -1298,11 +1307,11 @@ var createSyncEngine = (options = {}) => {
1298
1307
  };
1299
1308
  const streamSubscribers = new Set;
1300
1309
  const runInTransaction = options.transaction;
1301
- const instanceId = globalThis.crypto?.randomUUID?.() ?? `i${Math.random()}`;
1310
+ const instanceId = options.instanceId ?? globalThis.crypto?.randomUUID?.() ?? `i${Math.random()}`;
1302
1311
  let clusterBus;
1303
- const broadcast = (changes) => {
1312
+ const broadcast = (changes, originVersion) => {
1304
1313
  if (clusterBus !== undefined && changes.length > 0) {
1305
- clusterBus.publish({ changes, origin: instanceId });
1314
+ clusterBus.publish({ changes, origin: instanceId, originVersion });
1306
1315
  }
1307
1316
  };
1308
1317
  const subsFor = (collection) => {
@@ -1679,11 +1688,44 @@ var createSyncEngine = (options = {}) => {
1679
1688
  subscriber(entry);
1680
1689
  }
1681
1690
  };
1691
+ const encodeCursor = (versions) => JSON.stringify(versions);
1692
+ const decodeCursor = (cursor) => {
1693
+ try {
1694
+ const parsed = JSON.parse(cursor);
1695
+ if (typeof parsed !== "object" || parsed === null)
1696
+ return null;
1697
+ const out = {};
1698
+ for (const [k, v] of Object.entries(parsed)) {
1699
+ if (typeof v === "number")
1700
+ out[k] = v;
1701
+ }
1702
+ return out;
1703
+ } catch {
1704
+ return null;
1705
+ }
1706
+ };
1707
+ const currentCursor = () => {
1708
+ const versions = { [instanceId]: version };
1709
+ for (let i = changeLog.length - 1;i >= 0; i--) {
1710
+ const entry = changeLog[i];
1711
+ if (versions[entry.origin] === undefined) {
1712
+ versions[entry.origin] = entry.originVersion;
1713
+ }
1714
+ }
1715
+ return encodeCursor(versions);
1716
+ };
1682
1717
  const applyChange = async (table, change, shouldBroadcast = true) => {
1683
1718
  version += 1;
1684
1719
  const changeVersion = version;
1685
1720
  const at = Date.now();
1686
- logChange(changeVersion, { version: changeVersion, table, change, at });
1721
+ logChange(changeVersion, {
1722
+ version: changeVersion,
1723
+ table,
1724
+ change,
1725
+ at,
1726
+ origin: instanceId,
1727
+ originVersion: changeVersion
1728
+ });
1687
1729
  emitActivity({
1688
1730
  type: "change",
1689
1731
  at,
@@ -1702,14 +1744,15 @@ var createSyncEngine = (options = {}) => {
1702
1744
  { table, key: changedKeyFor(table, change), row: change.row }
1703
1745
  ]));
1704
1746
  emissions.push(...searchPairs([{ table, change }]));
1747
+ const cursorForBatch = currentCursor();
1705
1748
  for (const [subscription, diff] of emissions) {
1706
- subscription.onDiff(diff, changeVersion);
1749
+ subscription.onDiff(diff, changeVersion, cursorForBatch);
1707
1750
  }
1708
1751
  if (shouldBroadcast) {
1709
- broadcast([{ table, change }]);
1752
+ broadcast([{ table, change }], changeVersion);
1710
1753
  }
1711
1754
  };
1712
- const applyChangeBatch = async (changes, shouldBroadcast = true) => {
1755
+ const applyChangeBatch = async (changes, shouldBroadcast = true, peerOrigin) => {
1713
1756
  if (changes.length === 0) {
1714
1757
  return;
1715
1758
  }
@@ -1718,8 +1761,17 @@ var createSyncEngine = (options = {}) => {
1718
1761
  const perSubscription = new Map;
1719
1762
  const reactiveChanges = [];
1720
1763
  const batchAt = Date.now();
1764
+ const batchOrigin = peerOrigin?.origin ?? instanceId;
1765
+ const batchOriginVersion = peerOrigin?.originVersion ?? batchVersion;
1721
1766
  for (const { table, change } of changes) {
1722
- logChange(batchVersion, { version: batchVersion, table, change, at: batchAt });
1767
+ logChange(batchVersion, {
1768
+ version: batchVersion,
1769
+ table,
1770
+ change,
1771
+ at: batchAt,
1772
+ origin: batchOrigin,
1773
+ originVersion: batchOriginVersion
1774
+ });
1723
1775
  emitActivity({
1724
1776
  type: "change",
1725
1777
  at: batchAt,
@@ -1751,29 +1803,70 @@ var createSyncEngine = (options = {}) => {
1751
1803
  }
1752
1804
  emissions.push(...await reactivePairs(reactiveChanges));
1753
1805
  emissions.push(...searchPairs(changes));
1806
+ const cursorForBatch = currentCursor();
1754
1807
  for (const [subscription, diff] of emissions) {
1755
- subscription.onDiff(diff, batchVersion);
1808
+ subscription.onDiff(diff, batchVersion, cursorForBatch);
1756
1809
  }
1757
1810
  if (shouldBroadcast) {
1758
- broadcast(changes);
1811
+ broadcast(changes, batchVersion);
1812
+ }
1813
+ };
1814
+ const normalizeSince = (since) => {
1815
+ if (typeof since === "number") {
1816
+ return { [instanceId]: since };
1759
1817
  }
1818
+ return decodeCursor(since);
1760
1819
  };
1761
1820
  const canResume = (since, incremental) => {
1762
1821
  if (!incremental) {
1763
1822
  return false;
1764
1823
  }
1765
- if (since >= version) {
1766
- return true;
1824
+ const sinceVec = normalizeSince(since);
1825
+ if (sinceVec === null) {
1826
+ return false;
1827
+ }
1828
+ const oldestPerOrigin = new Map;
1829
+ for (const entry of changeLog) {
1830
+ const current = oldestPerOrigin.get(entry.origin);
1831
+ if (current === undefined || entry.originVersion < current) {
1832
+ oldestPerOrigin.set(entry.origin, entry.originVersion);
1833
+ }
1834
+ }
1835
+ const oldestLogVersion = changeLog[0]?.version;
1836
+ for (const [origin, lastSeen] of Object.entries(sinceVec)) {
1837
+ if (origin === instanceId) {
1838
+ if (lastSeen >= version)
1839
+ continue;
1840
+ const oldestLocal = oldestPerOrigin.get(instanceId);
1841
+ if (oldestLocal !== undefined) {
1842
+ if (oldestLocal > lastSeen + 1)
1843
+ return false;
1844
+ continue;
1845
+ }
1846
+ if (oldestLogVersion !== undefined && oldestLogVersion > lastSeen + 1) {
1847
+ return false;
1848
+ }
1849
+ } else {
1850
+ const oldestPeer = oldestPerOrigin.get(origin);
1851
+ if (oldestPeer === undefined) {
1852
+ if (lastSeen > 0)
1853
+ return false;
1854
+ } else if (oldestPeer > lastSeen + 1) {
1855
+ return false;
1856
+ }
1857
+ }
1767
1858
  }
1768
- const oldest = changeLog[0];
1769
- return oldest !== undefined && oldest.version <= since + 1;
1859
+ return true;
1770
1860
  };
1771
1861
  const buildCatchup = (since, tables, key, match) => {
1862
+ const sinceVec = normalizeSince(since) ?? {};
1772
1863
  const latest = new Map;
1773
1864
  for (const entry of changeLog) {
1774
- if (entry.version <= since || !tables.includes(entry.table)) {
1865
+ if (!tables.includes(entry.table))
1866
+ continue;
1867
+ const lastSeen = sinceVec[entry.origin];
1868
+ if (lastSeen !== undefined && entry.originVersion <= lastSeen)
1775
1869
  continue;
1776
- }
1777
1870
  const row = entry.change.row;
1778
1871
  const present = entry.change.op !== "delete" && match(row) ? "upsert" : "remove";
1779
1872
  latest.set(key(row), { op: present, row });
@@ -1818,6 +1911,7 @@ var createSyncEngine = (options = {}) => {
1818
1911
  set.add(subscription);
1819
1912
  return {
1820
1913
  initial: op.rows(),
1914
+ cursor: currentCursor(),
1821
1915
  version: atVersion,
1822
1916
  unsubscribe: () => {
1823
1917
  set.delete(subscription);
@@ -1844,6 +1938,7 @@ var createSyncEngine = (options = {}) => {
1844
1938
  set.add(subscription);
1845
1939
  return {
1846
1940
  initial,
1941
+ cursor: currentCursor(),
1847
1942
  version: atVersion,
1848
1943
  unsubscribe: () => {
1849
1944
  set.delete(subscription);
@@ -1905,6 +2000,7 @@ var createSyncEngine = (options = {}) => {
1905
2000
  reactiveSubs.add(subscription);
1906
2001
  return {
1907
2002
  initial: first.rows,
2003
+ cursor: currentCursor(),
1908
2004
  version: atVersion,
1909
2005
  unsubscribe: () => {
1910
2006
  set.delete(subscription);
@@ -1949,6 +2045,7 @@ var createSyncEngine = (options = {}) => {
1949
2045
  searchSubs.add(subscription);
1950
2046
  return {
1951
2047
  initial,
2048
+ cursor: currentCursor(),
1952
2049
  version: atVersion,
1953
2050
  unsubscribe: () => {
1954
2051
  set.delete(subscription);
@@ -2050,12 +2147,14 @@ var createSyncEngine = (options = {}) => {
2050
2147
  return wrapReturn({
2051
2148
  initial: [],
2052
2149
  catchup: buildCatchup(since, tables, key, boundMatch),
2150
+ cursor: currentCursor(),
2053
2151
  version: atVersion,
2054
2152
  unsubscribe
2055
2153
  });
2056
2154
  }
2057
2155
  return wrapReturn({
2058
2156
  initial: view.rows(),
2157
+ cursor: currentCursor(),
2059
2158
  version: atVersion,
2060
2159
  unsubscribe
2061
2160
  });
@@ -2094,7 +2193,10 @@ var createSyncEngine = (options = {}) => {
2094
2193
  if (message.origin === instanceId) {
2095
2194
  return;
2096
2195
  }
2097
- applyChangeBatch(message.changes, false);
2196
+ applyChangeBatch(message.changes, false, {
2197
+ origin: message.origin,
2198
+ originVersion: message.originVersion ?? 0
2199
+ });
2098
2200
  });
2099
2201
  clusterBus = bus;
2100
2202
  return async () => {
@@ -2891,5 +2993,5 @@ export {
2891
2993
  createPresenceHub
2892
2994
  };
2893
2995
 
2894
- //# debugId=B96A4C67BAC0D9FB64756E2164756E21
2996
+ //# debugId=34EB2D2EE376D7EC64756E2164756E21
2895
2997
  //# sourceMappingURL=index.js.map