wicked-bus 1.1.0 → 2.0.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/lib/archive.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Archive bucket lifecycle for the v2 tiered-storage warm tier.
3
+ *
4
+ * Each warm bucket is an independent SQLite file at
5
+ * `<data_dir>/archive/bus-YYYY-MM[suffix].db`. Files are independent —
6
+ * corrupting one bucket affects only the events in that month/split.
7
+ *
8
+ * Each bucket carries a `_meta` table (NOT a fictitious PRAGMA) holding
9
+ * `min_event_id`, `max_event_id`, `created_at`, and `sealed_at`. The query
10
+ * resolver reads these via normal SELECT to decide which buckets to ATTACH
11
+ * during a warm-spill (see lib/query.js and DESIGN-v2.md §5.4).
12
+ *
13
+ * Buckets without a `sealed_at` value are still being filled by sweep and
14
+ * are conservatively treated as covering up to MAX_SAFE_INTEGER until sealed.
15
+ *
16
+ * @module lib/archive
17
+ */
18
+
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+
23
+ const require = createRequire(import.meta.url);
24
+
25
+ const BUCKET_FILE_RE = /^bus-\d{4}-\d{2}[a-z]?\.db$/;
26
+
27
+ /**
28
+ * Build the absolute path to the archive directory for a given data_dir.
29
+ * @param {string} dataDir
30
+ * @returns {string}
31
+ */
32
+ export function archiveDir(dataDir) {
33
+ return path.join(dataDir, 'archive');
34
+ }
35
+
36
+ /**
37
+ * Ensure the archive directory exists (idempotent).
38
+ * @param {string} archDir
39
+ */
40
+ export function ensureArchiveDir(archDir) {
41
+ fs.mkdirSync(archDir, { recursive: true });
42
+ }
43
+
44
+ /**
45
+ * Create a new archive bucket file with the v2 `events` schema mirrored
46
+ * from the live tier, plus the `_meta` table. Returns the open Database
47
+ * handle. Caller is responsible for closing it.
48
+ *
49
+ * @param {string} filepath - absolute path to the bucket .db file
50
+ * @param {{ minEventId?: number, maxEventId?: number }} [meta]
51
+ * @returns {import('better-sqlite3').Database}
52
+ */
53
+ export function createBucket(filepath, meta = {}) {
54
+ ensureArchiveDir(path.dirname(filepath));
55
+ const Database = require('better-sqlite3');
56
+ const db = new Database(filepath);
57
+
58
+ db.pragma('journal_mode = WAL');
59
+ db.pragma('synchronous = NORMAL');
60
+ db.pragma('busy_timeout = 5000');
61
+
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS events (
64
+ event_id INTEGER PRIMARY KEY,
65
+ event_type TEXT NOT NULL,
66
+ domain TEXT NOT NULL,
67
+ subdomain TEXT NOT NULL DEFAULT '',
68
+ payload TEXT NOT NULL,
69
+ schema_version TEXT NOT NULL DEFAULT '1.0.0',
70
+ idempotency_key TEXT NOT NULL,
71
+ emitted_at INTEGER NOT NULL,
72
+ expires_at INTEGER NOT NULL,
73
+ dedup_expires_at INTEGER NOT NULL,
74
+ metadata TEXT,
75
+ parent_event_id INTEGER,
76
+ session_id TEXT,
77
+ correlation_id TEXT,
78
+ producer_id TEXT,
79
+ origin_node_id TEXT,
80
+ registry_schema_version INTEGER,
81
+ payload_cas_sha TEXT
82
+ );
83
+ CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type);
84
+ CREATE INDEX IF NOT EXISTS idx_events_domain ON events(domain);
85
+ CREATE INDEX IF NOT EXISTS idx_events_correlation_id ON events(correlation_id);
86
+
87
+ CREATE TABLE IF NOT EXISTS _meta (
88
+ k TEXT PRIMARY KEY,
89
+ v TEXT NOT NULL
90
+ );
91
+ `);
92
+
93
+ setMeta(db, 'created_at', String(Date.now()));
94
+ if (meta.minEventId != null) setMeta(db, 'min_event_id', String(meta.minEventId));
95
+ if (meta.maxEventId != null) setMeta(db, 'max_event_id', String(meta.maxEventId));
96
+
97
+ return db;
98
+ }
99
+
100
+ /**
101
+ * Read the `_meta` key/value pairs from a bucket file.
102
+ * Opens read-only so a sealed bucket can be inspected without taking a write lock.
103
+ *
104
+ * @param {string} filepath
105
+ * @returns {Record<string,string>}
106
+ */
107
+ export function getBucketMeta(filepath) {
108
+ const Database = require('better-sqlite3');
109
+ const db = new Database(filepath, { readonly: true });
110
+ try {
111
+ const rows = db.prepare('SELECT k, v FROM _meta').all();
112
+ const meta = {};
113
+ for (const r of rows) meta[r.k] = r.v;
114
+ return meta;
115
+ } finally {
116
+ db.close();
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Mark a bucket sealed: write `sealed_at` and (optionally) finalize `max_event_id`.
122
+ * Once sealed, a bucket's covered range is fixed and the resolver can use
123
+ * the [min, max] interval to skip it when out of range.
124
+ *
125
+ * @param {string} filepath
126
+ * @param {{ maxEventId?: number }} [opts]
127
+ */
128
+ export function sealBucket(filepath, opts = {}) {
129
+ const Database = require('better-sqlite3');
130
+ const db = new Database(filepath);
131
+ try {
132
+ setMeta(db, 'sealed_at', String(Date.now()));
133
+ if (opts.maxEventId != null) {
134
+ setMeta(db, 'max_event_id', String(opts.maxEventId));
135
+ }
136
+ } finally {
137
+ db.close();
138
+ }
139
+ }
140
+
141
+ function setMeta(db, k, v) {
142
+ db.prepare('INSERT OR REPLACE INTO _meta(k, v) VALUES (?, ?)').run(k, v);
143
+ }
144
+
145
+ /**
146
+ * List all bucket files in the archive directory, sorted lexicographically.
147
+ * Suffix letters (`a`, `b`, …) preserve creation order within a month after
148
+ * auto-split, so lexical sort matches creation order.
149
+ *
150
+ * @param {string} archDir
151
+ * @returns {string[]} absolute paths
152
+ */
153
+ export function listBuckets(archDir) {
154
+ if (!fs.existsSync(archDir)) return [];
155
+ return fs.readdirSync(archDir)
156
+ .filter(f => BUCKET_FILE_RE.test(f))
157
+ .sort()
158
+ .map(f => path.join(archDir, f));
159
+ }
160
+
161
+ /**
162
+ * Find buckets that cover (any part of) the event_id range [gapStart, gapEnd].
163
+ * Returns descriptors with absolute path + parsed range + unsealed flag.
164
+ * Unsealed buckets are conservatively included whenever their `min_event_id`
165
+ * is at or below `gapEnd`.
166
+ *
167
+ * @param {string} archDir
168
+ * @param {number} gapStart - inclusive
169
+ * @param {number} gapEnd - inclusive
170
+ * @returns {Array<{ filepath: string, min: number, max: number, unsealed: boolean }>}
171
+ */
172
+ export function bucketsCoveringRange(archDir, gapStart, gapEnd) {
173
+ const all = listBuckets(archDir);
174
+ const covering = [];
175
+ for (const filepath of all) {
176
+ let meta;
177
+ try {
178
+ meta = getBucketMeta(filepath);
179
+ } catch (_e) {
180
+ // Unreadable bucket — surface to caller via spill, not enumeration.
181
+ // Caller's ATTACH attempt will throw WB-013 if needed.
182
+ covering.push({ filepath, min: -Infinity, max: Infinity, unsealed: true });
183
+ continue;
184
+ }
185
+ const min = meta.min_event_id != null ? Number(meta.min_event_id) : null;
186
+ const sealed = meta.sealed_at != null;
187
+ const max = sealed && meta.max_event_id != null
188
+ ? Number(meta.max_event_id)
189
+ : Number.MAX_SAFE_INTEGER;
190
+
191
+ if (min === null) continue; // empty bucket — nothing to cover
192
+
193
+ if (max >= gapStart && min <= gapEnd) {
194
+ covering.push({ filepath, min, max, unsealed: !sealed });
195
+ }
196
+ }
197
+ return covering;
198
+ }
package/lib/cas.js ADDED
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Content-addressable store (CAS) for large event payloads.
3
+ *
4
+ * Per DESIGN-v2.md §6:
5
+ * - Storage: `<dataDir>/cas/<sha[0:2]>/<sha>` (200-bit sharding)
6
+ * - SHA-256 of uncompressed content; filename is full hex SHA
7
+ * - O_EXCL writes; collision = no-op (immutable, content-addressed)
8
+ * - Object size cap (default 256 MB) — `WB-008 PAYLOAD_TOO_LARGE` above
9
+ * - Reference-tracked via `events.payload_cas_sha`; GC walks live + warm
10
+ * buckets and deletes orphans past a configurable grace window
11
+ * - GC aborts with `WB-010 CAS_GC_INCOMPLETE_BUCKET_SET` if any bucket is
12
+ * unreadable (offline-bucket safety, round-1 council fix)
13
+ *
14
+ * The functions are synchronous because better-sqlite3 is synchronous and
15
+ * fs.* sync APIs are fine for the CAS sizes we target (≤256 MB by default).
16
+ *
17
+ * @module lib/cas
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { createHash } from 'node:crypto';
23
+ import { createRequire } from 'node:module';
24
+ import { WBError } from './errors.js';
25
+ import { archiveDir, listBuckets } from './archive.js';
26
+
27
+ const require = createRequire(import.meta.url);
28
+
29
+ export const DEFAULT_OBJECT_MAX_BYTES = 256 * 1024 * 1024;
30
+ export const DEFAULT_GC_GRACE_DAYS = 7;
31
+
32
+ /**
33
+ * Standard CAS root for a given dataDir.
34
+ * @param {string} dataDir
35
+ * @returns {string}
36
+ */
37
+ export function casDir(dataDir) {
38
+ return path.join(dataDir, 'cas');
39
+ }
40
+
41
+ function shardedPathFor(dataDir, sha) {
42
+ return path.join(casDir(dataDir), sha.slice(0, 2), sha);
43
+ }
44
+
45
+ function sha256(buf) {
46
+ return createHash('sha256').update(buf).digest('hex');
47
+ }
48
+
49
+ function ensureBuffer(content) {
50
+ if (Buffer.isBuffer(content)) return content;
51
+ if (typeof content === 'string') return Buffer.from(content, 'utf8');
52
+ throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
53
+ message: 'cas.put requires Buffer or string content',
54
+ received: typeof content,
55
+ });
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // put / get / exists / stats
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Store content in the CAS. Returns the SHA-256 hex digest.
64
+ * Writes are O_EXCL — duplicate content (same SHA) is a no-op.
65
+ *
66
+ * @param {string} dataDir
67
+ * @param {Buffer|string} content
68
+ * @param {object} [opts]
69
+ * @param {number} [opts.max_bytes] override DEFAULT_OBJECT_MAX_BYTES
70
+ * @returns {string} SHA-256 hex digest
71
+ */
72
+ export function put(dataDir, content, opts = {}) {
73
+ const buf = ensureBuffer(content);
74
+ const cap = opts.max_bytes ?? DEFAULT_OBJECT_MAX_BYTES;
75
+
76
+ if (buf.length > cap) {
77
+ throw new WBError('WB-008', 'PAYLOAD_TOO_LARGE', {
78
+ message: `CAS object exceeds ${cap}-byte cap`,
79
+ size: buf.length,
80
+ cap,
81
+ });
82
+ }
83
+
84
+ const sha = sha256(buf);
85
+ const target = shardedPathFor(dataDir, sha);
86
+
87
+ if (fs.existsSync(target)) return sha; // immutable; collision = no-op
88
+
89
+ fs.mkdirSync(path.dirname(target), { recursive: true });
90
+
91
+ // Write to a temp file in the same directory then rename; this gives us
92
+ // atomic visibility and avoids partial reads during a crash.
93
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
94
+ let fd;
95
+ try {
96
+ fd = fs.openSync(tmp, 'wx', 0o600);
97
+ fs.writeSync(fd, buf);
98
+ fs.fsyncSync(fd);
99
+ } finally {
100
+ if (fd !== undefined) {
101
+ try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
102
+ }
103
+ }
104
+
105
+ try {
106
+ fs.renameSync(tmp, target);
107
+ } catch (e) {
108
+ // Race: another writer just moved their copy into place. Same SHA →
109
+ // identical content → safe to drop ours.
110
+ try { fs.unlinkSync(tmp); } catch (_e) { /* ignore */ }
111
+ if (e.code !== 'EEXIST') throw e;
112
+ }
113
+
114
+ return sha;
115
+ }
116
+
117
+ /**
118
+ * Read content for a given SHA. Returns a Buffer, or null if not found.
119
+ */
120
+ export function get(dataDir, sha) {
121
+ const target = shardedPathFor(dataDir, sha);
122
+ try {
123
+ return fs.readFileSync(target);
124
+ } catch (e) {
125
+ if (e.code === 'ENOENT') return null;
126
+ throw e;
127
+ }
128
+ }
129
+
130
+ export function exists(dataDir, sha) {
131
+ return fs.existsSync(shardedPathFor(dataDir, sha));
132
+ }
133
+
134
+ /**
135
+ * Aggregate stats: total objects, total bytes, root path.
136
+ */
137
+ export function stats(dataDir) {
138
+ const root = casDir(dataDir);
139
+ if (!fs.existsSync(root)) {
140
+ return { root, object_count: 0, total_bytes: 0 };
141
+ }
142
+
143
+ let count = 0;
144
+ let bytes = 0;
145
+ for (const file of walkCasFiles(root)) {
146
+ count++;
147
+ try { bytes += fs.statSync(file).size; }
148
+ catch (_e) { /* removed concurrently */ }
149
+ }
150
+ return { root, object_count: count, total_bytes: bytes };
151
+ }
152
+
153
+ function* walkCasFiles(root) {
154
+ for (const shard of safeReaddir(root)) {
155
+ const shardDir = path.join(root, shard);
156
+ let stat;
157
+ try { stat = fs.statSync(shardDir); }
158
+ catch (_e) { continue; }
159
+ if (!stat.isDirectory()) continue;
160
+ for (const f of safeReaddir(shardDir)) {
161
+ yield path.join(shardDir, f);
162
+ }
163
+ }
164
+ }
165
+
166
+ function safeReaddir(p) {
167
+ try { return fs.readdirSync(p); } catch (_e) { return []; }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // GC — offline-bucket-safe (round-1 council fix)
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Walk the live tier and all warm buckets to compute the live SHA set, then
176
+ * remove CAS entries that are NOT referenced and were last modified before
177
+ * `now - graceMs`. ABORTS with WB-010 if any bucket is unreadable so we
178
+ * never silently shrink the live set on incomplete data.
179
+ *
180
+ * @param {object} opts
181
+ * @param {string} opts.dataDir
182
+ * @param {import('better-sqlite3').Database} opts.liveDb
183
+ * @param {number} [opts.grace_days]
184
+ * @param {number} [opts.now] test-injectable timestamp
185
+ * @param {string[]} [opts.allow_missing_buckets] basenames (or globs) the
186
+ * operator has agreed are
187
+ * intentionally absent (e.g.,
188
+ * exported to cold and removed)
189
+ * @param {boolean} [opts.dry_run=false]
190
+ * @returns {{
191
+ * live_shas: number,
192
+ * considered: number,
193
+ * deleted: number,
194
+ * skipped_in_grace: number,
195
+ * bytes_freed: number,
196
+ * }}
197
+ */
198
+ export function gc(opts) {
199
+ const dataDir = opts.dataDir;
200
+ const liveDb = opts.liveDb;
201
+ const graceDays = opts.grace_days ?? DEFAULT_GC_GRACE_DAYS;
202
+ const graceMs = graceDays * 86400_000;
203
+ const now = opts.now ?? Date.now();
204
+ const allow = new Set(opts.allow_missing_buckets ?? []);
205
+ const dryRun = !!opts.dry_run;
206
+
207
+ if (!dataDir) throw new Error('gc requires opts.dataDir');
208
+ if (!liveDb) throw new Error('gc requires opts.liveDb');
209
+
210
+ const liveShas = collectLiveShasOrAbort(dataDir, liveDb, allow);
211
+
212
+ const root = casDir(dataDir);
213
+ if (!fs.existsSync(root)) {
214
+ return { live_shas: liveShas.size, considered: 0, deleted: 0, skipped_in_grace: 0, bytes_freed: 0 };
215
+ }
216
+
217
+ let considered = 0;
218
+ let deleted = 0;
219
+ let skippedInGrace = 0;
220
+ let bytesFreed = 0;
221
+
222
+ for (const file of walkCasFiles(root)) {
223
+ considered++;
224
+ const sha = path.basename(file);
225
+ if (liveShas.has(sha)) continue;
226
+
227
+ let stat;
228
+ try { stat = fs.statSync(file); }
229
+ catch (_e) { continue; } // removed concurrently
230
+
231
+ if (now - stat.mtimeMs < graceMs) {
232
+ skippedInGrace++;
233
+ continue;
234
+ }
235
+
236
+ if (!dryRun) {
237
+ try {
238
+ fs.unlinkSync(file);
239
+ deleted++;
240
+ bytesFreed += stat.size;
241
+ } catch (_e) { /* concurrent delete or permission — skip */ }
242
+ } else {
243
+ deleted++;
244
+ bytesFreed += stat.size;
245
+ }
246
+ }
247
+
248
+ return {
249
+ live_shas: liveShas.size,
250
+ considered,
251
+ deleted,
252
+ skipped_in_grace: skippedInGrace,
253
+ bytes_freed: bytesFreed,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Build the set of payload_cas_sha values referenced by ANY event in live or
259
+ * any warm bucket. Aborts with WB-010 if any bucket is unreadable.
260
+ */
261
+ function collectLiveShasOrAbort(dataDir, liveDb, allowSet) {
262
+ const shas = new Set();
263
+
264
+ // Live tier
265
+ for (const row of liveDb.prepare(
266
+ 'SELECT payload_cas_sha FROM events WHERE payload_cas_sha IS NOT NULL'
267
+ ).all()) {
268
+ if (row.payload_cas_sha) shas.add(row.payload_cas_sha);
269
+ }
270
+
271
+ // Warm tier — every expected bucket must be readable
272
+ const archDir = archiveDir(dataDir);
273
+ for (const filepath of listBuckets(archDir)) {
274
+ const basename = path.basename(filepath);
275
+ if (allowSet.has(basename)) continue; // operator-acknowledged absence
276
+
277
+ try { fs.accessSync(filepath, fs.constants.R_OK); }
278
+ catch (e) {
279
+ throw new WBError('WB-010', 'CAS_GC_INCOMPLETE_BUCKET_SET', {
280
+ message: `CAS GC requires every warm bucket to be readable; ${basename} is not`,
281
+ bucket: filepath,
282
+ cause: e.code,
283
+ });
284
+ }
285
+
286
+ let bucketDb;
287
+ try {
288
+ const Database = require('better-sqlite3');
289
+ bucketDb = new Database(filepath, { readonly: true });
290
+ } catch (e) {
291
+ throw new WBError('WB-010', 'CAS_GC_INCOMPLETE_BUCKET_SET', {
292
+ message: `failed to open warm bucket: ${basename}`,
293
+ bucket: filepath,
294
+ cause: e.message,
295
+ });
296
+ }
297
+
298
+ try {
299
+ for (const row of bucketDb.prepare(
300
+ 'SELECT payload_cas_sha FROM events WHERE payload_cas_sha IS NOT NULL'
301
+ ).all()) {
302
+ if (row.payload_cas_sha) shas.add(row.payload_cas_sha);
303
+ }
304
+ } finally {
305
+ bucketDb.close();
306
+ }
307
+ }
308
+
309
+ return shas;
310
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Cross-process causality propagation — DESIGN-v2.md §8.
3
+ *
4
+ * In-process: callers wrap a body in `withContext({ correlation_id, ... }, fn)`;
5
+ * any `emit()` calls inside the body inherit the context, and successive
6
+ * emits chain via parent_event_id.
7
+ *
8
+ * Cross-process: spawned subprocesses inherit four env vars and the same
9
+ * `withContext` runs at the entry point of the child to re-enter the trace.
10
+ * - WICKED_BUS_CORRELATION_ID
11
+ * - WICKED_BUS_SESSION_ID
12
+ * - WICKED_BUS_PARENT_EVENT_ID
13
+ * - WICKED_BUS_PRODUCER_ID
14
+ *
15
+ * The `currentContext()` accessor returns the active context, drawing from
16
+ * the AsyncLocalStorage frame if one exists, otherwise from the env vars.
17
+ *
18
+ * @module lib/causality
19
+ */
20
+
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
22
+
23
+ const ENV_KEYS = Object.freeze({
24
+ correlation_id: 'WICKED_BUS_CORRELATION_ID',
25
+ session_id: 'WICKED_BUS_SESSION_ID',
26
+ parent_event_id: 'WICKED_BUS_PARENT_EVENT_ID',
27
+ producer_id: 'WICKED_BUS_PRODUCER_ID',
28
+ });
29
+
30
+ const als = new AsyncLocalStorage();
31
+
32
+ /**
33
+ * Run `fn` with the given context fields attached. Nested withContext()
34
+ * calls inherit-and-override (last write wins per field).
35
+ *
36
+ * @param {object} ctx
37
+ * @param {string} [ctx.correlation_id]
38
+ * @param {string} [ctx.session_id]
39
+ * @param {number} [ctx.parent_event_id]
40
+ * @param {string} [ctx.producer_id]
41
+ * @param {Function} fn - sync or async; awaited if it returns a Promise
42
+ * @returns {*}
43
+ */
44
+ export function withContext(ctx, fn) {
45
+ const inherited = currentContext();
46
+ const merged = { ...inherited, ...sanitize(ctx) };
47
+ return als.run(merged, fn);
48
+ }
49
+
50
+ /**
51
+ * Read the active context. Returns an object with the four fields (any of
52
+ * which may be `null`/`undefined`). The result is a snapshot copy — mutating
53
+ * it does not affect the running context.
54
+ */
55
+ export function currentContext() {
56
+ const frame = als.getStore();
57
+ if (frame) return { ...frame };
58
+
59
+ // Fallback: read env vars. Useful at the entry point of a spawned child
60
+ // before any explicit withContext() wrap.
61
+ const fromEnv = {
62
+ correlation_id: process.env[ENV_KEYS.correlation_id] || null,
63
+ session_id: process.env[ENV_KEYS.session_id] || null,
64
+ parent_event_id: parseIntOrNull(process.env[ENV_KEYS.parent_event_id]),
65
+ producer_id: process.env[ENV_KEYS.producer_id] || null,
66
+ };
67
+ // Return undefined-y context only when truly nothing is set — keeps the
68
+ // happy path's "no causality at all" comparison cheap.
69
+ if (
70
+ !fromEnv.correlation_id && !fromEnv.session_id &&
71
+ !fromEnv.parent_event_id && !fromEnv.producer_id
72
+ ) return {};
73
+ return fromEnv;
74
+ }
75
+
76
+ /**
77
+ * Set the parent_event_id for subsequent emits inside the active context.
78
+ * Used by emit() so the next event in the same withContext() block chains
79
+ * to the most recently emitted event_id.
80
+ *
81
+ * @param {number} eventId
82
+ */
83
+ export function recordEmit(eventId) {
84
+ const frame = als.getStore();
85
+ if (frame) frame.parent_event_id = eventId;
86
+ }
87
+
88
+ /**
89
+ * Build an env object suitable for passing to spawn()/exec() so the child
90
+ * inherits the active causality context. Caller usually does:
91
+ *
92
+ * spawn(cmd, args, { env: { ...process.env, ...causalityEnv() } });
93
+ *
94
+ * If no context is active, returns {} so the caller's spawn is unaffected.
95
+ */
96
+ export function causalityEnv() {
97
+ const ctx = currentContext();
98
+ const out = {};
99
+ if (ctx.correlation_id) out[ENV_KEYS.correlation_id] = String(ctx.correlation_id);
100
+ if (ctx.session_id) out[ENV_KEYS.session_id] = String(ctx.session_id);
101
+ if (ctx.parent_event_id != null) {
102
+ out[ENV_KEYS.parent_event_id] = String(ctx.parent_event_id);
103
+ }
104
+ if (ctx.producer_id) out[ENV_KEYS.producer_id] = String(ctx.producer_id);
105
+ return out;
106
+ }
107
+
108
+ /** Constant export for callers wanting to manipulate env vars directly. */
109
+ export const CAUSALITY_ENV_KEYS = ENV_KEYS;
110
+
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function sanitize(obj) {
114
+ const out = {};
115
+ if (obj.correlation_id) out.correlation_id = String(obj.correlation_id);
116
+ if (obj.session_id) out.session_id = String(obj.session_id);
117
+ if (obj.parent_event_id != null) {
118
+ const n = Number(obj.parent_event_id);
119
+ if (Number.isInteger(n) && n > 0) out.parent_event_id = n;
120
+ }
121
+ if (obj.producer_id) out.producer_id = String(obj.producer_id);
122
+ return out;
123
+ }
124
+
125
+ function parseIntOrNull(v) {
126
+ if (v == null) return null;
127
+ const n = parseInt(v, 10);
128
+ return Number.isInteger(n) ? n : null;
129
+ }