enrich.sh 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # enrich.sh
2
+
3
+ ```
4
+ ┌─────────────────────────────────────────────────┐
5
+ │ │
6
+ │ ███████╗███╗ ██╗██████╗ ██╗ ██████╗██╗ ██╗ │
7
+ │ ██╔════╝████╗ ██║██╔══██╗██║██╔════╝██║ ██║ │
8
+ │ █████╗ ██╔██╗ ██║██████╔╝██║██║ ███████║ │
9
+ │ ██╔══╝ ██║╚██╗██║██╔══██╗██║██║ ██╔══██║ │
10
+ │ ███████╗██║ ╚████║██║ ██║██║╚██████╗██║ ██║ │
11
+ │ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝ │
12
+ │ │
13
+ │ Serverless Data Ingestion Pipeline │
14
+ │ Your events → Parquet on R2 → DuckDB │
15
+ │ │
16
+ └─────────────────────────────────────────────────┘
17
+ ```
18
+
19
+ Official JavaScript SDK for **[Enrich.sh](https://get.enrich.sh)**
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install enrich.sh
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```
32
+ ┌──────────┐ track() ┌──────────┐ flush ┌──────────┐
33
+ │ Your │ ─────────────► │ SDK │ ──────────► │ Enrich │
34
+ │ App │ (buffered) │ Buffer │ (batched) │ API │
35
+ └──────────┘ └──────────┘ └────┬─────┘
36
+
37
+ ┌────▼─────┐
38
+ │ R2 │
39
+ │ Parquet │
40
+ └────┬─────┘
41
+
42
+ ┌────▼─────┐
43
+ │ DuckDB │
44
+ │ Query │
45
+ └──────────┘
46
+ ```
47
+
48
+ ### 1 · Initialize
49
+
50
+ ```javascript
51
+ import { Enrich } from 'enrich.sh';
52
+
53
+ const enrich = new Enrich('sk_live_your_api_key');
54
+ ```
55
+
56
+ ### 2 · Track Events
57
+
58
+ Events are buffered locally, then flushed in batches.
59
+
60
+ ```javascript
61
+ enrich.track('page_views', {
62
+ url: window.location.href,
63
+ referrer: document.referrer,
64
+ user_id: 'usr_42',
65
+ });
66
+ ```
67
+
68
+ ### 3 · Direct Ingestion
69
+
70
+ Send a batch immediately — useful server-side.
71
+
72
+ ```javascript
73
+ await enrich.ingest('purchases', [
74
+ { item: 'Laptop', price: 999, user_id: 'usr_42' },
75
+ { item: 'Mouse', price: 25, user_id: 'usr_77' },
76
+ ]);
77
+ ```
78
+
79
+ ### 4 · Query with DuckDB
80
+
81
+ Get presigned URLs, pass them straight to DuckDB.
82
+
83
+ ```javascript
84
+ const urls = await enrich.query('page_views', { days: 7 });
85
+
86
+ // DuckDB (JS/WASM)
87
+ await conn.query(`SELECT * FROM read_parquet(${JSON.stringify(urls)})`);
88
+ ```
89
+
90
+ ---
91
+
92
+ ## API Reference
93
+
94
+ ```
95
+ ┌───────────────────────────────────────────────────────────────┐
96
+ │ METHOD │ DESCRIPTION │
97
+ ├───────────────────────┼───────────────────────────────────────┤
98
+ │ new Enrich(key, opt) │ Create client instance │
99
+ │ .track(id, event) │ Buffer event for batched delivery │
100
+ │ .ingest(id, data) │ Send immediately (returns Promise) │
101
+ │ .query(id, params) │ Get signed Parquet URLs │
102
+ │ .queryDetailed(id,p) │ Get URLs + file metadata │
103
+ │ .flush([id]) │ Force-flush one or all buffers │
104
+ │ .beacon([id]) │ Keepalive flush (tab close safe) │
105
+ │ .destroy() │ Flush + cleanup timers │
106
+ └───────────────────────┴───────────────────────────────────────┘
107
+ ```
108
+
109
+ ### `new Enrich(apiKey, options?)`
110
+
111
+ | Option | Default | Description |
112
+ | :-------------- | :------------------- | :-------------------------------- |
113
+ | `baseUrl` | `https://enrich.sh` | API endpoint |
114
+ | `batchSize` | `100` | Events buffered before auto-flush |
115
+ | `flushInterval` | `5000` | Max ms between flushes |
116
+ | `maxRetries` | `2` | Retry attempts on flush failure |
117
+
118
+ ### `.track(streamId, event)`
119
+
120
+ Buffers a single event. Auto-flushes at `batchSize` or every `flushInterval` ms.
121
+
122
+ ### `.ingest(streamId, data)`
123
+
124
+ Sends one event or an array immediately. Returns the API response.
125
+
126
+ ### `.query(streamId, params?)`
127
+
128
+ Returns `string[]` — signed URLs for Parquet files.
129
+
130
+ | Param | Format | Example |
131
+ | :------ | :----------- | :------------- |
132
+ | `date` | `YYYY-MM-DD` | `2026-02-11` |
133
+ | `start` | `YYYY-MM-DD` | `2026-02-01` |
134
+ | `end` | `YYYY-MM-DD` | `2026-02-10` |
135
+ | `days` | number | `7` |
136
+
137
+ ### `.queryDetailed(streamId, params?)`
138
+
139
+ Same params as `.query()`. Returns full response:
140
+
141
+ ```json
142
+ {
143
+ "urls": ["https://..."],
144
+ "files": [{ "key": "...", "url": "...", "size": 1024, "uploaded_at": "..." }],
145
+ "file_count": 3,
146
+ "expires_at": "2026-02-12T..."
147
+ }
148
+ ```
149
+
150
+ ### `.flush(streamId?)`
151
+
152
+ Force-send buffered events. Omit `streamId` to flush all.
153
+
154
+ ### `.beacon(streamId?)`
155
+
156
+ Best-effort flush that survives page unload/tab close.
157
+ Uses `fetch({ keepalive: true })` with proper `Authorization` headers.
158
+ Fire-and-forget — does not return a Promise.
159
+
160
+ ### `.destroy()`
161
+
162
+ Flush everything and clear timers. Call before process exit.
163
+
164
+ ---
165
+
166
+ ## Limits & Specs
167
+
168
+ ```
169
+ ┌───────────────────────────────────────────────────────────────┐
170
+ │ SPEC │ VALUE │
171
+ ├───────────────────────┼───────────────────────────────────────┤
172
+ │ Max request size │ 1 MB │
173
+ │ Max events / request │ ~10,000 (depending on event size) │
174
+ │ Signed URL TTL │ 24 hours │
175
+ │ Output format │ Apache Parquet │
176
+ │ Auth │ Bearer token (sk_live_* / sk_test_*)│
177
+ │ Transport │ HTTPS │
178
+ │ Runtime │ Node 18+, Browsers, Edge Workers │
179
+ │ Dependencies │ 0 │
180
+ │ Package size │ < 3 KB │
181
+ └───────────────────────┴───────────────────────────────────────┘
182
+ ```
183
+
184
+ ### Rate Limits by Plan
185
+
186
+ ```
187
+ ┌──────────────┬──────────────┬────────────┬────────────────────┐
188
+ │ Plan │ Events/mo │ Streams │ Retention │
189
+ ├──────────────┼──────────────┼────────────┼────────────────────┤
190
+ │ Test Key │ 100K │ 3 │ 30 days │
191
+ │ Starter │ 5M │ 3 │ 30 days │
192
+ │ Pro │ 100M │ Unlimited │ 90 days │
193
+ │ Scale │ 500M │ Unlimited │ 1 year │
194
+ │ Enterprise │ Custom │ Unlimited │ Custom │
195
+ └──────────────┴──────────────┴────────────┴────────────────────┘
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Examples
201
+
202
+ ### Browser — Clickstream
203
+
204
+ ```javascript
205
+ import { Enrich } from 'enrich.sh';
206
+
207
+ const enrich = new Enrich('sk_live_xxx');
208
+
209
+ document.addEventListener('click', (e) => {
210
+ enrich.track('clicks', {
211
+ tag: e.target.tagName,
212
+ id: e.target.id,
213
+ path: location.pathname,
214
+ ts: new Date().toISOString(),
215
+ });
216
+ });
217
+
218
+ // Flush before tab close (beacon survives page unload)
219
+ window.addEventListener('beforeunload', () => enrich.beacon());
220
+ ```
221
+
222
+ ### Node.js — Server-side
223
+
224
+ ```javascript
225
+ import { Enrich } from 'enrich.sh';
226
+
227
+ const enrich = new Enrich('sk_live_xxx');
228
+
229
+ app.post('/checkout', async (req, res) => {
230
+ await enrich.ingest('transactions', {
231
+ order_id: req.body.id,
232
+ amount: req.body.total,
233
+ currency: 'USD',
234
+ });
235
+ res.json({ ok: true });
236
+ });
237
+
238
+ // Cleanup on shutdown
239
+ process.on('SIGTERM', () => enrich.destroy());
240
+ ```
241
+
242
+ ### DuckDB — Analytics
243
+
244
+ ```javascript
245
+ import * as duckdb from '@duckdb/duckdb-wasm';
246
+ import { Enrich } from 'enrich.sh';
247
+
248
+ const enrich = new Enrich('sk_live_xxx');
249
+ const urls = await enrich.query('clicks', { days: 30 });
250
+
251
+ const conn = await db.connect();
252
+ const result = await conn.query(`
253
+ SELECT path, COUNT(*) as hits
254
+ FROM read_parquet(${JSON.stringify(urls)})
255
+ GROUP BY path
256
+ ORDER BY hits DESC
257
+ `);
258
+ ```
259
+
260
+ ---
261
+
262
+ ## Keys
263
+
264
+ ```
265
+ sk_test_* → Sandbox (100K events, free tier limits)
266
+ sk_live_* → Production (paid plan limits apply)
267
+ ```
268
+
269
+ Test keys work identically to live keys but enforce free-tier caps.
270
+ Get your keys at **[dashboard.enrich.sh](https://dashboard.enrich.sh)**
271
+
272
+ ---
273
+
274
+ ## License
275
+
276
+ MIT — [Enrich.sh](https://get.enrich.sh)
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "enrich.sh",
3
+ "version": "0.0.1",
4
+ "description": "Official SDK for Enrich.sh — Serverless Data Ingestion Pipeline. Ingest → Parquet → DuckDB.",
5
+ "keywords": [
6
+ "enrich",
7
+ "analytics",
8
+ "ingestion",
9
+ "data-pipeline",
10
+ "parquet",
11
+ "duckdb",
12
+ "clickstream",
13
+ "serverless"
14
+ ],
15
+ "homepage": "https://get.enrich.sh",
16
+ "bugs": {
17
+ "url": "https://github.com/enrich-sh/sdk-js/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/enrich-sh/sdk-js.git"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Enrich.sh",
25
+ "type": "module",
26
+ "main": "src/index.js",
27
+ "types": "src/index.d.ts",
28
+ "exports": {
29
+ ".": "./src/index.js"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "test": "node test.js"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Enrich.sh SDK
3
+ * Official client for data ingestion and retrieval.
4
+ */
5
+
6
+ export interface EnrichOptions {
7
+ /** API base URL. Default: "https://enrich.sh" */
8
+ baseUrl?: string;
9
+ /** Events buffered before auto-flush. Default: 100 */
10
+ batchSize?: number;
11
+ /** Max milliseconds between flushes. Default: 5000 */
12
+ flushInterval?: number;
13
+ /** Retry attempts on flush failure. Default: 2 */
14
+ maxRetries?: number;
15
+ }
16
+
17
+ export interface QueryParams {
18
+ /** Single day — YYYY-MM-DD */
19
+ date?: string;
20
+ /** Range start — YYYY-MM-DD */
21
+ start?: string;
22
+ /** Range end — YYYY-MM-DD */
23
+ end?: string;
24
+ /** Last N days */
25
+ days?: number | string;
26
+ }
27
+
28
+ export interface FileInfo {
29
+ key: string;
30
+ url: string;
31
+ size: number;
32
+ uploaded_at: string;
33
+ }
34
+
35
+ export interface QueryDetailedResponse {
36
+ success: boolean;
37
+ stream_id: string;
38
+ file_count: number;
39
+ urls: string[];
40
+ files: FileInfo[];
41
+ expires_at: string;
42
+ }
43
+
44
+ export interface IngestResponse {
45
+ accepted: number;
46
+ buffered: number;
47
+ }
48
+
49
+ export class Enrich {
50
+ readonly apiKey: string;
51
+ readonly baseUrl: string;
52
+ readonly batchSize: number;
53
+ readonly flushInterval: number;
54
+ readonly maxRetries: number;
55
+
56
+ /**
57
+ * Create an Enrich client.
58
+ * @param apiKey Your API key (sk_live_* or sk_test_*)
59
+ * @param options Configuration options
60
+ */
61
+ constructor(apiKey: string, options?: EnrichOptions);
62
+
63
+ /**
64
+ * Send events immediately.
65
+ * @param streamId Target stream identifier
66
+ * @param data Single event or array of events
67
+ */
68
+ ingest(streamId: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<IngestResponse>;
69
+
70
+ /**
71
+ * Buffer a single event for batched delivery.
72
+ * Auto-flushes at batchSize or after flushInterval ms.
73
+ * @param streamId Target stream
74
+ * @param event Event payload (any JSON-serializable object)
75
+ */
76
+ track(streamId: string, event: Record<string, unknown>): void;
77
+
78
+ /**
79
+ * Flush buffered events.
80
+ * @param streamId Flush one stream, or omit to flush all
81
+ */
82
+ flush(streamId?: string): Promise<void>;
83
+
84
+ /**
85
+ * Get presigned Parquet file URLs for DuckDB.
86
+ * @param streamId Stream to query
87
+ * @param params Date filters
88
+ * @returns Array of signed URLs
89
+ */
90
+ query(streamId: string, params?: QueryParams): Promise<string[]>;
91
+
92
+ /**
93
+ * Get full query response with file metadata.
94
+ * @param streamId Stream to query
95
+ * @param params Date filters
96
+ */
97
+ queryDetailed(streamId: string, params?: QueryParams): Promise<QueryDetailedResponse>;
98
+
99
+ /**
100
+ * Best-effort flush that survives page unload/tab close.
101
+ * Uses fetch({ keepalive: true }) with proper Authorization headers.
102
+ * Fire-and-forget — does not return a Promise.
103
+ * @param streamId Flush one stream, or omit to flush all
104
+ */
105
+ beacon(streamId?: string): void;
106
+
107
+ /**
108
+ * Flush all buffers and stop timers. Call before shutdown.
109
+ */
110
+ destroy(): Promise<void>;
111
+ }
package/src/index.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Enrich.sh SDK
3
+ * Official client for data ingestion and retrieval.
4
+ *
5
+ * @version 1.1.0
6
+ * @license MIT
7
+ */
8
+
9
+ export class Enrich {
10
+ /**
11
+ * @param {string} apiKey - Your API key (sk_live_* or sk_test_*)
12
+ * @param {Object} [options]
13
+ * @param {string} [options.baseUrl=https://enrich.sh] - API base URL
14
+ * @param {number} [options.batchSize=100] - Events buffered before auto-flush
15
+ * @param {number} [options.flushInterval=5000] - Max ms between flushes
16
+ * @param {number} [options.maxRetries=2] - Retry count on flush failure
17
+ */
18
+ constructor(apiKey, options = {}) {
19
+ if (!apiKey) {
20
+ throw new Error('Enrich: API key is required. Get one at dashboard.enrich.sh');
21
+ }
22
+ if (!apiKey.startsWith('sk_live_') && !apiKey.startsWith('sk_test_')) {
23
+ throw new Error('Enrich: Invalid key format. Keys start with sk_live_ or sk_test_');
24
+ }
25
+
26
+ this.apiKey = apiKey;
27
+ this.baseUrl = (options.baseUrl || 'https://enrich.sh').replace(/\/+$/, '');
28
+ this.batchSize = options.batchSize || 100;
29
+ this.flushInterval = options.flushInterval || 5000;
30
+ this.maxRetries = options.maxRetries ?? 2;
31
+
32
+ this._buffers = new Map(); // streamId -> event[]
33
+ this._timers = new Map(); // streamId -> timerId
34
+ }
35
+
36
+ // ── Ingestion ───────────────────────────────────────────────
37
+
38
+ /**
39
+ * Validate stream ID format (must match backend rules).
40
+ * @param {string} streamId
41
+ */
42
+ _validateStreamId(streamId) {
43
+ if (!streamId || typeof streamId !== 'string') {
44
+ throw new Error('Enrich: streamId is required and must be a string');
45
+ }
46
+ if (!/^[a-zA-Z0-9_-]+$/.test(streamId)) {
47
+ throw new Error('Enrich: streamId must be alphanumeric (a-z, 0-9, _, -)');
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Send events immediately.
53
+ * @param {string} streamId - Target stream identifier
54
+ * @param {Object|Object[]} data - Event object or array of events
55
+ * @returns {Promise<Object>} API response
56
+ */
57
+ async ingest(streamId, data) {
58
+ this._validateStreamId(streamId);
59
+ const events = Array.isArray(data) ? data : [data];
60
+ if (events.length === 0) return { success: true, events_received: 0 };
61
+
62
+ const res = await fetch(`${this.baseUrl}/ingest`, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Authorization': `Bearer ${this.apiKey}`,
66
+ 'Content-Type': 'application/json',
67
+ },
68
+ body: JSON.stringify({
69
+ stream_id: streamId,
70
+ data: events,
71
+ }),
72
+ });
73
+
74
+ if (!res.ok) {
75
+ const body = await res.json().catch(() => ({}));
76
+ throw new Error(body.error || `Enrich ingest failed (${res.status})`);
77
+ }
78
+
79
+ return res.json();
80
+ }
81
+
82
+ /**
83
+ * Buffer a single event for automatic batched delivery.
84
+ * Flushes when buffer hits batchSize or after flushInterval ms.
85
+ * @param {string} streamId - Target stream
86
+ * @param {Object} event - Event payload
87
+ */
88
+ track(streamId, event) {
89
+ this._validateStreamId(streamId);
90
+ if (!this._buffers.has(streamId)) {
91
+ this._buffers.set(streamId, []);
92
+ }
93
+
94
+ this._buffers.get(streamId).push({
95
+ ...event,
96
+ _ts: Date.now(),
97
+ });
98
+
99
+ this._resetTimer(streamId);
100
+
101
+ if (this._buffers.get(streamId).length >= this.batchSize) {
102
+ this.flush(streamId);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Flush buffered events for one stream, or all streams.
108
+ * @param {string} [streamId] - Omit to flush everything
109
+ * @returns {Promise}
110
+ */
111
+ async flush(streamId) {
112
+ if (streamId) return this._flushOne(streamId);
113
+ return Promise.all(
114
+ Array.from(this._buffers.keys()).map((id) => this._flushOne(id)),
115
+ );
116
+ }
117
+
118
+ // ── Query ───────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Get presigned URLs for stored Parquet files.
122
+ * Pass these directly to DuckDB's read_parquet().
123
+ *
124
+ * @param {string} streamId
125
+ * @param {Object} [params]
126
+ * @param {string} [params.date] - Single day, YYYY-MM-DD
127
+ * @param {string} [params.start] - Range start, YYYY-MM-DD
128
+ * @param {string} [params.end] - Range end, YYYY-MM-DD
129
+ * @param {number} [params.days] - Last N days
130
+ * @returns {Promise<string[]>} Array of signed URLs
131
+ */
132
+ async query(streamId, params = {}) {
133
+ const qs = new URLSearchParams({ stream_id: streamId, ...params });
134
+
135
+ const res = await fetch(`${this.baseUrl}/query-urls?${qs}`, {
136
+ headers: { 'Authorization': `Bearer ${this.apiKey}` },
137
+ });
138
+
139
+ if (!res.ok) {
140
+ const body = await res.json().catch(() => ({}));
141
+ throw new Error(body.error || `Enrich query failed (${res.status})`);
142
+ }
143
+
144
+ const data = await res.json();
145
+ return data.urls || [];
146
+ }
147
+
148
+ /**
149
+ * Get full query response including file metadata.
150
+ * @param {string} streamId
151
+ * @param {Object} [params] - Same as query()
152
+ * @returns {Promise<Object>} { urls, files, file_count, expires_at }
153
+ */
154
+ async queryDetailed(streamId, params = {}) {
155
+ const qs = new URLSearchParams({ stream_id: streamId, ...params });
156
+
157
+ const res = await fetch(`${this.baseUrl}/query-urls?${qs}`, {
158
+ headers: { 'Authorization': `Bearer ${this.apiKey}` },
159
+ });
160
+
161
+ if (!res.ok) {
162
+ const body = await res.json().catch(() => ({}));
163
+ throw new Error(body.error || `Enrich query failed (${res.status})`);
164
+ }
165
+
166
+ return res.json();
167
+ }
168
+
169
+ // ── Lifecycle ───────────────────────────────────────────────
170
+
171
+ /**
172
+ * Best-effort flush that survives page unload.
173
+ * Uses fetch({ keepalive: true }) — works like sendBeacon but
174
+ * supports Authorization headers (no token in URL).
175
+ * @param {string} [streamId] - Flush one stream, or omit for all
176
+ */
177
+ beacon(streamId) {
178
+ const ids = streamId
179
+ ? [streamId]
180
+ : Array.from(this._buffers.keys());
181
+
182
+ for (const id of ids) {
183
+ const buffer = this._buffers.get(id);
184
+ if (!buffer || buffer.length === 0) continue;
185
+
186
+ const events = buffer.splice(0, buffer.length);
187
+ this._clearTimer(id);
188
+
189
+ fetch(`${this.baseUrl}/ingest`, {
190
+ method: 'POST',
191
+ headers: {
192
+ 'Authorization': `Bearer ${this.apiKey}`,
193
+ 'Content-Type': 'application/json',
194
+ },
195
+ body: JSON.stringify({ stream_id: id, data: events }),
196
+ keepalive: true,
197
+ }).catch(() => { });
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Flush all buffers and stop timers. Call before shutdown.
203
+ */
204
+ async destroy() {
205
+ try {
206
+ await this.flush();
207
+ } catch {
208
+ // Best effort — cleanup must still happen
209
+ }
210
+ for (const [id, timer] of this._timers) {
211
+ clearTimeout(timer);
212
+ }
213
+ this._timers.clear();
214
+ this._buffers.clear();
215
+ }
216
+
217
+ // ── Private ─────────────────────────────────────────────────
218
+
219
+ async _flushOne(streamId) {
220
+ const buffer = this._buffers.get(streamId);
221
+ if (!buffer || buffer.length === 0) return;
222
+
223
+ const events = buffer.splice(0, buffer.length);
224
+ this._clearTimer(streamId);
225
+
226
+ let lastErr;
227
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
228
+ try {
229
+ return await this.ingest(streamId, events);
230
+ } catch (err) {
231
+ lastErr = err;
232
+ if (attempt < this.maxRetries) {
233
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
234
+ }
235
+ }
236
+ }
237
+
238
+ console.error(`[enrich] flush failed for "${streamId}" after ${this.maxRetries + 1} attempts:`, lastErr);
239
+ throw lastErr;
240
+ }
241
+
242
+ _resetTimer(streamId) {
243
+ this._clearTimer(streamId);
244
+ const timer = setTimeout(() => this.flush(streamId), this.flushInterval);
245
+ if (typeof timer === 'object' && timer.unref) timer.unref();
246
+ this._timers.set(streamId, timer);
247
+ }
248
+
249
+ _clearTimer(streamId) {
250
+ const timer = this._timers.get(streamId);
251
+ if (timer) {
252
+ clearTimeout(timer);
253
+ this._timers.delete(streamId);
254
+ }
255
+ }
256
+ }