madeonsol-x402 2.2.0 → 2.4.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/dist/stream.js CHANGED
@@ -40,6 +40,9 @@ async function resolveWebSocket(override) {
40
40
  }
41
41
  const OPEN = 1;
42
42
  const HELD_LIVE_CAP = 10_000;
43
+ const DEFAULT_SUB_ID = "default";
44
+ const SUB_ID_RE = /^[A-Za-z0-9_.-]{1,64}$/;
45
+ const subIdOf = (f) => (typeof f.sub_id === "string" && f.sub_id ? f.sub_id : DEFAULT_SUB_ID);
43
46
  function isThenable(v) {
44
47
  return !!v && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
45
48
  }
@@ -80,6 +83,12 @@ export class MadeOnSolStream {
80
83
  ws = null;
81
84
  listeners = new Map();
82
85
  desired = { channels: new Set(), filters: {} };
86
+ /** Named subscriptions (Phase 2), in creation order; the default one is `desired`. */
87
+ named = new Map();
88
+ /** sub_ids of the subscribes sent on this connection whose `subscribed` ack is still due (acks arrive in order). */
89
+ ackExpect = [];
90
+ namedUnsupportedWarned = false;
91
+ listWaiters = [];
83
92
  closedByUser = false;
84
93
  stopped = false;
85
94
  attempt = 0;
@@ -200,27 +209,123 @@ export class MadeOnSolStream {
200
209
  }
201
210
  }
202
211
  }
