@usearete/sdk 0.21.0 → 0.22.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.
- package/README.md +31 -8
- package/dist/index.cjs +252 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +104 -4
- package/dist/index.esm.js +251 -54
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1566
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
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
|
|
3080
|
+
let waiting = null;
|
|
2945
3081
|
let done = false;
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
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
|
-
|
|
2963
|
-
|
|
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
|
-
|
|
2970
|
-
|
|
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;
|