@constructive-io/express-context 0.26.2 → 0.28.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/refusals.js ADDED
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ /**
3
+ * refusals — the refusal-observability instantiation of `usage-counter`.
4
+ *
5
+ * Every refusal the platform issues (a 429 from admission control, a 413 for an
6
+ * oversized body, a document-gate rejection, a gateway's route or IP window)
7
+ * is one `RefusalRecorder.record(...)` call at the point the response is
8
+ * written. That call is an in-memory increment and nothing else — no promise,
9
+ * no pool, no allocation beyond the key — so a flood costs a counter bump and
10
+ * refusing traffic keeps working when the recorder's store is down.
11
+ *
12
+ * Counts are keyed by `(minute, database, lane, reason, route, source /24)`,
13
+ * which is exactly the set of questions the platform table answers: which
14
+ * tenant, why, on which route, from which network, since when. Raw addresses
15
+ * are never part of the key; `sourceBucket` truncates to a /24 (v4) or /48
16
+ * (v6) before anything is counted.
17
+ *
18
+ * The flush writes the drained batch as one `record_refusals(jsonb)` call —
19
+ * one statement per window regardless of how many keys it holds.
20
+ *
21
+ * @module refusals
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.createRecordRefusalsSink = exports.RefusalRecorder = exports.DEFAULT_REFUSAL_MAX_KEYS = exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = exports.refusalRows = exports.refusalOverflowKey = exports.refusalKeyOf = exports.sourceBucket = exports.OVERFLOW_ROUTE = exports.OVERFLOW_SOURCE = exports.UNKNOWN_SOURCE = exports.REFUSAL_REASONS = void 0;
25
+ const logger_1 = require("@pgpmjs/logger");
26
+ const pg_query_context_1 = require("pg-query-context");
27
+ const usage_counter_1 = require("./usage-counter");
28
+ const log = new logger_1.Logger('refusals');
29
+ exports.REFUSAL_REASONS = [
30
+ 'rate_limited',
31
+ 'concurrency_saturated',
32
+ 'queue_timeout',
33
+ 'request_too_large',
34
+ 'query_too_deep',
35
+ 'query_too_costly',
36
+ 'page_size_too_large',
37
+ 'anonymous_not_callable',
38
+ 'route_rate_limited'
39
+ ];
40
+ /** `source_bucket` when the lane could not resolve an address. */
41
+ exports.UNKNOWN_SOURCE = 'unknown';
42
+ /** `source_bucket` / `route_key` of the fold-down entry once the counter is full. */
43
+ exports.OVERFLOW_SOURCE = 'overflow';
44
+ exports.OVERFLOW_ROUTE = '*';
45
+ const MINUTE_MS = 60_000;
46
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
47
+ /**
48
+ * Reduce a client address to a network prefix: `/24` for IPv4, `/48` for IPv6.
49
+ * Enough to tell "one network hammering an anonymous route" from "the tenant's
50
+ * own users"; not enough to be a person. Anything unparseable is `unknown`.
51
+ */
52
+ const sourceBucket = (ip) => {
53
+ if (!ip)
54
+ return exports.UNKNOWN_SOURCE;
55
+ let addr = ip.trim();
56
+ if (addr.startsWith('[')) {
57
+ const end = addr.indexOf(']');
58
+ if (end === -1)
59
+ return exports.UNKNOWN_SOURCE;
60
+ addr = addr.slice(1, end);
61
+ }
62
+ const zone = addr.indexOf('%');
63
+ if (zone !== -1)
64
+ addr = addr.slice(0, zone);
65
+ if (addr.toLowerCase().startsWith('::ffff:')) {
66
+ const mapped = addr.slice('::ffff:'.length);
67
+ if (IPV4.test(mapped))
68
+ addr = mapped;
69
+ }
70
+ const v4 = IPV4.exec(addr);
71
+ if (v4) {
72
+ const octets = v4.slice(1, 5).map(Number);
73
+ if (octets.some((o) => o > 255))
74
+ return exports.UNKNOWN_SOURCE;
75
+ return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
76
+ }
77
+ const groups = expandIpv6(addr);
78
+ if (!groups)
79
+ return exports.UNKNOWN_SOURCE;
80
+ return `${groups[0]}:${groups[1]}:${groups[2]}::/48`;
81
+ };
82
+ exports.sourceBucket = sourceBucket;
83
+ /** The eight hextets of an IPv6 address, or null when it does not parse. */
84
+ const expandIpv6 = (addr) => {
85
+ if (!/^[0-9a-fA-F:]+$/.test(addr) || addr.indexOf(':::') !== -1)
86
+ return null;
87
+ const halves = addr.split('::');
88
+ if (halves.length > 2)
89
+ return null;
90
+ const head = halves[0] ? halves[0].split(':') : [];
91
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
92
+ const missing = 8 - head.length - tail.length;
93
+ if (halves.length === 2 ? missing < 1 : missing !== 0)
94
+ return null;
95
+ const groups = [...head, ...Array(missing).fill('0'), ...tail];
96
+ if (groups.some((g) => g.length === 0 || g.length > 4))
97
+ return null;
98
+ return groups.map((g) => parseInt(g, 16).toString(16));
99
+ };
100
+ const minuteOf = (at) => Math.floor(at / MINUTE_MS) * MINUTE_MS;
101
+ const refusalKeyOf = (key) => `${key.minuteBucket}\u0000${key.databaseId ?? ''}\u0000${key.lane}\u0000${key.reason}\u0000${key.routeKey}\u0000${key.sourceBucket}`;
102
+ exports.refusalKeyOf = refusalKeyOf;
103
+ /**
104
+ * Where a key folds to once the counter is full: same minute, tenant, lane and
105
+ * reason; route and source collapsed. A source-spraying flood therefore costs
106
+ * `maxKeys` entries plus one per `(database, lane, reason)`, the tenant/reason
107
+ * attribution is never lost, and the presence of an `overflow` row is itself
108
+ * the signal that a spray happened.
109
+ */
110
+ const refusalOverflowKey = (key) => ({
111
+ ...key,
112
+ routeKey: exports.OVERFLOW_ROUTE,
113
+ sourceBucket: exports.OVERFLOW_SOURCE
114
+ });
115
+ exports.refusalOverflowKey = refusalOverflowKey;
116
+ /** Shape a drained batch for `record_refusals(jsonb)`. */
117
+ const refusalRows = (entries) => entries.map(({ key, count, firstAt, lastAt }) => ({
118
+ minute_bucket: new Date(key.minuteBucket).toISOString(),
119
+ database_id: key.databaseId,
120
+ lane: key.lane,
121
+ reason: key.reason,
122
+ route_key: key.routeKey,
123
+ source_bucket: key.sourceBucket,
124
+ count,
125
+ first_seen_at: new Date(firstAt).toISOString(),
126
+ last_seen_at: new Date(lastAt).toISOString()
127
+ }));
128
+ exports.refusalRows = refusalRows;
129
+ exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = 10_000;
130
+ exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = 2_000;
131
+ exports.DEFAULT_REFUSAL_MAX_KEYS = 2_000;
132
+ const defaultOnError = (err, dropped) => {
133
+ const refusals = dropped.reduce((sum, e) => sum + e.count, 0);
134
+ const message = err instanceof Error ? err.message : String(err);
135
+ log.error(`[refusals] flush failed; dropped ${refusals} refusals across ${dropped.length} keys: ${message}`);
136
+ };
137
+ /**
138
+ * `BoundedCounter<RefusalKey>` + `CounterFlusher` + a `record_refusals` sink.
139
+ * Construct one per process, `start()` it, `record()` from every refusal site,
140
+ * `stop()` on shutdown.
141
+ */
142
+ class RefusalRecorder {
143
+ counter;
144
+ flusher;
145
+ recordFailures = 0;
146
+ constructor(opts) {
147
+ this.counter = new usage_counter_1.BoundedCounter({
148
+ maxKeys: opts.maxKeys ?? exports.DEFAULT_REFUSAL_MAX_KEYS,
149
+ keyOf: exports.refusalKeyOf,
150
+ overflowKey: exports.refusalOverflowKey
151
+ });
152
+ this.flusher = new usage_counter_1.CounterFlusher(this.counter, (entries) => opts.sink((0, exports.refusalRows)(entries)), {
153
+ intervalMs: opts.intervalMs ?? exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS,
154
+ jitterMs: opts.jitterMs ?? exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS,
155
+ onError: opts.onError ?? defaultOnError
156
+ });
157
+ }
158
+ /**
159
+ * Count one refusal. Synchronous and never throws: the request that is
160
+ * being refused must finish being refused whatever state the recorder is in.
161
+ */
162
+ record(refusal) {
163
+ try {
164
+ const at = refusal.at ?? Date.now();
165
+ this.counter.increment({
166
+ minuteBucket: minuteOf(at),
167
+ databaseId: refusal.databaseId,
168
+ lane: refusal.lane,
169
+ reason: refusal.reason,
170
+ routeKey: refusal.routeKey,
171
+ sourceBucket: (0, exports.sourceBucket)(refusal.sourceIp)
172
+ }, 1, at);
173
+ }
174
+ catch (err) {
175
+ this.recordFailures += 1;
176
+ const message = err instanceof Error ? err.message : String(err);
177
+ log.error(`[refusals] record failed (refusal still served): ${message}`);
178
+ }
179
+ }
180
+ start() {
181
+ this.flusher.start();
182
+ }
183
+ /** Final flush, then stop. */
184
+ stop() {
185
+ return this.flusher.stop();
186
+ }
187
+ /** Drain and write now; a sink failure is reported, never thrown. */
188
+ flush() {
189
+ return this.flusher.flush();
190
+ }
191
+ stats() {
192
+ return {
193
+ keys: this.counter.size,
194
+ overflowed: this.counter.overflowed,
195
+ recordFailures: this.recordFailures,
196
+ flusher: this.flusher.stats()
197
+ };
198
+ }
199
+ }
200
+ exports.RefusalRecorder = RefusalRecorder;
201
+ const FUNCTION_NAME = /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/;
202
+ /**
203
+ * A sink that writes a batch as one `record_refusals(jsonb)` call inside a
204
+ * transaction carrying the caller's claims. Claims are resolved per flush so a
205
+ * pool that was unreachable at startup does not leave the recorder wedged.
206
+ */
207
+ const createRecordRefusalsSink = (opts) => {
208
+ const fn = opts.functionName ?? 'constructive_usage_private.record_refusals';
209
+ if (!FUNCTION_NAME.test(fn)) {
210
+ throw new Error(`createRecordRefusalsSink: invalid function name ${JSON.stringify(fn)}`);
211
+ }
212
+ const [schema, name] = fn.split('.');
213
+ const sql = `SELECT "${schema}"."${name}"($1::jsonb) AS recorded`;
214
+ return async (rows) => {
215
+ if (rows.length === 0)
216
+ return;
217
+ const claims = await opts.claims();
218
+ await (0, pg_query_context_1.withPgClient)(opts.pool, claims, async (client) => {
219
+ await client.query(sql, [JSON.stringify(rows)]);
220
+ });
221
+ };
222
+ };
223
+ exports.createRecordRefusalsSink = createRecordRefusalsSink;
@@ -23,13 +23,13 @@ export interface RequestProtection {
23
23
  idleInTransactionTimeoutMs: number;
24
24
  /** `lock_timeout` for the request's transaction. */
25
25
  lockTimeoutMs: number;
26
- /** In-flight requests allowed per tenant (not yet enforced). */
26
+ /** In-flight requests admitted per database, per replica. */
27
27
  maxConcurrentRequests: number;
28
- /** How long a request may wait for a concurrency slot (not yet enforced). */
28
+ /** How long a request may wait for a concurrency slot. `0` never queues. */
29
29
  maxQueueWaitMs: number;
30
- /** Sustained request rate per tenant (not yet enforced). */
30
+ /** Sustained request rate allowed one caller on one route. */
31
31
  rateLimitRpm: number;
32
- /** Burst allowance above `rateLimitRpm` (not yet enforced). */
32
+ /** Burst allowance above `rateLimitRpm` within the same window. */
33
33
  rateLimitBurst: number;
34
34
  /** Deepest selection set accepted in an operation. */
35
35
  maxQueryDepth: number;
package/types.d.ts CHANGED
@@ -152,6 +152,12 @@ export type ConstructiveAPIToken = {
152
152
  session_id?: string;
153
153
  access_level?: string;
154
154
  kind?: string;
155
+ /** Session lineage: the top of this session's exchange chain. */
156
+ root_session_id?: string;
157
+ /** Session lineage: the session this one was exchanged from, if any. */
158
+ parent_session_id?: string;
159
+ /** Caller-declared purpose recorded on the credential at exchange time. */
160
+ intent?: string;
155
161
  [key: string]: unknown;
156
162
  };
157
163
  export interface BillingConfig {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * usage-counter — the shared in-memory count + batch-flush mechanism.
3
+ *
4
+ * Two classes, neither of which knows what is being counted:
5
+ *
6
+ * - `BoundedCounter<K>` is the map. Consumers pass the key type, how a key
7
+ * serialises (`keyOf`) and where a key folds to once the map is full
8
+ * (`overflowKey`). `increment` is O(1), synchronous and never throws, so a
9
+ * request path can call it while refusing traffic without ever waiting on,
10
+ * or being affected by, whatever eventually stores the counts.
11
+ * - `CounterFlusher<K>` is the loop. It drains the counter on a jittered
12
+ * interval, hands the batch to a `sink`, and owns the failure policy: a
13
+ * batch the sink cannot write is reported through `onError` *and dropped*.
14
+ * Re-queuing under a broken sink is how a bounded counter becomes an
15
+ * unbounded one, and the counter is already accumulating the next window.
16
+ *
17
+ * Refusal observability instantiates these with a refusal key and a sink that
18
+ * calls `constructive_usage_private.record_refusals` (see `refusals.ts`); the
19
+ * page/request metering lane instantiates the same two classes with its own
20
+ * `K`, `keyOf`, `overflowKey`, bound, interval and sink. The seam is the
21
+ * constructor arguments — nothing in this file is specific to either.
22
+ *
23
+ * Counters live in process memory only. A crash loses at most one interval's
24
+ * worth of counts; `stop()` flushes on graceful shutdown. That is deliberate:
25
+ * the alternative is a durable write per event, which is the thing counting in
26
+ * memory exists to avoid.
27
+ *
28
+ * @module usage-counter
29
+ */
30
+ export interface CounterEntry<K> {
31
+ key: K;
32
+ /** Sum of every `by` since this entry was created (or last drained). */
33
+ count: number;
34
+ /** Earliest `at` folded into this entry. */
35
+ firstAt: number;
36
+ /** Latest `at` folded into this entry. */
37
+ lastAt: number;
38
+ }
39
+ export interface BoundedCounterOptions<K> {
40
+ /**
41
+ * Distinct keys the counter will hold between drains. Past this, a *new* key
42
+ * is replaced by `overflowKey(key)` before it is counted; keys already
43
+ * present keep incrementing. Overflow keys are always admitted, so the true
44
+ * ceiling is `maxKeys` plus the number of distinct overflow keys the
45
+ * consumer's `overflowKey` can produce — keep that small.
46
+ */
47
+ maxKeys: number;
48
+ /** Serialise a key. Two keys with the same string share one entry. */
49
+ keyOf: (key: K) => string;
50
+ /**
51
+ * Where a key folds to once the map is full. Should collapse the
52
+ * high-cardinality parts of the key (a source address, a route) while
53
+ * keeping the attribution the consumer cannot afford to lose.
54
+ */
55
+ overflowKey: (key: K) => K;
56
+ }
57
+ export declare class BoundedCounter<K> {
58
+ private readonly entries;
59
+ private overflowedSinceDrain;
60
+ private readonly maxKeys;
61
+ private readonly keyOf;
62
+ private readonly overflowKey;
63
+ constructor(opts: BoundedCounterOptions<K>);
64
+ /** Distinct keys currently held. */
65
+ get size(): number;
66
+ /** Increments folded into an overflow key since the last drain. */
67
+ get overflowed(): number;
68
+ /**
69
+ * Add `by` to `key`. Synchronous, O(1), never throws for a well-formed
70
+ * `keyOf`/`overflowKey`; the caller's request path must not depend on it in
71
+ * any other way.
72
+ */
73
+ increment(key: K, by?: number, at?: number): void;
74
+ /**
75
+ * Return every entry and start a fresh window. The returned array is the
76
+ * only reference to those entries, so a slow sink never contends with the
77
+ * hot path.
78
+ */
79
+ drain(): CounterEntry<K>[];
80
+ }
81
+ export type CounterSink<K> = (entries: CounterEntry<K>[]) => Promise<void>;
82
+ export interface CounterFlusherOptions<K> {
83
+ /** Base time between flushes. */
84
+ intervalMs: number;
85
+ /**
86
+ * Uniform random offset added to each interval so replicas started together
87
+ * do not hit the store on the same tick.
88
+ */
89
+ jitterMs: number;
90
+ /**
91
+ * Called for every failed flush with the batch that was dropped. Every
92
+ * failure is reported — there is no "log once then go quiet" — and the
93
+ * request path is not involved, so making this loud is free.
94
+ */
95
+ onError: (err: unknown, dropped: CounterEntry<K>[]) => void;
96
+ }
97
+ export interface CounterFlusherStats {
98
+ /** Epoch ms of the last flush attempt that returned without error, or null. */
99
+ lastFlushAt: number | null;
100
+ /** Epoch ms of the last failed flush, or null. */
101
+ lastErrorAt: number | null;
102
+ /** Message of the last failed flush, or null. */
103
+ lastError: string | null;
104
+ /** Failed flushes since the last success. */
105
+ consecutiveFailures: number;
106
+ /** Entries handed to `onError` over the flusher's lifetime. */
107
+ droppedEntries: number;
108
+ /** Whether the timer is armed. */
109
+ running: boolean;
110
+ }
111
+ export declare class CounterFlusher<K> {
112
+ private readonly counter;
113
+ private readonly sink;
114
+ private readonly opts;
115
+ private timer;
116
+ private inFlight;
117
+ private running;
118
+ private lastFlushAt;
119
+ private lastErrorAt;
120
+ private lastError;
121
+ private consecutiveFailures;
122
+ private droppedEntries;
123
+ constructor(counter: BoundedCounter<K>, sink: CounterSink<K>, opts: CounterFlusherOptions<K>);
124
+ /** Arm the timer. Idempotent. The timer is unref'd so it never pins the process. */
125
+ start(): void;
126
+ /** Disarm the timer and flush whatever is pending. Idempotent. */
127
+ stop(): Promise<void>;
128
+ /**
129
+ * Drain and write now. Never rejects: a sink failure goes to `onError`. If a
130
+ * flush is already in flight the call waits for it and then runs its own, so
131
+ * two overlapping timers cannot send batches out of order.
132
+ */
133
+ flush(): Promise<void>;
134
+ stats(): CounterFlusherStats;
135
+ private arm;
136
+ private flushOnce;
137
+ }
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ /**
3
+ * usage-counter — the shared in-memory count + batch-flush mechanism.
4
+ *
5
+ * Two classes, neither of which knows what is being counted:
6
+ *
7
+ * - `BoundedCounter<K>` is the map. Consumers pass the key type, how a key
8
+ * serialises (`keyOf`) and where a key folds to once the map is full
9
+ * (`overflowKey`). `increment` is O(1), synchronous and never throws, so a
10
+ * request path can call it while refusing traffic without ever waiting on,
11
+ * or being affected by, whatever eventually stores the counts.
12
+ * - `CounterFlusher<K>` is the loop. It drains the counter on a jittered
13
+ * interval, hands the batch to a `sink`, and owns the failure policy: a
14
+ * batch the sink cannot write is reported through `onError` *and dropped*.
15
+ * Re-queuing under a broken sink is how a bounded counter becomes an
16
+ * unbounded one, and the counter is already accumulating the next window.
17
+ *
18
+ * Refusal observability instantiates these with a refusal key and a sink that
19
+ * calls `constructive_usage_private.record_refusals` (see `refusals.ts`); the
20
+ * page/request metering lane instantiates the same two classes with its own
21
+ * `K`, `keyOf`, `overflowKey`, bound, interval and sink. The seam is the
22
+ * constructor arguments — nothing in this file is specific to either.
23
+ *
24
+ * Counters live in process memory only. A crash loses at most one interval's
25
+ * worth of counts; `stop()` flushes on graceful shutdown. That is deliberate:
26
+ * the alternative is a durable write per event, which is the thing counting in
27
+ * memory exists to avoid.
28
+ *
29
+ * @module usage-counter
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.CounterFlusher = exports.BoundedCounter = void 0;
33
+ class BoundedCounter {
34
+ entries = new Map();
35
+ overflowedSinceDrain = 0;
36
+ maxKeys;
37
+ keyOf;
38
+ overflowKey;
39
+ constructor(opts) {
40
+ if (!Number.isInteger(opts.maxKeys) || opts.maxKeys < 1) {
41
+ throw new Error(`BoundedCounter: maxKeys must be a positive integer, got ${opts.maxKeys}`);
42
+ }
43
+ this.maxKeys = opts.maxKeys;
44
+ this.keyOf = opts.keyOf;
45
+ this.overflowKey = opts.overflowKey;
46
+ }
47
+ /** Distinct keys currently held. */
48
+ get size() {
49
+ return this.entries.size;
50
+ }
51
+ /** Increments folded into an overflow key since the last drain. */
52
+ get overflowed() {
53
+ return this.overflowedSinceDrain;
54
+ }
55
+ /**
56
+ * Add `by` to `key`. Synchronous, O(1), never throws for a well-formed
57
+ * `keyOf`/`overflowKey`; the caller's request path must not depend on it in
58
+ * any other way.
59
+ */
60
+ increment(key, by = 1, at = Date.now()) {
61
+ let id = this.keyOf(key);
62
+ let entry = this.entries.get(id);
63
+ if (!entry && this.entries.size >= this.maxKeys) {
64
+ key = this.overflowKey(key);
65
+ id = this.keyOf(key);
66
+ entry = this.entries.get(id);
67
+ this.overflowedSinceDrain += by;
68
+ }
69
+ if (!entry) {
70
+ this.entries.set(id, { key, count: by, firstAt: at, lastAt: at });
71
+ return;
72
+ }
73
+ entry.count += by;
74
+ if (at < entry.firstAt)
75
+ entry.firstAt = at;
76
+ if (at > entry.lastAt)
77
+ entry.lastAt = at;
78
+ }
79
+ /**
80
+ * Return every entry and start a fresh window. The returned array is the
81
+ * only reference to those entries, so a slow sink never contends with the
82
+ * hot path.
83
+ */
84
+ drain() {
85
+ const drained = Array.from(this.entries.values());
86
+ this.entries.clear();
87
+ this.overflowedSinceDrain = 0;
88
+ return drained;
89
+ }
90
+ }
91
+ exports.BoundedCounter = BoundedCounter;
92
+ class CounterFlusher {
93
+ counter;
94
+ sink;
95
+ opts;
96
+ timer = null;
97
+ inFlight = null;
98
+ running = false;
99
+ lastFlushAt = null;
100
+ lastErrorAt = null;
101
+ lastError = null;
102
+ consecutiveFailures = 0;
103
+ droppedEntries = 0;
104
+ constructor(counter, sink, opts) {
105
+ this.counter = counter;
106
+ this.sink = sink;
107
+ this.opts = opts;
108
+ if (!(opts.intervalMs > 0)) {
109
+ throw new Error(`CounterFlusher: intervalMs must be positive, got ${opts.intervalMs}`);
110
+ }
111
+ if (!(opts.jitterMs >= 0)) {
112
+ throw new Error(`CounterFlusher: jitterMs must be non-negative, got ${opts.jitterMs}`);
113
+ }
114
+ }
115
+ /** Arm the timer. Idempotent. The timer is unref'd so it never pins the process. */
116
+ start() {
117
+ if (this.running)
118
+ return;
119
+ this.running = true;
120
+ this.arm();
121
+ }
122
+ /** Disarm the timer and flush whatever is pending. Idempotent. */
123
+ async stop() {
124
+ this.running = false;
125
+ if (this.timer) {
126
+ clearTimeout(this.timer);
127
+ this.timer = null;
128
+ }
129
+ await this.flush();
130
+ }
131
+ /**
132
+ * Drain and write now. Never rejects: a sink failure goes to `onError`. If a
133
+ * flush is already in flight the call waits for it and then runs its own, so
134
+ * two overlapping timers cannot send batches out of order.
135
+ */
136
+ async flush() {
137
+ if (this.inFlight)
138
+ await this.inFlight;
139
+ const run = this.flushOnce();
140
+ this.inFlight = run;
141
+ try {
142
+ await run;
143
+ }
144
+ finally {
145
+ if (this.inFlight === run)
146
+ this.inFlight = null;
147
+ }
148
+ }
149
+ stats() {
150
+ return {
151
+ lastFlushAt: this.lastFlushAt,
152
+ lastErrorAt: this.lastErrorAt,
153
+ lastError: this.lastError,
154
+ consecutiveFailures: this.consecutiveFailures,
155
+ droppedEntries: this.droppedEntries,
156
+ running: this.running
157
+ };
158
+ }
159
+ arm() {
160
+ const delay = this.opts.intervalMs + Math.floor(Math.random() * (this.opts.jitterMs + 1));
161
+ this.timer = setTimeout(() => {
162
+ this.timer = null;
163
+ void this.flush().finally(() => {
164
+ if (this.running)
165
+ this.arm();
166
+ });
167
+ }, delay);
168
+ this.timer.unref?.();
169
+ }
170
+ async flushOnce() {
171
+ const entries = this.counter.drain();
172
+ if (entries.length === 0)
173
+ return;
174
+ try {
175
+ await this.sink(entries);
176
+ this.lastFlushAt = Date.now();
177
+ this.consecutiveFailures = 0;
178
+ }
179
+ catch (err) {
180
+ this.lastErrorAt = Date.now();
181
+ this.lastError = err instanceof Error ? err.message : String(err);
182
+ this.consecutiveFailures += 1;
183
+ this.droppedEntries += entries.length;
184
+ // The batch is gone whether or not onError itself misbehaves; a
185
+ // reporter that throws must not turn one lost window into a stuck loop.
186
+ try {
187
+ this.opts.onError(err, entries);
188
+ }
189
+ catch {
190
+ // Fire-and-forget by design: the failure is already recorded in stats().
191
+ }
192
+ }
193
+ }
194
+ }
195
+ exports.CounterFlusher = CounterFlusher;