@absolutejs/sync 1.17.0 → 1.18.1

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,63 @@ 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);
1759
1812
  }
1760
1813
  };
1814
+ const normalizeSince = (since) => {
1815
+ if (typeof since === "number") {
1816
+ return { [instanceId]: since };
1817
+ }
1818
+ return decodeCursor(since);
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
+ for (const [origin, lastSeen] of Object.entries(sinceVec)) {
1836
+ if (origin === instanceId) {
1837
+ if (lastSeen >= version)
1838
+ continue;
1839
+ const oldestLocal = oldestPerOrigin.get(instanceId);
1840
+ if (oldestLocal === undefined || oldestLocal > lastSeen + 1)
1841
+ return false;
1842
+ } else {
1843
+ const oldestPeer = oldestPerOrigin.get(origin);
1844
+ if (oldestPeer === undefined) {
1845
+ if (lastSeen > 0)
1846
+ return false;
1847
+ } else if (oldestPeer > lastSeen + 1) {
1848
+ return false;
1849
+ }
1850
+ }
1767
1851
  }
1768
- const oldest = changeLog[0];
1769
- return oldest !== undefined && oldest.version <= since + 1;
1852
+ return true;
1770
1853
  };
1771
1854
  const buildCatchup = (since, tables, key, match) => {
1855
+ const sinceVec = normalizeSince(since) ?? {};
1772
1856
  const latest = new Map;
1773
1857
  for (const entry of changeLog) {
1774
- if (entry.version <= since || !tables.includes(entry.table)) {
1858
+ if (!tables.includes(entry.table))
1859
+ continue;
1860
+ const lastSeen = sinceVec[entry.origin];
1861
+ if (lastSeen !== undefined && entry.originVersion <= lastSeen)
1775
1862
  continue;
1776
- }
1777
1863
  const row = entry.change.row;
1778
1864
  const present = entry.change.op !== "delete" && match(row) ? "upsert" : "remove";
1779
1865
  latest.set(key(row), { op: present, row });
@@ -1818,6 +1904,7 @@ var createSyncEngine = (options = {}) => {
1818
1904
  set.add(subscription);
1819
1905
  return {
1820
1906
  initial: op.rows(),
1907
+ cursor: currentCursor(),
1821
1908
  version: atVersion,
1822
1909
  unsubscribe: () => {
1823
1910
  set.delete(subscription);
@@ -1844,6 +1931,7 @@ var createSyncEngine = (options = {}) => {
1844
1931
  set.add(subscription);
1845
1932
  return {
1846
1933
  initial,
1934
+ cursor: currentCursor(),
1847
1935
  version: atVersion,
1848
1936
  unsubscribe: () => {
1849
1937
  set.delete(subscription);
@@ -1905,6 +1993,7 @@ var createSyncEngine = (options = {}) => {
1905
1993
  reactiveSubs.add(subscription);
1906
1994
  return {
1907
1995
  initial: first.rows,
1996
+ cursor: currentCursor(),
1908
1997
  version: atVersion,
1909
1998
  unsubscribe: () => {
1910
1999
  set.delete(subscription);
@@ -1949,6 +2038,7 @@ var createSyncEngine = (options = {}) => {
1949
2038
  searchSubs.add(subscription);
1950
2039
  return {
1951
2040
  initial,
2041
+ cursor: currentCursor(),
1952
2042
  version: atVersion,
1953
2043
  unsubscribe: () => {
1954
2044
  set.delete(subscription);
@@ -2050,12 +2140,14 @@ var createSyncEngine = (options = {}) => {
2050
2140
  return wrapReturn({
2051
2141
  initial: [],
2052
2142
  catchup: buildCatchup(since, tables, key, boundMatch),
2143
+ cursor: currentCursor(),
2053
2144
  version: atVersion,
2054
2145
  unsubscribe
2055
2146
  });
2056
2147
  }
2057
2148
  return wrapReturn({
2058
2149
  initial: view.rows(),
2150
+ cursor: currentCursor(),
2059
2151
  version: atVersion,
2060
2152
  unsubscribe
2061
2153
  });
@@ -2094,7 +2186,10 @@ var createSyncEngine = (options = {}) => {
2094
2186
  if (message.origin === instanceId) {
2095
2187
  return;
2096
2188
  }
2097
- applyChangeBatch(message.changes, false);
2189
+ applyChangeBatch(message.changes, false, {
2190
+ origin: message.origin,
2191
+ originVersion: message.originVersion ?? 0
2192
+ });
2098
2193
  });
2099
2194
  clusterBus = bus;
2100
2195
  return async () => {
@@ -2891,5 +2986,5 @@ export {
2891
2986
  createPresenceHub
2892
2987
  };
2893
2988
 
2894
- //# debugId=B96A4C67BAC0D9FB64756E2164756E21
2989
+ //# debugId=8942CF1B0DE51E1664756E2164756E21
2895
2990
  //# sourceMappingURL=index.js.map