@usearete/sdk 0.20.4 → 0.22.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.
package/README.md CHANGED
@@ -432,20 +432,43 @@ It returns `{ success: true, data } | { success: false, error }` and never throw
432
432
 
433
433
  ```ts
434
434
  type Update<T> =
435
- | { type: 'upsert'; key: string; data: T }
436
- | { type: 'patch'; key: string; data: Partial<T> }
437
- | { type: 'remove'; key: string }
438
- | { type: 'delete'; key: string };
435
+ | { type: 'upsert'; key: string; data: T; cursor?: string }
436
+ | { type: 'patch'; key: string; data: Partial<T>; cursor?: string }
437
+ | { type: 'remove'; key: string; cursor?: string }
438
+ | { type: 'delete'; key: string; cursor?: string };
439
439
 
440
440
  type RichUpdate<T> =
441
- | { type: 'created'; key: string; data: T }
442
- | { type: 'updated'; key: string; before: T; after: T; patch?: unknown }
443
- | { type: 'removed'; key: string; lastKnown?: T }
444
- | { type: 'deleted'; key: string; lastKnown?: T };
441
+ | { type: 'created'; key: string; data: T; cursor?: string }
442
+ | { type: 'updated'; key: string; before: T; after: T; patch?: unknown; cursor?: string }
443
+ | { type: 'removed'; key: string; lastKnown?: T; cursor?: string }
444
+ | { type: 'deleted'; key: string; lastKnown?: T; cursor?: string };
445
445
  ```
446
446
 
447
447
  `remove` means an entity left only this query's filter or window. `delete` means the source entity was deleted and is removed from every query for that view.
448
448
 
449
+ ## Replay cursors
450
+
451
+ Updates from an append view backed by the server's journal carry `cursor`, the
452
+ `{epoch}:{offset}` position of the event. Store it with the data you derive
453
+ from that update and pass it back as `after` to resume exactly where you
454
+ stopped — `after` is exclusive, and it is never a `_seq` value. State and list
455
+ views project membership rather than a tape, so their updates have no cursor.
456
+
457
+ ```ts
458
+ for await (const update of session.stacks.myStack.views.Trade.list.watch()) {
459
+ await db.apply(update, update.cursor); // one transaction: data + position
460
+ }
461
+ ```
462
+
463
+ Reconnects resume from the last cursor delivered on each subscription. If a
464
+ record is lost locally the stream ends with a `StreamGapError` carrying the
465
+ last cursor delivered before the loss, rather than skipping records silently.
466
+ Server-side refusals (`cursor-expired`, `cursor-epoch-changed`,
467
+ `cursor-unknown`, `invalid-cursor`, `replay-gap`, `replay-lagged`) end the
468
+ stream with their wire code; `isReplayErrorCode` identifies them, and the
469
+ failing frame — including `replayWindow` and `recoverFrom` — is on the error's
470
+ `details`.
471
+
449
472
  ## License
450
473
 
451
474
  MIT
package/dist/index.cjs CHANGED
@@ -10,6 +10,17 @@ const DEFAULT_CONFIG = {
10
10
  maxReconnectAttempts: 5,
11
11
  maxEntriesPerView: DEFAULT_MAX_ENTRIES_PER_VIEW,
12
12
  };
13
+ const REPLAY_ERROR_CODES = {
14
+ 'cursor-expired': true,
15
+ 'cursor-epoch-changed': true,
16
+ 'cursor-unknown': true,
17
+ 'invalid-cursor': true,
18
+ 'replay-gap': true,
19
+ 'replay-lagged': true,
20
+ };
21
+ function isReplayErrorCode(code) {
22
+ return REPLAY_ERROR_CODES[code] === true;
23
+ }
13
24
  /**
14
25
  * Determines if the error indicates the client should fetch a new token
15
26
  */
@@ -32,7 +43,9 @@ class AreteError extends Error {
32
43
  }
33
44
  }
34
45
  /**
35
- * Parse a kebab-case error code string (from X-Error-Code header) to AuthErrorCode
46
+ * Parse a kebab-case error code string (from X-Error-Code header) to AuthErrorCode.
47
+ * Replay refusals keep their wire code: each needs a different recovery, so
48
+ * collapsing them into a generic failure would destroy the only distinction.
36
49
  */
