footprintjs 9.4.0 → 9.5.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.
Files changed (27) hide show
  1. package/dist/esm/lib/builder/FlowChartBuilder.js +15 -2
  2. package/dist/esm/lib/capture/envelope.js +187 -0
  3. package/dist/esm/lib/engine/handlers/ContinuationResolver.js +23 -4
  4. package/dist/esm/lib/engine/types.js +1 -1
  5. package/dist/esm/lib/observer-queue/deferredDispatcher.js +226 -0
  6. package/dist/esm/lib/observer-queue/flushDriver.js +163 -0
  7. package/dist/esm/lib/observer-queue/index.js +22 -0
  8. package/dist/esm/lib/observer-queue/mergedQueue.js +91 -0
  9. package/dist/esm/lib/observer-queue/ring.js +122 -0
  10. package/dist/lib/builder/FlowChartBuilder.js +15 -2
  11. package/dist/lib/capture/envelope.js +192 -0
  12. package/dist/lib/engine/handlers/ContinuationResolver.js +23 -4
  13. package/dist/lib/engine/types.js +1 -1
  14. package/dist/lib/observer-queue/deferredDispatcher.js +230 -0
  15. package/dist/lib/observer-queue/flushDriver.js +167 -0
  16. package/dist/lib/observer-queue/index.js +36 -0
  17. package/dist/lib/observer-queue/mergedQueue.js +95 -0
  18. package/dist/lib/observer-queue/ring.js +126 -0
  19. package/dist/types/lib/capture/envelope.d.ts +169 -0
  20. package/dist/types/lib/engine/handlers/ContinuationResolver.d.ts +15 -2
  21. package/dist/types/lib/engine/types.d.ts +3 -0
  22. package/dist/types/lib/observer-queue/deferredDispatcher.d.ts +169 -0
  23. package/dist/types/lib/observer-queue/flushDriver.d.ts +124 -0
  24. package/dist/types/lib/observer-queue/index.d.ts +25 -0
  25. package/dist/types/lib/observer-queue/mergedQueue.d.ts +85 -0
  26. package/dist/types/lib/observer-queue/ring.d.ts +99 -0
  27. package/package.json +1 -1
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ /**
3
+ * observer-queue/ring.ts — RFC-001 Block 2: bounded ring with overflow policies.
4
+ *
5
+ * Pattern: Fixed-capacity circular buffer with explicit, COUNTED overflow
6
+ * behavior. The deferred-observer queue must never grow without
7
+ * bound (a slow consumer cannot OOM the producer), and must never
8
+ * lose an event silently (every loss increments `drops`).
9
+ * Role: Storage primitive under the merged queue (Block 3). Pure data
10
+ * structure — zero imports, zero engine knowledge, generic over T.
11
+ *
12
+ * Overflow policies (RFC-001 §5, with the accepted 'block' resolution):
13
+ * - `'drop-oldest'` — evict the oldest queued item to admit the new one.
14
+ * The evicted item is LOST (`drops`++) and returned on the push result
15
+ * so the caller can account for it. Sequence stamps on surviving items
16
+ * keep loss visible as seq gaps (honest loss accounting). DEFAULT
17
+ * posture for telemetry-grade delivery.
18
+ * - `'sample'` — while saturated, admit 1 in `sampleEvery` arrivals
19
+ * (evicting the oldest to make room — that eviction is also a counted
20
+ * loss); refuse the rest (each a counted loss). Keeps a thinned,
21
+ * still-fresh stream under sustained overload. The saturation counter
22
+ * is episode-scoped: it resets whenever a push succeeds through the
23
+ * non-full path.
24
+ * - `'block'` — the ring REFUSES the new item (`accepted: false`,
25
+ * `rejections`++) and drops NOTHING. In a single-threaded runtime a
26
+ * queue cannot literally block its producer; the dispatcher (Block 5)
27
+ * interprets a refusal as "deliver this event synchronously inline" —
28
+ * re-introducing blocking delivery by the consumer's EXPLICIT choice.
29
+ * Rejections are NOT losses: the event is still delivered (inline), so
30
+ * `drops` stays untouched.
31
+ *
32
+ * Conservation invariant (property-tested):
33
+ * pushes === delivered + drops + rejections + size
34
+ *
35
+ * CURSOR-READY (amendment A2): v1 consumes destructively through ONE cursor
36
+ * (`shift()` advances `head`). The designed v1.1 path keeps items in the
37
+ * ring and gives each listener its own read cursor; `head` then advances to
38
+ * `min(cursors)` (the reclaim watermark) instead of on read. The storage
39
+ * layout (contiguous circular window, `head` + `count`) already supports
40
+ * that — only the consumption surface changes. Documented, not implemented.
41
+ */
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.BoundedRing = void 0;
44
+ const DEFAULT_SAMPLE_EVERY = 10;
45
+ class BoundedRing {
46
+ buffer;
47
+ policy;
48
+ sampleEvery;
49
+ /** Index of the oldest queued item — the single v1 cursor (see header). */
50
+ head = 0;
51
+ count = 0;
52
+ /** Arrivals seen while saturated in the current episode (`'sample'`). */
53
+ saturatedArrivals = 0;
54
+ pushes = 0;
55
+ delivered = 0;
56
+ drops = 0;
57
+ rejections = 0;
58
+ constructor(opts) {
59
+ if (!Number.isInteger(opts.capacity) || opts.capacity <= 0) {
60
+ throw new RangeError(`BoundedRing capacity must be a positive integer (got ${opts.capacity})`);
61
+ }
62
+ const sampleEvery = opts.sampleEvery ?? DEFAULT_SAMPLE_EVERY;
63
+ if (!Number.isInteger(sampleEvery) || sampleEvery <= 0) {
64
+ throw new RangeError(`BoundedRing sampleEvery must be a positive integer (got ${opts.sampleEvery})`);
65
+ }
66
+ this.buffer = new Array(opts.capacity);
67
+ this.policy = opts.policy;
68
+ this.sampleEvery = sampleEvery;
69
+ }
70
+ get size() {
71
+ return this.count;
72
+ }
73
+ get capacity() {
74
+ return this.buffer.length;
75
+ }
76
+ /** Lifetime counters — see {@link RingCounters}. */
77
+ getCounters() {
78
+ return { pushes: this.pushes, delivered: this.delivered, drops: this.drops, rejections: this.rejections };
79
+ }
80
+ /** Admit, evict-and-admit, refuse, or sample per policy — never throws. */
81
+ push(item) {
82
+ this.pushes += 1;
83
+ if (this.count < this.buffer.length) {
84
+ this.saturatedArrivals = 0; // new saturation episode starts fresh
85
+ this.store(item);
86
+ return { accepted: true };
87
+ }
88
+ if (this.policy === 'block') {
89
+ this.rejections += 1;
90
+ return { accepted: false };
91
+ }
92
+ if (this.policy === 'sample') {
93
+ this.saturatedArrivals += 1;
94
+ if (this.saturatedArrivals % this.sampleEvery !== 0) {
95
+ this.drops += 1; // the incoming item is sampled out — lost
96
+ return { accepted: false };
97
+ }
98
+ // The 1-in-N admission falls through to evict-and-store.
99
+ }
100
+ // 'drop-oldest' (and the 'sample' admission): evict the oldest — lost.
101
+ const evicted = this.buffer[this.head];
102
+ this.buffer[this.head] = undefined;
103
+ this.head = (this.head + 1) % this.buffer.length;
104
+ this.count -= 1;
105
+ this.drops += 1;
106
+ this.store(item);
107
+ return { accepted: true, evicted };
108
+ }
109
+ /** Pop the oldest queued item (FIFO). `undefined` when empty. */
110
+ shift() {
111
+ if (this.count === 0)
112
+ return undefined;
113
+ const item = this.buffer[this.head];
114
+ this.buffer[this.head] = undefined; // release the reference for GC
115
+ this.head = (this.head + 1) % this.buffer.length;
116
+ this.count -= 1;
117
+ this.delivered += 1;
118
+ return item;
119
+ }
120
+ store(item) {
121
+ this.buffer[(this.head + this.count) % this.buffer.length] = item;
122
+ this.count += 1;
123
+ }
124
+ }
125
+ exports.BoundedRing = BoundedRing;
126
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmluZy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9saWIvb2JzZXJ2ZXItcXVldWUvcmluZy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO0FBQUE7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7OztHQXVDRzs7O0FBeUNILE1BQU0sb0JBQW9CLEdBQUcsRUFBRSxDQUFDO0FBRWhDLE1BQWEsV0FBVztJQUNMLE1BQU0sQ0FBdUI7SUFDN0IsTUFBTSxDQUFpQjtJQUN2QixXQUFXLENBQVM7SUFDckMsMkVBQTJFO0lBQ25FLElBQUksR0FBRyxDQUFDLENBQUM7SUFDVCxLQUFLLEdBQUcsQ0FBQyxDQUFDO0lBQ2xCLHlFQUF5RTtJQUNqRSxpQkFBaUIsR0FBRyxDQUFDLENBQUM7SUFFdEIsTUFBTSxHQUFHLENBQUMsQ0FBQztJQUNYLFNBQVMsR0FBRyxDQUFDLENBQUM7SUFDZCxLQUFLLEdBQUcsQ0FBQyxDQUFDO0lBQ1YsVUFBVSxHQUFHLENBQUMsQ0FBQztJQUV2QixZQUFZLElBQWlCO1FBQzNCLElBQUksQ0FBQyxNQUFNLENBQUMsU0FBUyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsSUFBSSxJQUFJLENBQUMsUUFBUSxJQUFJLENBQUMsRUFBRSxDQUFDO1lBQzNELE1BQU0sSUFBSSxVQUFVLENBQUMsd0RBQXdELElBQUksQ0FBQyxRQUFRLEdBQUcsQ0FBQyxDQUFDO1FBQ2pHLENBQUM7UUFDRCxNQUFNLFdBQVcsR0FBRyxJQUFJLENBQUMsV0FBVyxJQUFJLG9CQUFvQixDQUFDO1FBQzdELElBQUksQ0FBQyxNQUFNLENBQUMsU0FBUyxDQUFDLFdBQVcsQ0FBQyxJQUFJLFdBQVcsSUFBSSxDQUFDLEVBQUUsQ0FBQztZQUN2RCxNQUFNLElBQUksVUFBVSxDQUFDLDJEQUEyRCxJQUFJLENBQUMsV0FBVyxHQUFHLENBQUMsQ0FBQztRQUN2RyxDQUFDO1FBQ0QsSUFBSSxDQUFDLE1BQU0sR0FBRyxJQUFJLEtB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RyxJQUFJLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQztRQUNqRCxJQUFJLENBQUMsS0FBSyxJQUFJLENBQUMsQ0FBQztRQUNoQixJQUFJLENBQUMsU0FBUyxJQUFJLENBQUMsQ0FBQztRQUNwQixPQUFPLElBQUksQ0FBQztJQUNkLENBQUM7SUFFTyxLQUFLLENBQUMsSUFBTztRQUNuQixJQUFJLENBQUMsTUFBTSxDQUFDLENBQUMsSUFBSSxDQUFDLElBQUksR0FBRyxJQUFJLENBQUMsS0FBSyxDQUFDLEdBQUcsSUFBSSxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsR0FBRyxJQUFJLENBQUM7UUFDbEUsSUFBSSxDQUFDLEtBQUssSUFBSSxDQUFDLENBQUM7SUFDbEIsQ0FBQztDQUNGO0FBMUZELGtDQTBGQyIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogb2JzZXJ2ZXItcXVldWUvcmluZy50cyDigJQgUkZDLTAwMSBCbG9jayAyOiBib3VuZGVkIHJpbmcgd2l0aCBvdmVyZmxvdyBwb2xpY2llcy5cbiAqXG4gKiBQYXR0ZXJuOiAgRml4ZWQtY2FwYWNpdHkgY2lyY3VsYXIgYnVmZmVyIHdpdGggZXhwbGljaXQsIENPVU5URUQgb3ZlcmZsb3dcbiAqICAgICAgICAgICBiZWhhdmlvci4gVGhlIGRlZmVycmVkLW9ic2VydmVyIHF1ZXVlIG11c3QgbmV2ZXIgZ3JvdyB3aXRob3V0XG4gKiAgICAgICAgICAgYm91bmQgKGEgc2xvdyBjb25zdW1lciBjYW5ub3QgT09NIHRoZSBwcm9kdWNlciksIGFuZCBtdXN0IG5ldmVyXG4gKiAgICAgICAgICAgbG9zZSBhbiBldmVudCBzaWxlbnRseSAoZXZlcnkgbG9zcyBpbmNyZW1lbnRzIGBkcm9wc2ApLlxuICogUm9sZTogICAgIFN0b3JhZ2UgcHJpbWl0aXZlIHVuZGVyIHRoZSBtZXJnZWQgcXVldWUgKEJsb2NrIDMpLiBQdXJlIGRhdGFcbiAqICAgICAgICAgICBzdHJ1Y3R1cmUg4oCUIHplcm8gaW1wb3J0cywgemVybyBlbmdpbmUga25vd2xlZGdlLCBnZW5lcmljIG92ZXIgVC5cbiAqXG4gKiBPdmVyZmxvdyBwb2xpY2llcyAoUkZDLTAwMSDCpzUsIHdpdGggdGhlIGFjY2VwdGVkICdibG9jaycgcmVzb2x1dGlvbik6XG4gKiAgIC0gYCdkcm9wLW9sZGVzdCdgIOKAlCBldmljdCB0aGUgb2xkZXN0IHF1ZXVlZCBpdGVtIHRvIGFkbWl0IHRoZSBuZXcgb25lLlxuICogICAgIFRoZSBldmljdGVkIGl0ZW0gaXMgTE9TVCAoYGRyb3BzYCsrKSBhbmQgcmV0dXJuZWQgb24gdGhlIHB1c2ggcmVzdWx0XG4gKiAgICAgc28gdGhlIGNhbGxlciBjYW4gYWNjb3VudCBmb3IgaXQuIFNlcXVlbmNlIHN0YW1wcyBvbiBzdXJ2aXZpbmcgaXRlbXNcbiAqICAgICBrZWVwIGxvc3MgdmlzaWJsZSBhcyBzZXEgZ2FwcyAoaG9uZXN0IGxvc3MgYWNjb3VudGluZykuIERFRkFVTFRcbiAqICAgICBwb3N0dXJlIGZvciB0ZWxlbWV0cnktZ3JhZGUgZGVsaXZlcnkuXG4gKiAgIC0gYCdzYW1wbGUnYCDigJQgd2hpbGUgc2F0dXJhdGVkLCBhZG1pdCAxIGluIGBzYW1wbGVFdmVyeWAgYXJyaXZhbHNcbiAqICAgICAoZXZpY3RpbmcgdGhlIG9sZGVzdCB0byBtYWtlIHJvb20g4oCUIHRoYXQgZXZpY3Rpb24gaXMgYWxzbyBhIGNvdW50ZWRcbiAqICAgICBsb3NzKTsgcmVmdXNlIHRoZSByZXN0IChlYWNoIGEgY291bnRlZCBsb3NzKS4gS2VlcHMgYSB0aGlubmVkLFxuICogICAgIHN0aWxsLWZyZXNoIHN0cmVhbSB1bmRlciBzdXN0YWluZWQgb3ZlcmxvYWQuIFRoZSBzYXR1cmF0aW9uIGNvdW50ZXJcbiAqICAgICBpcyBlcGlzb2RlLXNjb3BlZDogaXQgcmVzZXRzIHdoZW5ldmVyIGEgcHVzaCBzdWNjZWVkcyB0aHJvdWdoIHRoZVxuICogICAgIG5vbi1mdWxsIHBhdGguXG4gKiAgIC0gYCdibG9jaydgIOKAlCB0aGUgcmluZyBSRUZVU0VTIHRoZSBuZXcgaXRlbSAoYGFjY2VwdGVkOiBmYWxzZWAsXG4gKiAgICAgYHJlamVjdGlvbnNgKyspIGFuZCBkcm9wcyBOT1RISU5HLiBJbiBhIHNpbmdsZS10aHJlYWRlZCBydW50aW1lIGFcbiAqICAgICBxdWV1ZSBjYW5ub3QgbGl0ZXJhbGx5IGJsb2NrIGl0cyBwcm9kdWNlcjsgdGhlIGRpc3BhdGNoZXIgKEJsb2NrIDUpXG4gKiAgICAgaW50ZXJwcmV0cyBhIHJlZnVzYWwgYXMgXCJkZWxpdmVyIHRoaXMgZXZlbnQgc3luY2hyb25vdXNseSBpbmxpbmVcIiDigJRcbiAqICAgICByZS1pbnRyb2R1Y2luZyBibG9ja2luZyBkZWxpdmVyeSBieSB0aGUgY29uc3VtZXIncyBFWFBMSUNJVCBjaG9pY2UuXG4gKiAgICAgUmVqZWN0aW9ucyBhcmUgTk9UIGxvc3NlczogdGhlIGV2ZW50IGlzIHN0aWxsIGRlbGl2ZXJlZCAoaW5saW5lKSwgc29cbiAqICAgICBgZHJvcHNgIHN0YXlzIHVudG91Y2hlZC5cbiAqXG4gKiBDb25zZXJ2YXRpb24gaW52YXJpYW50IChwcm9wZXJ0eS10ZXN0ZWQpOlxuICogICBwdXNoZXMgPT09IGRlbGl2ZXJlZCArIGRyb3BzICsgcmVqZWN0aW9ucyArIHNpemVcbiAqXG4gKiBDVVJTT1ItUkVBRFkgKGFtZW5kbWVudCBBMik6IHYxIGNvbnN1bWVzIGRlc3RydWN0aXZlbHkgdGhyb3VnaCBPTkUgY3Vyc29yXG4gKiAoYHNoaWZ0KClgIGFkdmFuY2VzIGBoZWFkYCkuIFRoZSBkZXNpZ25lZCB2MS4xIHBhdGgga2VlcHMgaXRlbXMgaW4gdGhlXG4gKiByaW5nIGFuZCBnaXZlcyBlYWNoIGxpc3RlbmVyIGl0cyBvd24gcmVhZCBjdXJzb3I7IGBoZWFkYCB0aGVuIGFkdmFuY2VzIHRvXG4gKiBgbWluKGN1cnNvcnMpYCAodGhlIHJlY2xhaW0gd2F0ZXJtYXJrKSBpbnN0ZWFkIG9mIG9uIHJlYWQuIFRoZSBzdG9yYWdlXG4gKiBsYXlvdXQgKGNvbnRpZ3VvdXMgY2lyY3VsYXIgd2luZG93LCBgaGVhZGAgKyBgY291bnRgKSBhbHJlYWR5IHN1cHBvcnRzXG4gKiB0aGF0IOKAlCBvbmx5IHRoZSBjb25zdW1wdGlvbiBzdXJmYWNlIGNoYW5nZXMuIERvY3VtZW50ZWQsIG5vdCBpbXBsZW1lbnRlZC5cbiAqL1xuXG4vKiogSG93IHRoZSByaW5nIHRyZWF0cyBhIHB1c2ggd2hlbiBpdCBpcyBhdCBjYXBhY2l0eSAoUkZDLTAwMSDCpzUpLiAqL1xuZXhwb3J0IHR5cGUgT3ZlcmZsb3dQb2xpY3kgPSAnYmxvY2snIHwgJ2Ryb3Atb2xkZXN0JyB8ICdzYW1wbGUnO1xuXG5leHBvcnQgaW50ZXJmYWNlIFJpbmdPcHRpb25zIHtcbiAgLyoqIE1heCBxdWV1ZWQgaXRlbXMuIFBvc2l0aXZlIGludGVnZXIuICovXG4gIHJlYWRvbmx5IGNhcGFjaXR5OiBudW1iZXI7XG4gIC8qKiBPdmVyZmxvdyBiZWhhdmlvciBhdCBjYXBhY2l0eSDigJQgc2VlIHRoZSBtb2R1bGUgaGVhZGVyLiAqL1xuICByZWFkb25seSBwb2xpY3k6IE92ZXJmbG93UG9saWN5O1xuICAvKipcbiAgICogYCdzYW1wbGUnYCBvbmx5OiBhZG1pdCAxIGluIHRoaXMgbWFueSBhcnJpdmFscyB3aGlsZSBzYXR1cmF0ZWQuXG4gICAqIFBvc2l0aXZlIGludGVnZXI7IGRlZmF1bHQgMTAuXG4gICAqL1xuICByZWFkb25seSBzYW1wbGVFdmVyeT86IG51bWJlcjtcbn1cblxuLyoqIE91dGNvbWUgb2Ygb25lIHtAbGluayBCb3VuZGVkUmluZy5wdXNofS4gKi9cbmV4cG9ydCBpbnRlcmZhY2UgUmluZ1B1c2hSZXN1bHQ8VD4ge1xuICAvKiogVHJ1ZSB3aGVuIHRoZSBwdXNoZWQgaXRlbSBpcyBub3cgcXVldWVkLiAqL1xuICByZWFkb25seSBhY2NlcHRlZDogYm9vbGVhbjtcbiAgLyoqXG4gICAqIFRoZSBvbGRlc3QgaXRlbSwgd2hlbiBhZG1pdHRpbmcgdGhlIG5ldyBvbmUgZXZpY3RlZCBpdFxuICAgKiAoYCdkcm9wLW9sZGVzdCdgLCBvciBhIGAnc2FtcGxlJ2AgYWRtaXNzaW9uKS4gQWxyZWFkeSBjb3VudGVkIGluXG4gICAqIGBkcm9wc2Ag4oCUIHN1cmZhY2VkIHNvIGNhbGxlcnMgY2FuIGRvIHRoZWlyIG93biBsb3NzIGFjY291bnRpbmcuXG4gICAqL1xuICByZWFkb25seSBldmljdGVkPzogVDtcbn1cblxuLyoqIE1vbm90b25pYyBjb3VudGVycyDigJQgbmV2ZXIgcmVzZXQgZm9yIHRoZSBsaWZldGltZSBvZiB0aGUgcmluZy4gKi9cbmV4cG9ydCBpbnRlcmZhY2UgUmluZ0NvdW50ZXJzIHtcbiAgLyoqIFRvdGFsIGBwdXNoKClgIGNhbGxzLiAqL1xuICByZWFkb25seSBwdXNoZXM6IG51bWJlcjtcbiAgLyoqIGBzaGlmdCgpYCBjYWxscyB0aGF0IHJldHVybmVkIGFuIGl0ZW0uICovXG4gIHJlYWRvbmx5IGRlbGl2ZXJlZDogbnVtYmVyO1xuICAvKiogSXRlbXMgTE9TVCDigJQgZXZpY3Rpb25zIHBsdXMgc2FtcGxlZC1vdXQgcmVmdXNhbHMuIE5ldmVyIHNpbGVudC4gKi9cbiAgcmVhZG9ubHkgZHJvcHM6IG51bWJlcjtcbiAgLyoqIGAnYmxvY2snYCByZWZ1c2FscyDigJQgTk9UIGxvc3NlczsgdGhlIGNhbGxlciBkZWxpdmVycyB0aGVzZSBpbmxpbmUuICovXG4gIHJlYWRvbmx5IHJlamVjdGlvbnM6IG51bWJlcjtcbn1cblxuY29uc3QgREVGQVVMVF9TQU1QTEVfRVZFUlkgPSAxMDtcblxuZXhwb3J0IGNsYXNzIEJvdW5kZWRSaW5nPFQ+IHtcbiAgcHJpdmF0ZSByZWFkb25seSBidWZmZXI6IEFycmF5PFQgfCB1bmRlZmluZWQ+O1xuICBwcml2YXRlIHJlYWRvbmx5IHBvbGljeTogT3ZlcmZsb3dQb2xpY3k7XG4gIHByaXZhdGUgcmVhZG9ubHkgc2FtcGxlRXZlcnk6IG51bWJlcjtcbiAgLyoqIEluZGV4IG9mIHRoZSBvbGRlc3QgcXVldWVkIGl0ZW0g4oCUIHRoZSBzaW5nbGUgdjEgY3Vyc29yIChzZWUgaGVhZGVyKS4gKi9cbiAgcHJpdmF0ZSBoZWFkID0gMDtcbiAgcHJpdmF0ZSBjb3VudCA9IDA7XG4gIC8qKiBBcnJpdmFscyBzZWVuIHdoaWxlIHNhdHVyYXRlZCBpbiB0aGUgY3VycmVudCBlcGlzb2RlIChgJ3NhbXBsZSdgKS4gKi9cbiAgcHJpdmF0ZSBzYXR1cmF0ZWRBcnJpdmFscyA9IDA7XG5cbiAgcHJpdmF0ZSBwdXNoZXMgPSAwO1xuICBwcml2YXRlIGRlbGl2ZXJlZCA9IDA7XG4gIHByaXZhdGUgZHJvcHMgPSAwO1xuICBwcml2YXRlIHJlamVjdGlvbnMgPSAwO1xuXG4gIGNvbnN0cnVjdG9yKG9wdHM6IFJpbmdPcHRpb25zKSB7XG4gICAgaWYgKCFOdW1iZXIuaXNJbnRlZ2VyKG9wdHMuY2FwYWNpdHkpIHx8IG9wdHMuY2FwYWNpdHkgPD0gMCkge1xuICAgICAgdGhyb3cgbmV3IFJhbmdlRXJyb3IoYEJvdW5kZWRSaW5nIGNhcGFjaXR5IG11c3QgYmUgYSBwb3NpdGl2ZSBpbnRlZ2VyIChnb3QgJHtvcHRzLmNhcGFjaXR5fSlgKTtcbiAgICB9XG4gICAgY29uc3Qgc2FtcGxlRXZlcnkgPSBvcHRzLnNhbXBsZUV2ZXJ5ID8/IERFRkFVTFRfU0FNUExFX0VWRVJZO1xuICAgIGlmICghTnVtYmVyLmlzSW50ZWdlcihzYW1wbGVFdmVyeSkgfHwgc2FtcGxlRXZlcnkgPD0gMCkge1xuICAgICAgdGhyb3cgbmV3IFJhbmdlRXJyb3IoYEJvdW5kZWRSaW5nIHNhbXBsZUV2ZXJ5IG11c3QgYmUgYSBwb3NpdGl2ZSBpbnRlZ2VyIChnb3QgJHtvcHRzLnNhbXBsZUV2ZXJ5fSlgKTtcbiAgICB9XG4gICAgdGhpcy5idWZmZXIgPSBuZXcgQXJyYXk8VCB8IHVuZGVmaW5lZD4ob3B0cy5jYXBhY2l0eSk7XG4gICAgdGhpcy5wb2xpY3kgPSBvcHRzLnBvbGljeTtcbiAgICB0aGlzLnNhbXBsZUV2ZXJ5ID0gc2FtcGxlRXZlcnk7XG4gIH1cblxuICBnZXQgc2l6ZSgpOiBudW1iZXIge1xuICAgIHJldHVybiB0aGlzLmNvdW50O1xuICB9XG5cbiAgZ2V0IGNhcGFjaXR5KCk6IG51bWJlciB7XG4gICAgcmV0dXJuIHRoaXMuYnVmZmVyLmxlbmd0aDtcbiAgfVxuXG4gIC8qKiBMaWZldGltZSBjb3VudGVycyDigJQgc2VlIHtAbGluayBSaW5nQ291bnRlcnN9LiAqL1xuICBnZXRDb3VudGVycygpOiBSaW5nQ291bnRlcnMge1xuICAgIHJldHVybiB7IHB1c2hlczogdGhpcy5wdXNoZXMsIGRlbGl2ZXJlZDogdGhpcy5kZWxpdmVyZWQsIGRyb3BzOiB0aGlzLmRyb3BzLCByZWplY3Rpb25zOiB0aGlzLnJlamVjdGlvbnMgfTtcbiAgfVxuXG4gIC8qKiBBZG1pdCwgZXZpY3QtYW5kLWFkbWl0LCByZWZ1c2UsIG9yIHNhbXBsZSBwZXIgcG9saWN5IOKAlCBuZXZlciB0aHJvd3MuICovXG4gIHB1c2goaXRlbTogVCk6IFJpbmdQdXNoUmVzdWx0PFQ+IHtcbiAgICB0aGlzLnB1c2hlcyArPSAxO1xuXG4gICAgaWYgKHRoaXMuY291bnQgPCB0aGlzLmJ1ZmZlci5sZW5ndGgpIHtcbiAgICAgIHRoaXMuc2F0dXJhdGVkQXJyaXZhbHMgPSAwOyAvLyBuZXcgc2F0dXJhdGlvbiBlcGlzb2RlIHN0YXJ0cyBmcmVzaFxuICAgICAgdGhpcy5zdG9yZShpdGVtKTtcbiAgICAgIHJldHVybiB7IGFjY2VwdGVkOiB0cnVlIH07XG4gICAgfVxuXG4gICAgaWYgKHRoaXMucG9saWN5ID09PSAnYmxvY2snKSB7XG4gICAgICB0aGlzLnJlamVjdGlvbnMgKz0gMTtcbiAgICAgIHJldHVybiB7IGFjY2VwdGVkOiBmYWxzZSB9O1xuICAgIH1cblxuICAgIGlmICh0aGlzLnBvbGljeSA9PT0gJ3NhbXBsZScpIHtcbiAgICAgIHRoaXMuc2F0dXJhdGVkQXJyaXZhbHMgKz0gMTtcbiAgICAgIGlmICh0aGlzLnNhdHVyYXRlZEFycml2YWxzICUgdGhpcy5zYW1wbGVFdmVyeSAhPT0gMCkge1xuICAgICAgICB0aGlzLmRyb3BzICs9IDE7IC8vIHRoZSBpbmNvbWluZyBpdGVtIGlzIHNhbXBsZWQgb3V0IOKAlCBsb3N0XG4gICAgICAgIHJldHVybiB7IGFjY2VwdGVkOiBmYWxzZSB9O1xuICAgICAgfVxuICAgICAgLy8gVGhlIDEtaW4tTiBhZG1pc3Npb24gZmFsbHMgdGhyb3VnaCB0byBldmljdC1hbmQtc3RvcmUuXG4gICAgfVxuXG4gICAgLy8gJ2Ryb3Atb2xkZXN0JyAoYW5kIHRoZSAnc2FtcGxlJyBhZG1pc3Npb24pOiBldmljdCB0aGUgb2xkZXN0IOKAlCBsb3N0LlxuICAgIGNvbnN0IGV2aWN0ZWQgPSB0aGlzLmJ1ZmZlclt0aGlzLmhlYWRdIGFzIFQ7XG4gICAgdGhpcy5idWZmZXJbdGhpcy5oZWFkXSA9IHVuZGVmaW5lZDtcbiAgICB0aGlzLmhlYWQgPSAodGhpcy5oZWFkICsgMSkgJSB0aGlzLmJ1ZmZlci5sZW5ndGg7XG4gICAgdGhpcy5jb3VudCAtPSAxO1xuICAgIHRoaXMuZHJvcHMgKz0gMTtcbiAgICB0aGlzLnN0b3JlKGl0ZW0pO1xuICAgIHJldHVybiB7IGFjY2VwdGVkOiB0cnVlLCBldmljdGVkIH07XG4gIH1cblxuICAvKiogUG9wIHRoZSBvbGRlc3QgcXVldWVkIGl0ZW0gKEZJRk8pLiBgdW5kZWZpbmVkYCB3aGVuIGVtcHR5LiAqL1xuICBzaGlmdCgpOiBUIHwgdW5kZWZpbmVkIHtcbiAgICBpZiAodGhpcy5jb3VudCA9PT0gMCkgcmV0dXJuIHVuZGVmaW5lZDtcbiAgICBjb25zdCBpdGVtID0gdGhpcy5idWZmZXJbdGhpcy5oZWFkXSBhcyBUO1xuICAgIHRoaXMuYnVmZmVyW3RoaXMuaGVhZF0gPSB1bmRlZmluZWQ7IC8vIHJlbGVhc2UgdGhlIHJlZmVyZW5jZSBmb3IgR0NcbiAgICB0aGlzLmhlYWQgPSAodGhpcy5oZWFkICsgMSkgJSB0aGlzLmJ1ZmZlci5sZW5ndGg7XG4gICAgdGhpcy5jb3VudCAtPSAxO1xuICAgIHRoaXMuZGVsaXZlcmVkICs9IDE7XG4gICAgcmV0dXJuIGl0ZW07XG4gIH1cblxuICBwcml2YXRlIHN0b3JlKGl0ZW06IFQpOiB2b2lkIHtcbiAgICB0aGlzLmJ1ZmZlclsodGhpcy5oZWFkICsgdGhpcy5jb3VudCkgJSB0aGlzLmJ1ZmZlci5sZW5ndGhdID0gaXRlbTtcbiAgICB0aGlzLmNvdW50ICs9IDE7XG4gIH1cbn1cbiJdfQ==
@@ -0,0 +1,169 @@
1
+ /**
2
+ * capture/envelope.ts — RFC-001 Block 1: capture envelopes + payload summarizer.
3
+ *
4
+ * Pattern: Point-in-time capture. An observer event is snapshotted into a
5
+ * self-contained, immutable envelope at the moment it happens, so
6
+ * DELIVERY can be deferred (RFC-001 "one beat behind") without the
7
+ * payload drifting under later engine mutations.
8
+ * Role: The capture tier of the deferred-observer pipeline
9
+ * (`src/lib/observer-queue/`). Pure module — ZERO engine imports
10
+ * (only `capture/` internals); the engine wiring is RFC-001
11
+ * Blocks 6–10.
12
+ *
13
+ * Capture policies (RFC-001 §5):
14
+ * - `'summary'` — bounded, reference-free summarization via
15
+ * {@link summarizePayload}. Built on the SAME classification path as the
16
+ * retention markers in `summarize.ts` (#14 / #13c-A), extended with
17
+ * bounded structural descent. Structured-clone-safe by construction.
18
+ * - `'clone'` — `structuredClone` at capture time (the capture-tier
19
+ * spelling of retention `'full'` — see the mapping notes in
20
+ * `policies.ts`). If the payload is not clonable (functions, symbols,
21
+ * live handles), capture DEGRADES to `'summary'` and reports through the
22
+ * `warn` hook — a capture must never throw into the producer.
23
+ * - `'ref'` — pass-through of the live reference. The CALLER asserts
24
+ * immutability for the delivery window (safe e.g. for committed-state
25
+ * reads, proven immutable-after-swap in #13/#13b). Exempt from the
26
+ * clone-safety guarantee — see the dev-warn seam below.
27
+ *
28
+ * Dev-warn seam (resolves the isDevMode-would-be-an-engine-import problem):
29
+ * This module must not import `scope/detectCircular` (engine territory).
30
+ * Instead, `capture()` accepts {@link CaptureHooks} with an optional
31
+ * `warn` callback and invokes it on every `'ref'` capture and every
32
+ * `'clone'` degradation. The WIRING layer (Block 6) binds `warn` to an
33
+ * `isDevMode()`-gated, deduplicated console warner; the pure module stays
34
+ * engine-free and silent by default (no hooks ⇒ no warning, zero cost).
35
+ *
36
+ * Summarizer bounds (documented contract, exported as constants):
37
+ * - depth ≤ {@link PAYLOAD_SUMMARY_MAX_DEPTH} (3) — deeper structure
38
+ * collapses to a classified leaf with `depthClipped: true`.
39
+ * - breadth ≤ {@link PAYLOAD_SUMMARY_MAX_ENTRIES} (16) per object/array —
40
+ * the remainder is dropped and flagged `truncated: true` (the honest
41
+ * `size` still reports the real count).
42
+ * - total ≤ {@link PAYLOAD_SUMMARY_MAX_NODES} (128) summary nodes per
43
+ * payload — a global budget so wide×deep payloads stay O(1)-bounded.
44
+ * - string previews ≤ `SUMMARY_PREVIEW_LENGTH` (80) chars.
45
+ * Cycles are detected (ancestor set) and flagged `circular: true`;
46
+ * throwing getters yield a `'unreadable'` leaf; symbol-keyed properties
47
+ * are ignored (same as JSON / `Object.keys`); `Map`/`Set` are leaves with
48
+ * their REAL entry count (no descent — matches `summarize.ts`).
49
+ */
50
+ import { type SummaryValueType } from './summarize.js';
51
+ /** Which observer channel produced a captured event (RFC-001 §5). */
52
+ export type CaptureChannel = 'scope' | 'flow' | 'emit';
53
+ /**
54
+ * How an event payload is materialized into the envelope (RFC-001 §5).
55
+ * See the module header for the full per-policy contract.
56
+ */
57
+ export type CapturePolicy = 'summary' | 'clone' | 'ref';
58
+ /**
59
+ * A captured observer event — self-contained and immutable (shallow-frozen
60
+ * at creation). `seq` is the arrival stamp assigned at capture under the
61
+ * single JS thread: it totally orders events ACROSS all three channels, is
62
+ * monotonic, and is gap-detectable (a dropped event leaves a visible hole
63
+ * in the delivered `seq` sequence — honest loss accounting).
64
+ */
65
+ export interface CaptureEnvelope {
66
+ /** Arrival stamp — total order across channels; gaps reveal drops. */
67
+ readonly seq: number;
68
+ /** Producing observer channel. */
69
+ readonly channel: CaptureChannel;
70
+ /** Producing hook name — `'onWrite'`, `'onStageExecuted'`, `'onEmit'`, ... */
71
+ readonly method: string;
72
+ /** The execution step that produced the event (`stageId#executionIndex`). */
73
+ readonly runtimeStageId: string;
74
+ /** The run that produced the event (Convention 4 per-run scoping). */
75
+ readonly runId: string;
76
+ /**
77
+ * Per {@link CapturePolicy} — NEVER a live engine reference under
78
+ * `'summary'` / `'clone'`; under `'ref'` the caller asserted immutability.
79
+ */
80
+ readonly payload: unknown;
81
+ /** Capture wall-clock (ms epoch by default; injectable via hooks.now). */
82
+ readonly capturedAt: number;
83
+ }
84
+ /** The raw event description handed to {@link capture}. */
85
+ export interface CaptureRequest {
86
+ /** Arrival stamp — assigned by the merged queue's counter (Block 3). */
87
+ readonly seq: number;
88
+ readonly channel: CaptureChannel;
89
+ readonly method: string;
90
+ readonly runtimeStageId: string;
91
+ readonly runId: string;
92
+ /** The LIVE payload — `capture()` materializes it per policy. */
93
+ readonly payload: unknown;
94
+ }
95
+ /**
96
+ * Engine-free seams injected by the wiring layer (Block 6). The pure module
97
+ * never imports dev-mode or clock infrastructure.
98
+ */
99
+ export interface CaptureHooks {
100
+ /**
101
+ * Diagnostic sink — invoked on every `'ref'` capture (caller-asserted
102
+ * immutability) and every `'clone'` → `'summary'` degradation. Block 6
103
+ * binds this to an `isDevMode()`-gated, deduplicated warner.
104
+ */
105
+ readonly warn?: (message: string) => void;
106
+ /** Clock for `capturedAt` — defaults to `Date.now`. Injectable for tests. */
107
+ readonly now?: () => number;
108
+ }
109
+ /** Max nesting depth a payload summary descends before clipping. */
110
+ export declare const PAYLOAD_SUMMARY_MAX_DEPTH = 3;
111
+ /** Max entries summarized per object/array level before truncating. */
112
+ export declare const PAYLOAD_SUMMARY_MAX_ENTRIES = 16;
113
+ /** Global per-payload budget of summary nodes (wide×deep hard bound). */
114
+ export declare const PAYLOAD_SUMMARY_MAX_NODES = 128;
115
+ /**
116
+ * Leaf classification for a summary node — the `summarize.ts` family plus
117
+ * `'unreadable'` for properties whose getter threw during capture.
118
+ */
119
+ export type PayloadSummaryType = SummaryValueType | 'unreadable';
120
+ /**
121
+ * One node of a payload summary tree. Every field is a primitive, a plain
122
+ * object, or a plain array — structured-clone-safe by construction.
123
+ */
124
+ export interface PayloadSummaryNode {
125
+ /** Classification — same rules as the retention markers (one code path). */
126
+ readonly type: PayloadSummaryType;
127
+ /** Honest size proxy: string length, array length, or key/entry count. */
128
+ readonly size?: number;
129
+ /** First `SUMMARY_PREVIEW_LENGTH` chars — primitives/strings only. */
130
+ readonly preview?: string;
131
+ /** Summarized own enumerable string-keyed properties (objects). */
132
+ readonly fields?: Readonly<Record<string, PayloadSummaryNode>>;
133
+ /** Summarized leading items (arrays). */
134
+ readonly items?: readonly PayloadSummaryNode[];
135
+ /** Entries were omitted here (breadth cap or node budget). */
136
+ readonly truncated?: boolean;
137
+ /** This value is an ancestor of itself — descent stopped. */
138
+ readonly circular?: true;
139
+ /** {@link PAYLOAD_SUMMARY_MAX_DEPTH} reached — children not descended. */
140
+ readonly depthClipped?: true;
141
+ }
142
+ /**
143
+ * Root of a payload summary — branded so consumers (and tests) can detect
144
+ * that a payload was summarized rather than cloned. Sibling of the
145
+ * `__readSummary` / `__writeSummary` retention markers.
146
+ */
147
+ export interface PayloadSummary extends PayloadSummaryNode {
148
+ /** Discriminant — `'summary'`-policy envelope payloads carry this. */
149
+ readonly __payloadSummary: true;
150
+ }
151
+ /**
152
+ * Produce a bounded, reference-free, structured-clone-safe summary of an
153
+ * arbitrary payload. Never throws; never holds a reference into the source
154
+ * value (every node is a fresh object whose fields are primitives). Bounds
155
+ * are documented in the module header.
156
+ */
157
+ export declare function summarizePayload(payload: unknown): PayloadSummary;
158
+ /**
159
+ * Capture one observer event into an immutable {@link CaptureEnvelope}.
160
+ *
161
+ * Guarantees:
162
+ * - Never throws into the producer (`'clone'` degradation, summarizer
163
+ * never-throws contract).
164
+ * - The returned envelope is shallow-frozen — `seq`/`channel`/... cannot
165
+ * be reassigned. Under `'summary'`/`'clone'` the payload holds no live
166
+ * reference into the source; under `'ref'` it intentionally does.
167
+ * - Default policy is `'summary'` (cheapest safe tier).
168
+ */
169
+ export declare function capture(request: CaptureRequest, policy?: CapturePolicy, hooks?: CaptureHooks): CaptureEnvelope;
@@ -43,6 +43,18 @@ export declare class ContinuationResolver<TOut = any, TScope = any> {
43
43
  * Key: node.id, Value: visit count (0 = first visit).
44
44
  */
45
45
  private iterationCounters;
46
+ /**
47
+ * Total fn-bearing dynamic-next hops this traverser has followed.
48
+ *
49
+ * Fresh fn-bearing nodes bypass the per-node-id iteration counter (they
50
+ * are new nodes, often without stable ids — there is no back-edge to
51
+ * count). Without a bound, a stage that keeps returning a function-bearing
52
+ * dynamic `next` runs FOREVER on the flat trampoline (no stack overflow
53
+ * brakes it either). This run-total counter puts such chains under the
54
+ * same `maxIterations` budget (default 1000, tuned via
55
+ * `RunOptions.maxIterations`) that bounds loop edges.
56
+ */
57
+ private dynamicNextHops;
46
58
  private readonly onIterationUpdate?;
47
59
  private readonly maxIterations;
48
60
  constructor(deps: HandlerDeps<TOut, TScope>, nodeResolver: NodeResolver<TOut, TScope>, onIterationUpdate?: (nodeId: string, count: number) => void, maxIterations?: number);
@@ -62,8 +74,9 @@ export declare class ContinuationResolver<TOut = any, TScope = any> {
62
74
  * `onLoop` narrative), in the same order.
63
75
  *
64
76
  * Three dynamicNext patterns:
65
- * - StageNode with fn → truly dynamic node, returned as-is (no iteration
66
- * tracking — it is a fresh node, not a back-edge).
77
+ * - StageNode with fn → truly dynamic node, returned as-is (no per-node
78
+ * iteration tracking — it is a fresh node, not a back-edge — but the
79
+ * run-total dynamic-hop budget applies; see `dynamicNextHops`).
67
80
  * - String ID → reference to an existing node, resolved via NodeResolver.
68
81
  * - StageNode without fn → reference by ID, resolved via NodeResolver.
69
82
  */
@@ -337,6 +337,9 @@ export interface RunOptions {
337
337
  * This is the binding constraint for loop-heavy pipelines — raise it for
338
338
  * legitimately long loops (`loopTo` chains run with a flat stack, so high
339
339
  * values are safe; memory for state/narrative still grows per iteration).
340
+ * Also bounds the run-total chain of function-bearing dynamic `next`
341
+ * continuations (fresh nodes returned from stages) — a runaway dynamic
342
+ * chain errors instead of running forever.
340
343
  * Propagates to subflows. Must be >= 1.
341
344
  */
342
345
  maxIterations?: number;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * observer-queue/deferredDispatcher.ts — RFC-001 Block 5: deferred delivery façade.
3
+ *
4
+ * Pattern: capture → enqueue → (microtask) flush → invoke, with per-listener
5
+ * error isolation. Composes the whole pure pipeline: MergedQueue
6
+ * (Block 3, which captures via Block 1) + FlushDriver (Block 4) +
7
+ * a listener registry with timing/inflight accounting.
8
+ * Role: The object the engine wiring (Block 6) will hold. Producers call
9
+ * `capture()` (cheap, never throws, never blocks); listeners
10
+ * receive envelopes at the next checkpoint, "one beat behind".
11
+ * Pure module — zero engine imports.
12
+ *
13
+ * Delivery semantics (normative, RFC-001 §5 + amendments A2/A4):
14
+ * - Per-listener FIFO: every listener sees envelopes in seq order
15
+ * (invocation order; an async listener's COMPLETION order is its own
16
+ * concern) — EXCEPT under `'block'` overflow, where a refused enqueue
17
+ * is delivered inline and overtakes the queued backlog. `seq` always
18
+ * records true arrival order, so order-sensitive consumers re-sort;
19
+ * see the `'block'` caveat below.
20
+ * - Error isolation: a throwing listener (sync) or rejecting listener
21
+ * (async) never affects siblings or the producer. Both failure modes
22
+ * route to the injected `onError`; a throwing `onError` is itself
23
+ * swallowed.
24
+ * - The flush NEVER awaits a listener. Async continuations are tracked in
25
+ * an inflight set; `drain({ timeoutMs })` settles them
26
+ * (`Promise.allSettled` + deadline, shaped like `flushAllDetached`).
27
+ * - `'block'` overflow: a refused enqueue is delivered synchronously
28
+ * INLINE from `capture()` — re-introducing blocking delivery by the
29
+ * consumer's explicit choice. Ordering caveat (documented + tested): an
30
+ * inline event overtakes the queued backlog — `'block'` trades global
31
+ * ordering for zero loss and bounded memory. `seq` still tells the
32
+ * true arrival order.
33
+ * - Listener registry is idempotent by id (same id replaces, different
34
+ * ids coexist) — mirrors the repo-wide recorder ID contract. Stats
35
+ * accumulate per id across replacement; `removeListener` keeps the
36
+ * id's accumulated stats for post-run reports.
37
+ * - Events captured BEFORE any listener attaches stay queued — a listener
38
+ * attached before the next checkpoint still receives the backlog.
39
+ *
40
+ * Per-listener time accounting (amendment A2 — "name the hog"): cumulative
41
+ * `totalMs` and per-checkpoint `lastFlushMs` of SYNC time per listener id —
42
+ * the time that actually blocks the flush. An async listener's continuation
43
+ * time is intentionally not attributed (it does not block delivery).
44
+ */
45
+ import { type CaptureEnvelope, type CaptureHooks, type CapturePolicy } from '../capture/envelope.js';
46
+ import { type FlushSyncResult } from './flushDriver.js';
47
+ import { type EnqueueInput } from './mergedQueue.js';
48
+ import { type OverflowPolicy } from './ring.js';
49
+ /**
50
+ * One deferred observer. May return a Promise — the dispatcher tracks it in
51
+ * the inflight set but NEVER awaits it during a flush.
52
+ */
53
+ export type DeferredListener = (envelope: CaptureEnvelope) => void | Promise<void>;
54
+ export interface DispatchErrorContext {
55
+ readonly listenerId: string;
56
+ readonly envelope: CaptureEnvelope;
57
+ /** `'sync'` = listener threw; `'async'` = returned promise rejected. */
58
+ readonly phase: 'sync' | 'async';
59
+ }
60
+ /** Injected error sink — the wiring layer routes these (Block 6). */
61
+ export type DispatchErrorHandler = (error: unknown, context: DispatchErrorContext) => void;
62
+ export interface DeferredDispatcherOptions {
63
+ /** Queue bound — default 10 000 (see `MergedQueue`). */
64
+ readonly maxQueue?: number;
65
+ /** Overflow policy — default `'drop-oldest'`. */
66
+ readonly overflow?: OverflowPolicy;
67
+ /** `'sample'` overflow only — admit 1 in this many saturated arrivals. */
68
+ readonly sampleEvery?: number;
69
+ /** Default capture policy — default `'summary'`. */
70
+ readonly capturePolicy?: CapturePolicy;
71
+ /** Per-flush time budget, ms (A1) — default 2; `Infinity` = full drain. */
72
+ readonly flushBudgetMs?: number;
73
+ /** Listener-failure sink. No default — without it, failures are silent. */
74
+ readonly onError?: DispatchErrorHandler;
75
+ /** Capture seams (dev-warn, capturedAt clock) — see `CaptureHooks`. */
76
+ readonly hooks?: CaptureHooks;
77
+ /** Timing clock for budget + per-listener accounting. Injectable. */
78
+ readonly now?: () => number;
79
+ /** Checkpoint primitive — default `queueMicrotask`. Injectable. */
80
+ readonly schedule?: (cb: () => void) => void;
81
+ }
82
+ /** Per-listener accounting (A2/A4). */
83
+ export interface ListenerStats {
84
+ /** Envelopes delivered (invocations, including ones that threw). */
85
+ readonly events: number;
86
+ /** Cumulative sync delivery time, ms. */
87
+ readonly totalMs: number;
88
+ /** Sync delivery time since the last flush started, ms. */
89
+ readonly lastFlushMs: number;
90
+ }
91
+ /** The Block 9 observability surface (amendment A4) — pure getter. */
92
+ export interface DispatcherStats {
93
+ /** Current backlog. */
94
+ readonly depth: number;
95
+ /** Events LOST (overflow) — never silent; also visible as seq gaps. */
96
+ readonly drops: number;
97
+ /** Completed checkpoint flushes. */
98
+ readonly flushes: number;
99
+ /** Flushes cut short by `flushBudgetMs` (A1). */
100
+ readonly budgetExhausted: number;
101
+ /** p95 flush duration, ms (rolling window). */
102
+ readonly p95FlushMs: number;
103
+ /** `'block'`-policy refusals delivered synchronously inline. */
104
+ readonly inlineDeliveries: number;
105
+ /** Async listener continuations not yet settled. */
106
+ readonly inflight: number;
107
+ /** Per-listener time accounting — "name the hog" (A2). */
108
+ readonly perListener: Readonly<Record<string, ListenerStats>>;
109
+ }
110
+ /** Result of {@link DeferredDispatcher.drain} — `flushAllDetached` shape. */
111
+ export interface DrainResult {
112
+ /** Async continuations seen settling fulfilled. Best-effort count — a
113
+ * continuation that settles between checks is drained but may not be
114
+ * counted (same semantics as `flushAllDetached`). */
115
+ readonly done: number;
116
+ /** Continuations whose listener promise rejected (routed to onError). */
117
+ readonly failed: number;
118
+ /** Still in flight (or queued) when the deadline expired. `0` = drained. */
119
+ readonly pending: number;
120
+ }
121
+ export declare class DeferredDispatcher {
122
+ private readonly queue;
123
+ private readonly driver;
124
+ private readonly listeners;
125
+ private readonly listenerStats;
126
+ /** Tracked async continuations — resolve `true` (ok) / `false` (failed). */
127
+ private readonly inflight;
128
+ private readonly onError?;
129
+ private readonly now;
130
+ private inlineDeliveries;
131
+ constructor(opts?: DeferredDispatcherOptions);
132
+ /** Idempotent by id — same id replaces (stats continue), ids coexist. */
133
+ addListener(id: string, listener: DeferredListener): void;
134
+ /** Stop delivering to `id`. Accumulated stats are kept for reports. */
135
+ removeListener(id: string): void;
136
+ /**
137
+ * Producer entry point: capture the event (seq-stamped, payload per
138
+ * policy) and stage it for the next checkpoint. Cheap; NEVER throws;
139
+ * never blocks — except under `'block'` overflow, where a refused
140
+ * enqueue is delivered synchronously inline (explicit consumer choice).
141
+ */
142
+ capture(input: EnqueueInput, policy?: CapturePolicy): void;
143
+ /**
144
+ * Terminal flush — synchronously deliver everything queued (end of run /
145
+ * shutdown). Async listener continuations are NOT awaited; follow with
146
+ * `drain()` for that.
147
+ */
148
+ flushNow(opts?: {
149
+ maxRounds?: number;
150
+ }): FlushSyncResult;
151
+ /**
152
+ * Flush the backlog, then settle all inflight async continuations —
153
+ * `Promise.allSettled` under a deadline, shaped like `flushAllDetached`.
154
+ * Loops while continuations spawn new captures, until quiescent or the
155
+ * deadline expires.
156
+ */
157
+ drain(opts?: {
158
+ timeoutMs?: number;
159
+ }): Promise<DrainResult>;
160
+ /** A4 — the stats object Block 9 consumes. Pure getter, fresh snapshot. */
161
+ getStats(): DispatcherStats;
162
+ private deliverNext;
163
+ /** Invoke every listener with full error isolation + time accounting. */
164
+ private deliver;
165
+ /** Track an async continuation; route its rejection; never reject. */
166
+ private track;
167
+ /** The error sink must never become an error source. */
168
+ private safeOnError;
169
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * observer-queue/flushDriver.ts — RFC-001 Block 4: armed-once microtask batcher.
3
+ *
4
+ * Pattern: Kernel-style bottom-half. Producers only set a flag ("work
5
+ * pending") and return; the actual work runs at the next
6
+ * scheduling checkpoint (a microtask), drains under a time
7
+ * budget, and re-arms itself if backlog remains. Same shape as
8
+ * the detach module's `microtaskBatchDriver` — accumulate during
9
+ * the current sync slice, drain at the boundary.
10
+ * Role: The scheduler of the deferred-observer pipeline. Owns WHEN
11
+ * delivery happens; knows nothing about envelopes or listeners
12
+ * (the dispatcher, Block 5, injects `depth`/`processNext`).
13
+ * Pure module — zero imports, zero engine knowledge.
14
+ *
15
+ * Scheduling semantics (normative, RFC-001 §5 + amendment A1):
16
+ * - `arm()` is idempotent: at most ONE pending flush exists (armed flag).
17
+ * N captures between checkpoints ⇒ exactly 1 flush.
18
+ * - A flush drains a SNAPSHOT: at most `depth()`-at-flush-start items.
19
+ * Events enqueued BY listeners during the flush exceed the snapshot and
20
+ * land at the NEXT checkpoint — listener-driven cascades cannot starve
21
+ * the event loop.
22
+ * - `flushBudgetMs` (default 2; `Infinity` = full snapshot drain): the
23
+ * flush stops once the budget is exhausted, counts `budgetExhausted`,
24
+ * and re-arms. At least ONE item is processed per flush regardless of
25
+ * budget — guaranteed progress under any clock.
26
+ * - If backlog remains after the flush (budget cut OR listener enqueues),
27
+ * the driver re-arms for the next checkpoint.
28
+ *
29
+ * Why stage boundaries make this safe: the engine `await`s every stage, so
30
+ * the microtask queue runs at EVERY stage boundary — flushes are at most
31
+ * "one beat behind" the producing stage. See
32
+ * `docs/guides/execution-model.md` ("Stage boundaries are scheduling
33
+ * points") and the FAQ in `docs/design/rfc-001-deferred-observers.md`.
34
+ *
35
+ * Testability: `now` (clock) and `schedule` (checkpoint primitive) are
36
+ * injectable — tests pump flushes deterministically with a fake clock and
37
+ * a captured-callback scheduler; production uses `performance.now` and
38
+ * `queueMicrotask`.
39
+ */
40
+ /** Result of one flush (also delivered to `onFlushEnd`). */
41
+ export interface FlushOutcome {
42
+ /** Items processed in this flush. */
43
+ readonly processed: number;
44
+ /** True when the time budget cut the flush before the snapshot drained. */
45
+ readonly budgetExhausted: boolean;
46
+ /** True when backlog remained and the driver re-armed itself. */
47
+ readonly rearmed: boolean;
48
+ }
49
+ /** Result of a synchronous {@link FlushDriver.flushSync} drain. */
50
+ export interface FlushSyncResult {
51
+ /** Items processed across all rounds. */
52
+ readonly drained: number;
53
+ /** Items still queued when `maxRounds` stopped a runaway cascade. */
54
+ readonly remaining: number;
55
+ }
56
+ export interface FlushDriverOptions {
57
+ /** Current backlog of the queue this driver drains. */
58
+ readonly depth: () => number;
59
+ /** Process exactly ONE queued item. Precondition: `depth() > 0`. */
60
+ readonly processNext: () => void;
61
+ /**
62
+ * Per-flush time budget in ms. Default 2. `Infinity` drains the full
63
+ * snapshot every checkpoint. Must be > 0.
64
+ */
65
+ readonly flushBudgetMs?: number;
66
+ /** Clock — default `performance.now` (falls back to `Date.now`). */
67
+ readonly now?: () => number;
68
+ /** Checkpoint primitive — default `queueMicrotask`. */
69
+ readonly schedule?: (cb: () => void) => void;
70
+ /** Fires before the first item of every flush (incl. `flushSync`). */
71
+ readonly onFlushStart?: () => void;
72
+ /** Fires after every flush with its outcome (incl. `flushSync`). */
73
+ readonly onFlushEnd?: (outcome: FlushOutcome) => void;
74
+ }
75
+ export interface FlushDriverStats {
76
+ /** Completed flushes (zero-work wakeups are not counted). */
77
+ readonly flushes: number;
78
+ /** Flushes cut short by `flushBudgetMs` (A1 — backlog visibility). */
79
+ readonly budgetExhausted: number;
80
+ /** Duration of the most recent flush, ms. */
81
+ readonly lastFlushMs: number;
82
+ /** p95 over the last {@link FLUSH_SAMPLE_WINDOW} flush durations, ms. */
83
+ readonly p95FlushMs: number;
84
+ /** True while a flush is scheduled but not yet run. */
85
+ readonly armed: boolean;
86
+ }
87
+ /** Rolling sample window for the p95 flush-duration stat (A4). */
88
+ export declare const FLUSH_SAMPLE_WINDOW = 128;
89
+ export declare class FlushDriver {
90
+ private readonly depth;
91
+ private readonly processNext;
92
+ private readonly flushBudgetMs;
93
+ private readonly now;
94
+ private readonly schedule;
95
+ private readonly onFlushStart?;
96
+ private readonly onFlushEnd?;
97
+ private armed;
98
+ private flushes;
99
+ private budgetExhaustedCount;
100
+ private lastFlushMs;
101
+ private readonly samples;
102
+ private sampleWriteIdx;
103
+ constructor(opts: FlushDriverOptions);
104
+ /**
105
+ * Request a flush at the next checkpoint. Idempotent — while one flush
106
+ * is pending, further arms are free no-ops (the armed-once invariant).
107
+ */
108
+ arm(): void;
109
+ /**
110
+ * Synchronous full drain — the terminal-flush primitive (end of run /
111
+ * shutdown). Repeats snapshot rounds until the queue is empty so
112
+ * listener-enqueued cascades drain too, capped at `maxRounds` so a
113
+ * listener that enqueues forever cannot hang the process (`remaining`
114
+ * reports what the cap left behind).
115
+ */
116
+ flushSync(opts?: {
117
+ maxRounds?: number;
118
+ }): FlushSyncResult;
119
+ getStats(): FlushDriverStats;
120
+ /** The microtask body — see the module-header semantics. */
121
+ private flush;
122
+ private recordFlush;
123
+ private p95FlushMs;
124
+ }