203
- /** Subscribe to one or more channels (connects on first call). Optional server-side filters. */
204
- subscribe(channels, filters) {
205
- for (const c of channels)
206
- this.desired.channels.add(c);
207
- if (filters)
208
- this.desired.filters = { ...this.desired.filters, ...filters };
212
+ subscribe(arg, filters) {
213
+ if (Array.isArray(arg)) {
214
+ for (const c of arg)
215
+ this.desired.channels.add(c);
216
+ if (filters)
217
+ this.desired.filters = { ...this.desired.filters, ...filters };
218
+ if (this.ws && this.ws.readyState === OPEN)
219
+ this.sendSubscribe({ only: [DEFAULT_SUB_ID] });
220
+ else
221
+ void this.connect();
222
+ return this;
223
+ }
224
+ const subId = arg.subId;
225
+ if (typeof subId !== "string" || !SUB_ID_RE.test(subId))
226
+ throw new Error("subId must be 1-64 characters of A-Z a-z 0-9 _ . -");
227
+ if (subId === DEFAULT_SUB_ID)
228
+ return this.subscribe(arg.channels, arg.filters);
229
+ const entry = this.named.get(subId) ?? { channels: new Set(), filters: {} };
230
+ for (const c of arg.channels)
231
+ entry.channels.add(c);
232
+ // A named subscription's filters are REPLACED when given (the server does the same).
233
+ if (arg.filters)
234
+ entry.filters = { ...arg.filters };
235
+ this.named.set(subId, entry);
209
236
  if (this.ws && this.ws.readyState === OPEN)
210
- this.sendSubscribe();
237
+ this.sendSubscribe({ only: [subId] });
211
238
  else
212
239
  void this.connect();
213
240
  return this;
214
241
  }
215
- /** Stop receiving the given channels. */
216
- unsubscribe(channels) {
217
- for (const c of channels)
242
+ /**
243
+ * Replace the filters of a subscription (`"default"` for the plain one).
244
+ * The server acks with an `updated` frame; a refused update (for example a
245
+ * `token:prices` subscription without valid `mints`) arrives as a `warning`
246
+ * with code `invalid_filters` and the previous filters stay.
247
+ */
248
+ updateSubscription(subId, filters) {
249
+ if (subId === DEFAULT_SUB_ID)
250
+ this.desired.filters = { ...filters };
251
+ else {
252
+ const entry = this.named.get(subId);
253
+ if (!entry)
254
+ throw new Error(`unknown subscription ${subId}`);
255
+ entry.filters = { ...filters };
256
+ }
257
+ if (this.ws && this.ws.readyState === OPEN) {
258
+ this.ws.send(JSON.stringify({ type: "update", ...(subId === DEFAULT_SUB_ID ? {} : { sub_id: subId }), filters }));
259
+ }
260
+ return this;
261
+ }
262
+ unsubscribe(arg) {
263
+ if (typeof arg === "string") {
264
+ if (arg === DEFAULT_SUB_ID)
265
+ return this.unsubscribe(Array.from(this.desired.channels));
266
+ this.named.delete(arg);
267
+ this.forgetPending(arg);
268
+ if (this.ws && this.ws.readyState === OPEN)
269
+ this.ws.send(JSON.stringify({ type: "unsubscribe", sub_id: arg }));
270
+ return this;
271
+ }
272
+ for (const c of arg)
218
273
  this.desired.channels.delete(c);
219
274
  if (this.ws && this.ws.readyState === OPEN) {
220
- this.ws.send(JSON.stringify({ type: "unsubscribe", channels }));
275
+ this.ws.send(JSON.stringify({ type: "unsubscribe", channels: arg }));
221
276
  }
222
277
  return this;
223
278
  }
279
+ /** Every subscription this client asks for (local view, no round trip). */
280
+ getSubscriptions() {
281
+ const out = [];
282
+ if (this.desired.channels.size > 0)
283
+ out.push({ subId: DEFAULT_SUB_ID, channels: Array.from(this.desired.channels), filters: { ...this.desired.filters } });
284
+ for (const [subId, e] of this.named)
285
+ out.push({ subId, channels: Array.from(e.channels), filters: { ...e.filters } });
286
+ return out;
287
+ }
288
+ /**
289
+ * Ask the server what this connection holds (`list` → `subscriptions`).
290
+ * Resolves with the local view when not connected or when the server does
291
+ * not answer within `timeoutMs`.
292
+ */
293
+ listSubscriptions(timeoutMs = 5_000) {
294
+ if (!this.ws || this.ws.readyState !== OPEN)
295
+ return Promise.resolve(this.getSubscriptions());
296
+ return new Promise((resolve) => {
297
+ const w = { resolve, timer: setTimeout(() => { this.listWaiters = this.listWaiters.filter((x) => x !== w); resolve(this.getSubscriptions()); }, timeoutMs) };
298
+ this.listWaiters.push(w);
299
+ try {
300
+ this.ws.send(JSON.stringify({ type: "list" }));
301
+ }
302
+ catch { /* closing: the timer answers */ }
303
+ });
304
+ }
305
+ /**
306
+ * A pre-Phase-2 server (ignores sub_id) answers every resume with ONE
307
+ * replay for the whole connection, reported without sub_id: only "default"
308
+ * can still be awaited. Finishes the recovery at once when that one has
309
+ * already ended.
310
+ */
311
+ collapsePending(r) {
312
+ if (r.pending.size === 1 && r.pending.has(DEFAULT_SUB_ID))
313
+ return;
314
+ r.pending.clear();
315
+ if (!r.ends.has(DEFAULT_SUB_ID))
316
+ r.pending.add(DEFAULT_SUB_ID);
317
+ if (r.pending.size === 0 && r.protocol !== "detect")
318
+ this.finishRecovery(r.ends.get(DEFAULT_SUB_ID) ?? null);
319
+ }
320
+ /** A subscription removed while its replay was still awaited: stop waiting for it. */
321
+ forgetPending(subId) {
322
+ const r = this.recovery;
323
+ if (!r || !r.pending.has(subId))
324
+ return;
325
+ r.pending.delete(subId);
326
+ if (r.pending.size === 0 && r.protocol !== "detect")
327
+ this.finishRecovery(r.ends.size ? [...r.ends.values()].pop() : null);
328
+ }
224
329
  /** Open the connection (also called implicitly by subscribe). Restarts a stream that went `"fatal"`. */
225
330
  async connect() {
226
331
  if (this.connecting || (this.ws && this.ws.readyState === OPEN))
@@ -252,7 +357,8 @@ export class MadeOnSolStream {
252
357
  // The backoff attempt is NOT reset here — only a `subscribed` ack proves
253
358
  // the connection is usable (an auth/limit close follows a successful open).
254
359
  this.resetHeartbeat();
255
- if (this.desired.channels.size > 0)
360
+ this.ackExpect = [];
361
+ if (this.desired.channels.size > 0 || this.named.size > 0)
256
362
  this.sendSubscribe();
257
363
  this.emit("open", undefined);
258
364
  };
@@ -370,29 +476,64 @@ export class MadeOnSolStream {
370
476
  }
371
477
  this.emit("fatal", { code, reason });
372
478
  }
373
- sendSubscribe(resumeOverride) {
374
- const channels = Array.from(this.desired.channels);
375
- if (channels.length === 0 || !this.ws)
479
+ /** The subscribe frames for the given subscriptions (default first, then named in creation order). */
480
+ subscribeFrames(only) {
481
+ const out = [];
482
+ const want = (id) => !only || only.includes(id);
483
+ if (want(DEFAULT_SUB_ID) && this.desired.channels.size > 0) {
484
+ const msg = { type: "subscribe", channels: Array.from(this.desired.channels) };
485
+ if (Object.keys(this.desired.filters).length > 0)
486
+ msg.filters = this.desired.filters;
487
+ out.push({ subId: DEFAULT_SUB_ID, msg });
488
+ }
489
+ for (const [subId, e] of this.named) {
490
+ if (!want(subId) || e.channels.size === 0)
491
+ continue;
492
+ out.push({ subId, msg: { type: "subscribe", sub_id: subId, channels: Array.from(e.channels), filters: e.filters } });
493
+ }
494
+ return out;
495
+ }
496
+ /**
497
+ * Send the subscribe(s). On a connection's FIRST subscribe (or an explicit
498
+ * retry after a retryable gap) every subscription is sent with the SAME
499
+ * resume cursor: the server serves one replay per subscription, one after
500
+ * another, and holds live frames until the last replay_end. A later
501
+ * subscribe adds channels live (no resume).
502
+ */
503
+ sendSubscribe({ resumeOverride, only } = {}) {
504
+ if (!this.ws)
505
+ return;
506
+ const frames = this.subscribeFrames(only);
507
+ if (frames.length === 0)
376
508
  return;
377
- const msg = { type: "subscribe", channels };
378
- if (Object.keys(this.desired.filters).length > 0)
379
- msg.filters = this.desired.filters;
380
- // Only the FIRST subscribe of a connection resumes (or an explicit retry
381
- // after a retryable gap); a later subscribe adds channels live, and the
382
- // server replays only the channels named in a subscribe.
383
509
  if ((!this.firstSubscribeSent || resumeOverride) && this.cursor && !this.recovery) {
384
510
  const from = resumeOverride ?? { ...this.cursor };
385
- msg.resume = from;
511
+ const pending = new Set();
512
+ const channels = new Set();
513
+ for (const f of frames) {
514
+ f.msg.resume = from;
515
+ pending.add(f.subId);
516
+ for (const c of f.msg.channels)
517
+ channels.add(c);
518
+ }
386
519
  this.recovery = {
387
- protocol: "detect", from, channels, request: { resume: from }, acked: false, suppressAck: false,
520
+ protocol: "detect", from, channels: Array.from(channels), request: { resume: from }, acked: false, suppressAck: false,
388
521
  instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
389
- maxSeq: null, maxTs: null,
522
+ maxSeq: null, maxTs: null, pending, starts: new Map(), ends: new Map(),
390
523
  };
391
524
  }
392
525
  this.firstSubscribeSent = true;
393
- this.ws.send(JSON.stringify(msg));
526
+ for (const f of frames) {
527
+ this.ackExpect.push(f.subId);
528
+ this.ws.send(JSON.stringify(f.msg));
529
+ }
394
530
  }
395
- /** The server did not answer `resume` (older deployment): retry with the legacy fields. */
531
+ /**
532
+ * The server did not answer `resume` (older deployment): retry with the
533
+ * legacy fields. Such a server has no named subscriptions either, so the
534
+ * one legacy replay covers the union of channels and resolves every pending
535
+ * subscription at once.
536
+ */
396
537
  fallbackToLegacy() {
397
538
  const r = this.recovery;
398
539
  if (!r || r.protocol !== "detect" || !r.from || !this.ws)
@@ -402,6 +543,7 @@ export class MadeOnSolStream {
402
543
  r.timer = null;
403
544
  }
404
545
  r.protocol = "legacy";
546
+ r.pending = new Set([DEFAULT_SUB_ID]);
405
547
  r.instanceChanged = !this.serverInstance || this.serverInstance !== r.from.instance;
406
548
  // Same process → its ring still indexes our seq. Restarted → seq restarted, use time.
407
549
  const legacy = r.instanceChanged ? { replay_since_ts: r.from.ts } : { replay_since_seq: r.from.seq };
@@ -424,13 +566,22 @@ export class MadeOnSolStream {
424
566
  clearTimeout(this.retryTimer);
425
567
  this.retryTimer = null;
426
568
  }
569
+ // A `list` still awaiting its answer resolves with the local view; the
570
+ // ack order of the dead connection means nothing on the next one.
571
+ this.ackExpect = [];
572
+ const waiters = this.listWaiters;
573
+ this.listWaiters = [];
574
+ for (const w of waiters) {
575
+ clearTimeout(w.timer);
576
+ w.resolve(this.getSubscriptions());
577
+ }
427
578
  }
428
579
  /**
429
580
  * A retryable gap: ask the server again on this connection after its
430
581
  * retry_after_ms (row_cap resumes from resume_ts_hint). Bounded — the next
431
582
  * reconnect resumes anyway.
432
583
  */
433
- scheduleResumeRetry(retryAfterMs, hintTs) {
584
+ scheduleResumeRetry(retryAfterMs, hintTs, only) {
434
585
  if (this.retryTimer || !this.cursor)
435
586
  return;
436
587
  if (this.resumeRetries >= this.opts.maxResumeRetries)
@@ -440,11 +591,90 @@ export class MadeOnSolStream {
440
591
  const from = hintTs !== null && hintTs > this.cursor.ts ? { ...this.cursor, ts: hintTs } : { ...this.cursor };
441
592
  this.retryTimer = setTimeout(() => {
442
593
  this.retryTimer = null;
594
+ // Only the subscriptions whose replay was incomplete are asked again.
443
595
  if (this.ws && this.ws.readyState === OPEN && !this.recovery)
444
- this.sendSubscribe(from);
596
+ this.sendSubscribe({ resumeOverride: from, only });
445
597
  }, delay);
446
598
  }
447
- finishRecovery(end) {
599
+ /**
600
+ * One `replay_end` per subscription → one aggregate the single-replay logic
601
+ * can run on unchanged: complete only when every subscription is; the
602
+ * commit position is the SMALLEST last_seq / last_ts across them (a later
603
+ * subscription's replay covered more, but the earlier one's live frames
604
+ * from that point on are still only in the live flush); channel entries
605
+ * keyed `"<sub_id>/<channel>"` for named subscriptions; `retryable` when
606
+ * any subscription says so.
607
+ */
608
+ aggregateEnds(r, lastEnd) {
609
+ const ends = [...r.ends.entries()];
610
+ if (ends.length === 0)
611
+ return lastEnd;
612
+ if (ends.length === 1 && ends[0][0] === DEFAULT_SUB_ID)
613
+ return ends[0][1];
614
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
615
+ const agg = { ...(lastEnd ?? ends[ends.length - 1][1]) };
616
+ const channels = {};
617
+ let complete = true, retryableKnown = true, retryable = false, truncated = false;
618
+ let lastSeq = null, lastTs = null, liveFrom = null, retryAfter = null, hint = null;
619
+ let count = 0, sent = 0, matched = 0, reason = null, limits = null;
620
+ const min = (a, b) => (a === null ? b : b === null ? a : Math.min(a, b));
621
+ for (const [subId, e] of ends) {
622
+ if (e.complete === false) {
623
+ complete = false;
624
+ if (!reason && typeof e.reason === "string")
625
+ reason = e.reason;
626
+ }
627
+ if (typeof e.retryable !== "boolean")
628
+ retryableKnown = false;
629
+ else if (e.retryable)
630
+ retryable = true;
631
+ if (e.replay_truncated === true)
632
+ truncated = true;
633
+ const chs = e.channels;
634
+ if (chs && typeof chs === "object")
635
+ for (const [ch, raw] of Object.entries(chs))
636
+ channels[subId === DEFAULT_SUB_ID ? ch : `${subId}/${ch}`] = raw;
637
+ lastSeq = min(lastSeq, num(e.last_seq));
638
+ lastTs = min(lastTs, num(e.last_ts));
639
+ liveFrom = min(liveFrom, num(e.live_from_seq));
640
+ const ra = num(e.retry_after_ms);
641
+ if (ra !== null)
642
+ retryAfter = retryAfter === null ? ra : Math.max(retryAfter, ra);
643
+ hint = min(hint, num(e.resume_ts_hint));
644
+ count += num(e.count) ?? 0;
645
+ sent += num(e.sent) ?? 0;
646
+ matched += num(e.matched) ?? 0;
647
+ if (!limits && e.limits && typeof e.limits === "object")
648
+ limits = e.limits;
649
+ }
650
+ agg.complete = complete;
651
+ agg.reason = complete ? null : reason ?? "incomplete";
652
+ agg.channels = channels;
653
+ if (retryableKnown)
654
+ agg.retryable = retryable;
655
+ else
656
+ delete agg.retryable;
657
+ if (truncated)
658
+ agg.replay_truncated = true;
659
+ agg.last_seq = lastSeq;
660
+ agg.last_ts = lastTs;
661
+ agg.live_from_seq = liveFrom;
662
+ if (retryAfter !== null)
663
+ agg.retry_after_ms = retryAfter;
664
+ else
665
+ delete agg.retry_after_ms;
666
+ if (hint !== null)
667
+ agg.resume_ts_hint = hint;
668
+ else
669
+ delete agg.resume_ts_hint;
670
+ agg.count = count;
671
+ agg.sent = sent;
672
+ agg.matched = matched;
673
+ if (limits)
674
+ agg.limits = limits;
675
+ return agg;
676
+ }
677
+ finishRecovery(lastEnd) {
448
678
  const r = this.recovery;
449
679
  if (!r)
450
680
  return;
@@ -453,6 +683,10 @@ export class MadeOnSolStream {
453
683
  r.timer = null;
454
684
  }
455
685
  this.recovery = null;
686
+ const end = this.aggregateEnds(r, lastEnd);
687
+ // Subscriptions whose own replay_end was incomplete and retryable (or, on a
688
+ // server that does not say, incomplete): the automatic retry asks only for them.
689
+ const retrySubs = [...r.ends.entries()].filter(([, e]) => e.complete === false && e.retryable !== false).map(([id]) => id);
456
690
  const reasons = [];
457
691
  /** Reasons of the channels the server reported incomplete, with their retryability. */
458
692
  const channelReasons = [];
@@ -462,7 +696,7 @@ export class MadeOnSolStream {
462
696
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
463
697
  // A v1 server answers with complete/sent/matched; an older one with count only.
464
698
  const v1 = !!end && ("complete" in end || "sent" in end || "matched" in end);
465
- if (r.start?.replay_truncated === true || end?.replay_truncated === true)
699
+ if ([...r.starts.values()].some((s) => s.replay_truncated === true) || r.start?.replay_truncated === true || end?.replay_truncated === true)
466
700
  reasons.push("ring_truncated");
467
701
  if (!end)
468
702
  reasons.push("replay_timeout");
@@ -511,6 +745,8 @@ export class MadeOnSolStream {
511
745
  resumeReason: typeof end?.resume_reason === "string" ? end.resume_reason : null,
512
746
  start: r.start,
513
747
  end,
748
+ subscriptions: r.ends.size ? [...r.ends.keys()] : r.pending.size ? [...r.pending] : [DEFAULT_SUB_ID],
749
+ ends: Object.fromEntries(r.ends),
514
750
  };
515
751
  // Final vs retryable. The server says which (`retryable`): true only when an
516
752
  // incomplete channel's reason is transient (backpressure, closed,
@@ -589,7 +825,7 @@ export class MadeOnSolStream {
589
825
  else if (retryable) {
590
826
  this.unsafe = true;
591
827
  if (serverSays)
592
- this.scheduleResumeRetry(num(end?.retry_after_ms), capOnly ? num(end?.resume_ts_hint) : null);
828
+ this.scheduleResumeRetry(num(end?.retry_after_ms), capOnly ? num(end?.resume_ts_hint) : null, retrySubs.length ? retrySubs : undefined);
593
829
  }
594
830
  else {
595
831
  // strict: stop instead of skipping what cannot be recovered.
@@ -619,7 +855,7 @@ export class MadeOnSolStream {
619
855
  if (typeof msg.instance === "string")
620
856
  this.serverInstance = msg.instance;
621
857
  // Nothing to subscribe to → this frame is as far as a healthy connection gets.
622
- if (this.desired.channels.size === 0) {
858
+ if (this.desired.channels.size === 0 && this.named.size === 0) {
623
859
  this.attempt = 0;
624
860
  this.authFailures = 0;
625
861
  }
@@ -634,23 +870,47 @@ export class MadeOnSolStream {
634
870
  r.suppressAck = false;
635
871
  return;
636
872
  } // ack of our own fallback subscribe
637
- this.emit("subscribed", msg.channels);
873
+ // Acks arrive in the order the subscribes were sent: a named subscribe
874
+ // answered WITHOUT sub_id means the server ignores sub_id (older
875
+ // deployment) — every subscription then collapsed into one on the
876
+ // server. Said once, never silently.
877
+ const expected = this.ackExpect.shift() ?? DEFAULT_SUB_ID;
878
+ // The subscription this ack is about: the server's sub_id, else the
879
+ // one we sent in this position (an older server echoes none).
880
+ const ackedId = typeof msg.sub_id === "string" && msg.sub_id ? msg.sub_id : expected;
881
+ if (expected !== DEFAULT_SUB_ID && typeof msg.sub_id !== "string") {
882
+ if (!this.namedUnsupportedWarned) {
883
+ this.namedUnsupportedWarned = true;
884
+ this.emit("warning", { code: "named_subscriptions_unsupported", sub_id: expected, message: "The server ignored sub_id: it predates named subscriptions, so every subscription on this connection shares one channel set and one filter object." });
885
+ }
886
+ // Such a server runs ONE replay for the whole connection and reports
887
+ // it without sub_id ("default"): every named id must leave `pending`
888
+ // or the recovery would never finish and the cursor would freeze.
889
+ if (r)
890
+ this.collapsePending(r);
891
+ }
892
+ this.emit("subscribed", msg.channels, msg);
638
893
  if (r && r.protocol === "detect" && !r.acked) {
639
894
  r.acked = true;
640
- const echo = msg.resume;
641
- if (echo && typeof echo === "object" && echo.accepted === false) {
642
- // Refused (e.g. replay_in_progress): no replay follows, and this is
643
- // a v1 server — no waiting, no legacy fallback. The server's own
644
- // warning frame explains why. Nothing was recovered, so the
645
- // committed cursor must not move until a later recovery completes.
646
- this.dropRecovery();
647
- this.unsafe = true;
648
- }
649
- else if ("resume" in msg)
895
+ if ("resume" in msg)
650
896
  r.protocol = "resume"; // server echoed resume: it understood
651
897
  else
652
898
  r.timer = setTimeout(() => this.fallbackToLegacy(), this.opts.resumeDetectMs);
653
899
  }
900
+ const echo = msg.resume;
901
+ if (r && echo && typeof echo === "object" && echo.accepted === false) {
902
+ // Refused for THIS subscription (replay_in_progress: it already has a
903
+ // replay running or queued): no replay_end will come for it. When
904
+ // nothing at all was accepted, nothing was recovered, so the
905
+ // committed cursor must not move until a later recovery completes.
906
+ r.pending.delete(ackedId);
907
+ if (r.pending.size === 0 && r.ends.size === 0) {
908
+ this.dropRecovery();
909
+ this.unsafe = true;
910
+ }
911
+ else if (r.pending.size === 0 && r.protocol !== "detect")
912
+ this.finishRecovery([...r.ends.values()].pop() ?? null);
913
+ }
654
914
  return;
655
915
  }
656
916
  case "replay_start": {
@@ -660,7 +920,7 @@ export class MadeOnSolStream {
660
920
  r = this.recovery = {
661
921
  protocol: "resume", from: null, channels: [], request: {}, acked: true, suppressAck: false,
662
922
  instanceChanged: false, start: null, received: 0, delivered: 0, duplicates: 0, held: [], timer: null,
663
- maxSeq: null, maxTs: null,
923
+ maxSeq: null, maxTs: null, pending: new Set([subIdOf(msg)]), starts: new Map(), ends: new Map(),
664
924
  };
665
925
  }
666
926
  if (r.protocol === "detect") {
@@ -670,13 +930,48 @@ export class MadeOnSolStream {
670
930
  r.timer = null;
671
931
  }
672
932
  }
673
- r.start = msg;
933
+ r.starts.set(subIdOf(msg), msg);
934
+ if (!r.start)
935
+ r.start = msg;
936
+ return;
937
+ }
938
+ case "replay_end": {
939
+ const r = this.recovery;
940
+ if (!r)
941
+ return;
942
+ const sid = subIdOf(msg);
943
+ r.ends.set(sid, msg);
944
+ r.pending.delete(sid);
945
+ // Every subscription's replay has ended (a legacy server answers once,
946
+ // for the whole connection) → aggregate and commit.
947
+ if (r.pending.size === 0 || r.protocol === "legacy")
948
+ this.finishRecovery(msg);
674
949
  return;
675
950
  }
676
- case "replay_end":
677
- this.finishRecovery(msg);
951
+ case "updated":
952
+ this.emit("updated", msg);
953
+ return;
954
+ case "unsubscribed":
955
+ this.emit("unsubscribed", msg);
956
+ return;
957
+ case "subscriptions": {
958
+ const list = Array.isArray(msg.list)
959
+ ? msg.list.map((s) => ({
960
+ subId: typeof s.sub_id === "string" ? s.sub_id : DEFAULT_SUB_ID,
961
+ channels: (Array.isArray(s.channels) ? s.channels : []),
962
+ filters: (s.filters && typeof s.filters === "object" ? s.filters : {}),
963
+ }))
964
+ : [];
965
+ const waiters = this.listWaiters;
966
+ this.listWaiters = [];
967
+ for (const w of waiters) {
968
+ clearTimeout(w.timer);
969
+ w.resolve(list);
970
+ }
678
971
  return;
679
- case "warning":
972
+ }
973
+ case "warning": {
974
+ const sid = typeof msg.sub_id === "string" ? msg.sub_id : null;
680
975
  if (msg.code === "channels_revoked") {
681
976
  // The server dropped these (e.g. plan downgrade): stop re-subscribing them.
682
977
  const names = new Set();
@@ -692,12 +987,22 @@ export class MadeOnSolStream {
692
987
  names.add(x.channel);
693
988
  }
694
989
  }
695
- for (const c of names)
696
- this.desired.channels.delete(c);
990
+ const target = sid && sid !== DEFAULT_SUB_ID ? this.named.get(sid)?.channels : this.desired.channels;
991
+ if (target)
992
+ for (const c of names)
993
+ target.delete(c);
994
+ if (sid && sid !== DEFAULT_SUB_ID && this.named.get(sid)?.channels.size === 0)
995
+ this.named.delete(sid);
996
+ }
997
+ else if ((msg.code === "too_many_subscriptions" || msg.code === "invalid_sub_id") && sid && sid !== DEFAULT_SUB_ID) {
998
+ // The server will refuse it on every reconnect too: forget it, and do not wait for its replay.
999
+ this.named.delete(sid);
1000
+ this.forgetPending(sid);
697
1001
  }
698
1002
  // Never swallow a server warning: a rejected/revoked channel is silent.
699
1003
  this.emit("warning", msg);
700
1004
  return;
1005
+ }
701
1006
  default:
702
1007
  break;
703
1008
  }
@@ -737,7 +1042,9 @@ export class MadeOnSolStream {
737
1042
  }
738
1043
  const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : null;
739
1044
  if (id !== null && this.opts.dedupeSize > 0) {
740
- const key = `${String(msg.channel)}\u0000${id}`;
1045
+ // Dedupe is per (sub_id, channel, id): the same event delivered under two
1046
+ // named subscriptions is two legitimate deliveries.
1047
+ const key = `${subIdOf(msg)}\u0000${String(msg.channel)}\u0000${id}`;
741
1048
  if (this.seen.has(key)) {
742
1049
  this.seen.delete(key);
743
1050
  this.seen.set(key, true);