37
50
  function parseErrorCode(errorCode) {
38
51
  const codeMap = {
@@ -65,7 +78,10 @@ function parseErrorCode(errorCode) {
65
78
  'deployment-access-denied': 'DEPLOYMENT_ACCESS_DENIED',
66
79
  'quota-exceeded': 'QUOTA_EXCEEDED',
67
80
  };
68
- return codeMap[errorCode.toLowerCase()] || 'INTERNAL_ERROR';
81
+ const normalized = errorCode.toLowerCase();
82
+ if (isReplayErrorCode(normalized))
83
+ return normalized;
84
+ return codeMap[normalized] || 'INTERNAL_ERROR';
69
85
  }
70
86
 
71
87
  const GZIP_MAGIC_0 = 0x1f;
@@ -98,6 +114,17 @@ function isSubscriptionId(value) {
98
114
  function isMode(value) {
99
115
  return typeof value === 'string' && FRAME_MODES.has(value);
100
116
  }
117
+ function isOffset(value) {
118
+ return Number.isInteger(value) && value >= 0;
119
+ }
120
+ function isReplayWindow(value) {
121
+ return isRecord(value)
122
+ && typeof value['epoch'] === 'string'
123
+ && value['epoch'].length > 0
124
+ && isOffset(value['earliest'])
125
+ && isOffset(value['next'])
126
+ && (value['gapAfter'] === undefined || isOffset(value['gapAfter']));
127
+ }
101
128
  function isSort(value) {
102
129
  if (!isRecord(value))
103
130
  return false;
@@ -172,7 +199,9 @@ function isValidFrame(frame) {
172
199
  && typeof frame['fatal'] === 'boolean'
173
200
  && (frame['message'] === undefined || typeof frame['message'] === 'string')
174
201
  && (frame['error'] === undefined || typeof frame['error'] === 'string')
175
- && (frame['retryable'] === undefined || typeof frame['retryable'] === 'boolean');
202
+ && (frame['retryable'] === undefined || typeof frame['retryable'] === 'boolean')
203
+ && (frame['replayWindow'] === undefined || isReplayWindow(frame['replayWindow']))
204
+ && (frame['recoverFrom'] === undefined || typeof frame['recoverFrom'] === 'string');
176
205
  }
177
206
  if (!hasV2Identity(frame) || typeof frame['op'] !== 'string')
178
207
  return false;
@@ -181,7 +210,8 @@ function isValidFrame(frame) {
181
210
  if (frame['op'] === 'subscribed') {
182
211
  return isQuery(frame['query'])
183
212
  && isMode(frame['mode'])
184
- && (frame['sort'] === undefined || isSort(frame['sort']));
213
+ && (frame['sort'] === undefined || isSort(frame['sort']))
214
+ && (frame['replayWindow'] === undefined || isReplayWindow(frame['replayWindow']));
185
215
  }
186
216
  if (!isMode(frame['mode']) || typeof frame['entity'] !== 'string')
187
217
  return false;
@@ -198,10 +228,49 @@ function isValidFrame(frame) {
198
228
  && typeof frame['key'] === 'string'
199
229
  && 'data' in frame
200
230
  && (frame['seq'] === undefined || typeof frame['seq'] === 'string')
231
+ && (frame['offset'] === undefined || isOffset(frame['offset']))
201
232
  && (frame['append'] === undefined
202
233
  || (Array.isArray(frame['append'])
203
234
  && frame['append'].every((entry) => typeof entry === 'string')));
204
235
  }
236
+ /**
237
+ * Builds the `{epoch}:{offset}` cursor a consumer stores and sends back as
238
+ * `query.after`. An offset only means something inside the epoch the
239
+ * acknowledgement named, so the epoch has to be carried across frames.
240
+ */
241
+ class CursorTracker {
242
+ constructor() {
243
+ this.epochs = new Map();
244
+ this.cursors = new Map();
245
+ }
246
+ /** Records the frame and returns the cursor it carries, if any. */
247
+ observe(frame) {
248
+ if (isSubscribedFrame(frame)) {
249
+ const epoch = frame.replayWindow?.epoch;
250
+ if (epoch === undefined)
251
+ this.epochs.delete(frame.subscriptionId);
252
+ else
253
+ this.epochs.set(frame.subscriptionId, epoch);
254
+ return undefined;
255
+ }
256
+ if (!isEntityFrame(frame) || frame.offset === undefined)
257
+ return undefined;
258
+ const epoch = this.epochs.get(frame.subscriptionId);
259
+ if (epoch === undefined)
260
+ return undefined;
261
+ const cursor = `${epoch}:${frame.offset}`;
262
+ this.cursors.set(frame.subscriptionId, cursor);
263
+ return cursor;
264
+ }
265
+ /** The last cursor delivered on a subscription, for resuming after a drop. */
266
+ last(subscriptionId) {
267
+ return this.cursors.get(subscriptionId);
268
+ }
269
+ forget(subscriptionId) {
270
+ this.epochs.delete(subscriptionId);
271
+ this.cursors.delete(subscriptionId);
272
+ }
273
+ }
205
274
 
206
275
  let fallbackId = 0;
207
276
  const QUERY_FIELDS = new Set([
@@ -446,6 +515,7 @@ class SubscriptionRegistry {
446
515
  onChange: (callback) => this.queryStore.onChange(subscriptionId, callback),
447
516
  onUpdate: (callback) => this.queryStore.onUpdate(subscriptionId, callback),
448
517
  onRichUpdate: (callback) => this.queryStore.onRichUpdate(subscriptionId, callback),
518
+ getError: () => this.queryStore.getError(subscriptionId),
449
519
  refresh: () => this.refreshTracker(tracker),
450
520
  release: () => {
451
521
  if (released)
@@ -707,6 +777,12 @@ class ConnectionManager {
707
777
  this.currentState = 'disconnected';
708
778
  this.subscriptionQueue = new Map();
709
779
  this.activeSubscriptions = new Map();
780
+ /**
781
+ * Replay positions per subscription. Shared with the frame processor rather
782
+ * than duplicated: both observe the same frame stream, and this one is
783
+ * pruned on unsubscribe, so the processor gets that pruning for free.
784
+ */
785
+ this.cursors = new CursorTracker();
710
786
  this.socketGeneration = 0;
711
787
  this.pendingConnect = null;
712
788
  this.frameHandlers = new Set();
@@ -1518,6 +1594,7 @@ class ConnectionManager {
1518
1594
  }
1519
1595
  unsubscribe(subscriptionId) {
1520
1596
  this.subscriptionQueue.delete(subscriptionId);
1597
+ this.cursors.forget(subscriptionId);
1521
1598
  if (this.activeSubscriptions.has(subscriptionId)) {
1522
1599
  this.activeSubscriptions.delete(subscriptionId);
1523
1600
  if (this.ws?.readyState === WebSocket.OPEN) {
@@ -1562,9 +1639,14 @@ class ConnectionManager {
1562
1639
  }
1563
1640
  resubscribeActive() {
1564
1641
  for (const subscription of this.activeSubscriptions.values()) {
1565
- if (this.ws?.readyState === WebSocket.OPEN) {
1566
- this.ws.send(JSON.stringify(subscription));
1567
- }
1642
+ if (this.ws?.readyState !== WebSocket.OPEN)
1643
+ continue;
1644
+ // Re-sending the original `after` would replay what was already
1645
+ // delivered; sending none would skip whatever the drop cost us.
1646
+ const after = this.cursors.last(subscription.subscriptionId);
1647
+ this.ws.send(JSON.stringify(after === undefined
1648
+ ? subscription
1649
+ : { ...subscription, query: { ...subscription.query, after } }));
1568
1650
  }
1569
1651
  }
1570
1652
  addQueuedSubscription(subscription) {
@@ -1585,6 +1667,7 @@ class ConnectionManager {
1585
1667
  }
1586
1668
  }
1587
1669
  notifyFrameHandlers(frame) {
1670
+ this.cursors.observe(frame);
1588
1671
  for (const handler of this.frameHandlers) {
1589
1672
  handler(frame);
1590
1673
  }
@@ -1706,12 +1789,23 @@ function toCamelCaseSegment(value) {
1706
1789
  return pascal[0].toLowerCase() + pascal.slice(1);
1707
1790
  }
1708
1791
  class FrameProcessor {
1792
+ /**
1793
+ * Observe the connection's cursor tracker instead of a private one.
1794
+ *
1795
+ * The connection prunes on unsubscribe; this processor never sees an
1796
+ * `unsubscribed` frame, so its own tracker would grow for the life of the
1797
+ * page as a React app mounts and unmounts components with different queries.
1798
+ */
1799
+ useCursorTracker(cursors) {
1800
+ this.cursors = cursors;
1801
+ }
1709
1802
  constructor(storage, config = {}) {
1710
1803
  this.pendingUpdates = [];
1711
1804
  this.flushTimer = null;
1712
1805
  this.isProcessing = false;
1713
1806
  this.latestProcessedSlot = null;
1714
1807
  this.processedSlotWaiters = new Set();
1808
+ this.cursors = new CursorTracker();
1715
1809
  this.storage = storage;
1716
1810
  this.maxEntriesPerView = config.maxEntriesPerView === undefined
1717
1811
  ? DEFAULT_MAX_ENTRIES_PER_VIEW
@@ -2056,6 +2150,7 @@ class FrameProcessor {
2056
2150
  return null;
2057
2151
  }
2058
2152
  handleSubscribedFrame(frame) {
2153
+ this.cursors.observe(frame);
2059
2154
  const viewPath = frame.query.view;
2060
2155
  if (this.storage.setViewConfig && frame.sort) {
2061
2156
  this.storage.setViewConfig(viewPath, {
@@ -2101,7 +2196,11 @@ class FrameProcessor {
2101
2196
  const viewPath = frame.entity;
2102
2197
  const previousValue = this.storage.get(viewPath, frame.key);
2103
2198
  const previousSequence = this.getInternalSeq(previousValue);
2104
- const duplicateOrStaleSequence = frame.seq !== undefined
2199
+ const cursor = this.cursors.observe(frame);
2200
+ // On a tape the offset is the identity: two events decoded from one
2201
+ // transaction share a seq, so the seq guard would discard the second.
2202
+ const duplicateOrStaleSequence = frame.offset === undefined
2203
+ && frame.seq !== undefined
2105
2204
  && previousSequence !== undefined
2106
2205
  && this.compareSeq(frame.seq, previousSequence) <= 0;
2107
2206
  switch (frame.op) {
@@ -2138,9 +2237,10 @@ class FrameProcessor {
2138
2237
  type: 'upsert',
2139
2238
  key: frame.key,
2140
2239
  data: nextValue,
2240
+ cursor,
2141
2241
  };
2142
2242
  this.storage.notifyUpdate(viewPath, frame.key, update);
2143
- const richUpdate = this.createRichUpdate(frame.key, previousValue, nextValue);
2243
+ const richUpdate = this.createRichUpdate(frame.key, previousValue, nextValue, undefined, cursor);
2144
2244
  this.storage.notifyRichUpdate(viewPath, frame.key, richUpdate);
2145
2245
  this.queryStore?.applyLive(frame.subscriptionId, frame.key, update, richUpdate, frame.seq ?? this.extractSeq(frame.data));
2146
2246
  break;
@@ -2176,27 +2276,34 @@ class FrameProcessor {
2176
2276
  type: 'patch',
2177
2277
  key: frame.key,
2178
2278
  data: normalizedPatch,
2279
+ cursor,
2179
2280
  };
2180
2281
  this.storage.notifyUpdate(viewPath, frame.key, update);
2181
- const richUpdate = this.createRichUpdate(frame.key, previousValue, nextValue, normalizedPatch);
2282
+ const richUpdate = this.createRichUpdate(frame.key, previousValue, nextValue, normalizedPatch, cursor);
2182
2283
  this.storage.notifyRichUpdate(viewPath, frame.key, richUpdate);
2183
2284
  this.queryStore?.applyLive(frame.subscriptionId, frame.key, update, richUpdate, frame.seq ?? this.extractSeq(frame.data));
2184
2285
  break;
2185
2286
  }
2186
2287
  case 'remove':
2187
- this.queryStore?.applyLive(frame.subscriptionId, frame.key, { type: 'remove', key: frame.key });
2288
+ this.queryStore?.applyLive(frame.subscriptionId, frame.key, { type: 'remove', key: frame.key, cursor });
2188
2289
  break;
2189
2290
  case 'delete':
2190
2291
  this.storage.delete(viewPath, frame.key);
2191
2292
  this.storage.notifyUpdate(viewPath, frame.key, {
2192
2293
  type: 'delete',
2193
2294
  key: frame.key,
2295
+ cursor,
2194
2296
  });
2195
2297
  if (previousValue !== null) {
2196
- const richUpdate = { type: 'deleted', key: frame.key, lastKnown: previousValue };
2298
+ const richUpdate = {
2299
+ type: 'deleted',
2300
+ key: frame.key,
2301
+ lastKnown: previousValue,
2302
+ cursor,
2303
+ };
2197
2304
  this.storage.notifyRichUpdate(viewPath, frame.key, richUpdate);
2198
2305
  }
2199
- this.queryStore?.deleteGlobal(viewPath, frame.key, previousValue ?? undefined);
2306
+ this.queryStore?.deleteGlobal(viewPath, frame.key, previousValue ?? undefined, frame.subscriptionId, cursor);
2200
2307
  break;
2201
2308
  }
2202
2309
  }
@@ -2206,10 +2313,10 @@ class FrameProcessor {
2206
2313
  : { type: 'updated', key, before, after, patch };
2207
2314
  this.storage.notifyRichUpdate(viewPath, key, richUpdate);
2208
2315
  }
2209
- createRichUpdate(key, before, after, patch) {
2316
+ createRichUpdate(key, before, after, patch, cursor) {
2210
2317
  return before === null
2211
- ? { type: 'created', key, data: after }
2212
- : { type: 'updated', key, before, after, patch };
2318
+ ? { type: 'created', key, data: after, cursor }
2319
+ : { type: 'updated', key, before, after, patch, cursor };
2213
2320
  }
2214
2321
  enforceMaxEntries(viewPath) {
2215
2322
  if (this.maxEntriesPerView === null)
@@ -2647,6 +2754,10 @@ class QueryStore {
2647
2754
  getQueryKey(subscriptionId) {
2648
2755
  return this.records.get(subscriptionId)?.queryKey;
2649
2756
  }
2757
+ /** The current failure without rebuilding the snapshot's data. */
2758
+ getError(subscriptionId) {
2759
+ return this.records.get(subscriptionId)?.error;
2760
+ }
2650
2761
  getSnapshot(subscriptionId) {
2651
2762
  const record = this.records.get(subscriptionId);
2652
2763
  if (!record)
@@ -2665,6 +2776,7 @@ class QueryStore {
2665
2776
  data,
2666
2777
  isLoading: record.isLoading,
2667
2778
  isRefreshing: record.isRefreshing,
2779
+ ...(record.cursor ? { cursor: record.cursor } : {}),
2668
2780
  ...(record.error ? { error: record.error } : {}),
2669
2781
  };
2670
2782
  record.cachedSnapshot = snapshot;
@@ -2760,13 +2872,15 @@ class QueryStore {
2760
2872
  const record = this.records.get(subscriptionId);
2761
2873
  if (!record)
2762
2874
  return;
2875
+ if (update.cursor !== undefined)
2876
+ record.cursor = update.cursor;
2763
2877
  if (update.type === 'remove') {
2764
2878
  const lastKnown = this.storage.get(record.subscription.query.view, key) ?? undefined;
2765
2879
  record.keys = record.keys.filter((entry) => entry !== key);
2766
2880
  record.sequences.delete(key);
2767
2881
  this.touch(record);
2768
2882
  this.emitUpdate(record, update);
2769
- this.emitRichUpdate(record, { type: 'removed', key, lastKnown });
2883
+ this.emitRichUpdate(record, { type: 'removed', key, lastKnown, cursor: update.cursor });
2770
2884
  return;
2771
2885
  }
2772
2886
  if (!record.keys.includes(key))
@@ -2785,15 +2899,22 @@ class QueryStore {
2785
2899
  if (richUpdate)
2786
2900
  this.emitRichUpdate(record, richUpdate);
2787
2901
  }
2788
- deleteGlobal(view, key, lastKnown) {
2902
+ deleteGlobal(view, key, lastKnown, subscriptionId, cursor) {
2789
2903
  for (const record of this.records.values()) {
2790
2904
  if (record.subscription.query.view !== view || !record.keys.includes(key))
2791
2905
  continue;
2792
2906
  record.keys = record.keys.filter((entry) => entry !== key);
2793
2907
  record.sequences.delete(key);
2908
+ // A delete fans out to every subscription on the view, but only the one
2909
+ // the frame named read it at this offset. Handing the position to the
2910
+ // others would let a consumer still replaying checkpoint past events it
2911
+ // has not been given.
2912
+ const owned = cursor !== undefined && record.subscription.subscriptionId === subscriptionId;
2913
+ if (owned)
2914
+ record.cursor = cursor;
2794
2915
  this.touch(record);
2795
- this.emitUpdate(record, { type: 'delete', key });
2796
- this.emitRichUpdate(record, { type: 'deleted', key, lastKnown });
2916
+ this.emitUpdate(record, owned ? { type: 'delete', key, cursor } : { type: 'delete', key });
2917
+ this.emitRichUpdate(record, owned ? { type: 'deleted', key, lastKnown, cursor } : { type: 'deleted', key, lastKnown });
2797
2918
  }
2798
2919
  }
2799
2920
  evict(view, key) {
@@ -2934,43 +3055,112 @@ class QueryStore {
2934
3055
  }
2935
3056
 
2936
3057
  const MAX_QUEUE_SIZE = 1000;
2937
- function enqueue(queue, value) {
2938
- if (queue.length >= MAX_QUEUE_SIZE)
2939
- queue.shift();
2940
- queue.push(value);
3058
+ /**
3059
+ * A replayable record was dropped locally before the consumer read it.
3060
+ * `cursor` is the last one delivered, so the stream can be resumed from it
3061
+ * with `after` rather than silently skipping the lost records.
3062
+ */
3063
+ class StreamGapError extends AreteError {
3064
+ constructor(cursor) {
3065
+ super(`Update stream dropped a replayable record; resume after ${cursor ?? 'the start of the window'}`, 'stream-gap', { cursor });
3066
+ this.cursor = cursor;
3067
+ this.name = 'StreamGapError';
3068
+ }
3069
+ }
3070
+ /** Terminate the stream when a replay refusal makes further delivery impossible. */
3071
+ function watchRefusals(lease, fail) {
3072
+ return lease.onChange(() => {
3073
+ const error = lease.getError();
3074
+ if (error && isReplayErrorCode(String(error.code)))
3075
+ fail(error);
3076
+ });
2941
3077
  }
2942
3078
  function createIterator(start) {
2943
3079
  const queue = [];
2944
- let waitingResolve = null;
3080
+ let waiting = null;
2945
3081
  let done = false;
2946
- const push = (value) => {
2947
- if (waitingResolve) {
2948
- const resolve = waitingResolve;
2949
- waitingResolve = null;
2950
- resolve({ value, done: false });
2951
- }
2952
- else {
2953
- enqueue(queue, value);
2954
- }
2955
- };
2956
- const stop = start(push);
3082
+ let failure = null;
3083
+ let lastCursor;
3084
+ let stop = () => { };
2957
3085
  const cleanup = () => {
2958
3086
  if (done)
2959
3087
  return;
2960
3088
  done = true;
2961
3089
  stop();
2962
- waitingResolve?.({ value: undefined, done: true });
2963
- waitingResolve = null;
3090
+ waiting?.resolve({ value: undefined, done: true });
3091
+ waiting = null;
3092
+ };
3093
+ const deliver = (item) => {
3094
+ if (item.cursor !== undefined)
3095
+ lastCursor = item.cursor;
3096
+ return { value: item.value, done: false };
3097
+ };
3098
+ /**
3099
+ * End the stream at `error`, after whatever is already queued.
3100
+ *
3101
+ * `discardQueued` is for a local overflow only: there the hole is at the
3102
+ * front of the queue, so everything behind it is no longer contiguous with
3103
+ * what the consumer has read. A server refusal is the opposite — it reports
3104
+ * records skipped *after* everything already sent, so the queue is real data
3105
+ * that arrived before the gap. Dropping it would lose records the consumer
3106
+ * was never told about and leave `recoverFrom` pointing past them.
3107
+ */
3108
+ const fail = (error, discardQueued = false) => {
3109
+ if (done || failure)
3110
+ return;
3111
+ failure = error;
3112
+ if (discardQueued)
3113
+ queue.length = 0;
3114
+ const pending = waiting;
3115
+ waiting = null;
3116
+ // A waiter means the queue was empty, so there is nothing to drain first.
3117
+ if (pending && queue.length === 0) {
3118
+ failure = null;
3119
+ cleanup();
3120
+ pending.reject(error);
3121
+ return;
3122
+ }
3123
+ if (pending) {
3124
+ const item = queue.shift();
3125
+ pending.resolve(deliver(item));
3126
+ }
2964
3127
  };
3128
+ const push = (value, cursor) => {
3129
+ if (done || failure)
3130
+ return;
3131
+ if (waiting) {
3132
+ const pending = waiting;
3133
+ waiting = null;
3134
+ pending.resolve(deliver({ value, cursor }));
3135
+ return;
3136
+ }
3137
+ if (queue.length >= MAX_QUEUE_SIZE) {
3138
+ const dropped = queue.shift();
3139
+ if (dropped?.cursor !== undefined) {
3140
+ fail(new StreamGapError(lastCursor), true);
3141
+ return;
3142
+ }
3143
+ }
3144
+ queue.push({ value, cursor });
3145
+ };
3146
+ stop = start(push, fail);
3147
+ if (done)
3148
+ stop();
2965
3149
  return {
2966
3150
  async next() {
3151
+ const item = queue.shift();
3152
+ if (item !== undefined)
3153
+ return deliver(item);
3154
+ if (failure) {
3155
+ const error = failure;
3156
+ failure = null;
3157
+ cleanup();
3158
+ throw error;
3159
+ }
2967
3160
  if (done)
2968
3161
  return { value: undefined, done: true };
2969
- const value = queue.shift();
2970
- if (value !== undefined)
2971
- return { value, done: false };
2972
- return new Promise((resolve) => {
2973
- waitingResolve = resolve;
3162
+ return new Promise((resolve, reject) => {
3163
+ waiting = { resolve, reject };
2974
3164
  });
2975
3165
  },
2976
3166
  async return() {
@@ -2989,14 +3179,16 @@ function acquire(registry, query, snapshotEnabled) {
2989
3179
  function createUpdateStream(_storage, subscriptionRegistry, query, keyFilter, snapshotEnabled = true) {
2990
3180
  return {
2991
3181
  [Symbol.asyncIterator]() {
2992
- return createIterator((push) => {
3182
+ return createIterator((push, fail) => {
2993
3183
  const lease = acquire(subscriptionRegistry, query, snapshotEnabled);
2994
3184
  const unsubscribe = lease.onUpdate((update) => {
2995
3185
  if (keyFilter === undefined || update.key === keyFilter)
2996
- push(update);
3186
+ push(update, update.cursor);
2997
3187
  });
3188
+ const stopRefusals = watchRefusals(lease, fail);
2998
3189
  return () => {
2999
3190
  unsubscribe();
3191
+ stopRefusals();
3000
3192
  lease.release();
3001
3193
  };
3002
3194
  });
@@ -3007,16 +3199,16 @@ function createEntityStream(_storage, subscriptionRegistry, query, options, keyF
3007
3199
  const schema = options?.schema;
3008
3200
  return {
3009
3201
  [Symbol.asyncIterator]() {
3010
- return createIterator((push) => {
3202
+ return createIterator((push, fail) => {
3011
3203
  const lease = acquire(subscriptionRegistry, query, options?.withSnapshot ?? true);
3012
- const emit = (entity) => {
3204
+ const emit = (entity, cursor) => {
3013
3205
  if (!schema) {
3014
- push(entity);
3206
+ push(entity, cursor);
3015
3207
  return;
3016
3208
  }
3017
3209
  const parsed = schema.safeParse(entity);
3018
3210
  if (parsed.success)
3019
- push(parsed.data);
3211
+ push(parsed.data, cursor);
3020
3212
  };
3021
3213
  for (const [index, entity] of lease.getSnapshot().data.entries()) {
3022
3214
  const key = lease.getSnapshot().keys[index];
@@ -3027,12 +3219,14 @@ function createEntityStream(_storage, subscriptionRegistry, query, options, keyF
3027
3219
  if (keyFilter !== undefined && update.key !== keyFilter)
3028
3220
  return;
3029
3221
  if (update.type === 'created')
3030
- emit(update.data);
3222
+ emit(update.data, update.cursor);
3031
3223
  if (update.type === 'updated')
3032
- emit(update.after);
3224
+ emit(update.after, update.cursor);
3033
3225
  });
3226
+ const stopRefusals = watchRefusals(lease, fail);
3034
3227
  return () => {
3035
3228
  unsubscribe();
3229
+ stopRefusals();
3036
3230
  lease.release();
3037
3231
  };
3038
3232
  });
@@ -3042,14 +3236,16 @@ function createEntityStream(_storage, subscriptionRegistry, query, options, keyF
3042
3236
  function createRichUpdateStream(_storage, subscriptionRegistry, query, keyFilter, snapshotEnabled = true) {
3043
3237
  return {
3044
3238
  [Symbol.asyncIterator]() {
3045
- return createIterator((push) => {
3239
+ return createIterator((push, fail) => {
3046
3240
  const lease = acquire(subscriptionRegistry, query, snapshotEnabled);
3047
3241
  const unsubscribe = lease.onRichUpdate((update) => {
3048
3242
  if (keyFilter === undefined || update.key === keyFilter)
3049
- push(update);
3243
+ push(update, update.cursor);
3050
3244
  });
3245
+ const stopRefusals = watchRefusals(lease, fail);
3051
3246
  return () => {
3052
3247
  unsubscribe();
3248
+ stopRefusals();
3053
3249
  lease.release();
3054
3250
  };
3055
3251
  });
@@ -6837,6 +7033,7 @@ let Arete$1 = class Arete {
6837
7033
  auth: options.auth,
6838
7034
  fetch: this.fetchImpl,
6839
7035
  });
7036
+ this.processor.useCursorTracker(this.connection.cursors);
6840
7037
  this.subscriptionRegistry = new SubscriptionRegistry(this.connection, this.queryStore);
6841
7038
  this.connection.onFrame((frame) => {
6842
7039
  this.processor.handleFrame(frame);
@@ -7747,6 +7944,7 @@ exports.ReadRequestError = ReadRequestError;
7747
7944
  exports.SPL_TOKEN_PROGRAM_ADDRESS = SPL_TOKEN_PROGRAM_ADDRESS;
7748
7945
  exports.STACK_RUNTIME_EXTENSIONS = STACK_RUNTIME_EXTENSIONS;
7749
7946
  exports.SYSTEM_PROGRAM_ADDRESS = SYSTEM_PROGRAM_ADDRESS;
7947
+ exports.StreamGapError = StreamGapError;
7750
7948
  exports.SubscriptionRegistry = SubscriptionRegistry;
7751
7949
  exports.TOKEN_2022_PROGRAM_ADDRESS = TOKEN_2022_PROGRAM_ADDRESS;
7752
7950
  exports.TRANSACTION_RESOURCE_OPTION_KEYS = TRANSACTION_RESOURCE_OPTION_KEYS;
@@ -7813,6 +8011,7 @@ exports.instructionOperation = instructionOperation;
7813
8011
  exports.isEntityFrame = isEntityFrame;
7814
8012
  exports.isErrorFrame = isErrorFrame;
7815
8013
  exports.isHostedAreteEndpoint = isHostedAreteEndpoint;
8014
+ exports.isReplayErrorCode = isReplayErrorCode;
7816
8015
  exports.isSnapshotFrame = isSnapshotFrame;
7817
8016
  exports.isSubscribedFrame = isSubscribedFrame;
7818
8017
  exports.isUnsubscribedFrame = isUnsubscribedFrame;