ccqa-tools 1.37.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 (38) hide show
  1. package/README.md +177 -0
  2. package/dist/coverage/collector.cjs +229 -0
  3. package/dist/coverage/collector.d.cts +183 -0
  4. package/dist/coverage/collector.d.ts +183 -0
  5. package/dist/coverage/collector.js +225 -0
  6. package/dist/coverage/core.cjs +176 -0
  7. package/dist/coverage/core.d.cts +157 -0
  8. package/dist/coverage/core.d.ts +157 -0
  9. package/dist/coverage/core.js +163 -0
  10. package/dist/coverage/middleware.cjs +161 -0
  11. package/dist/coverage/middleware.d.cts +23 -0
  12. package/dist/coverage/middleware.d.ts +23 -0
  13. package/dist/coverage/middleware.js +158 -0
  14. package/dist/coverage/next-loader.cjs +158 -0
  15. package/dist/coverage/next-loader.d.cts +18 -0
  16. package/dist/coverage/next-loader.d.ts +19 -0
  17. package/dist/coverage/next-loader.js +158 -0
  18. package/dist/coverage/next.cjs +101 -0
  19. package/dist/coverage/next.d.cts +33 -0
  20. package/dist/coverage/next.d.ts +33 -0
  21. package/dist/coverage/next.js +100 -0
  22. package/dist/coverage/register.cjs +741 -0
  23. package/dist/coverage/register.d.cts +1 -0
  24. package/dist/coverage/register.d.ts +1 -0
  25. package/dist/coverage/register.js +716 -0
  26. package/dist/coverage/slack.cjs +228 -0
  27. package/dist/coverage/slack.d.cts +47 -0
  28. package/dist/coverage/slack.d.ts +47 -0
  29. package/dist/coverage/slack.js +225 -0
  30. package/dist/coverage/temporal-workflow.cjs +154 -0
  31. package/dist/coverage/temporal-workflow.d.cts +28 -0
  32. package/dist/coverage/temporal-workflow.d.ts +28 -0
  33. package/dist/coverage/temporal-workflow.js +153 -0
  34. package/dist/coverage/temporal.cjs +253 -0
  35. package/dist/coverage/temporal.d.cts +40 -0
  36. package/dist/coverage/temporal.d.ts +40 -0
  37. package/dist/coverage/temporal.js +250 -0
  38. package/package.json +95 -0
@@ -0,0 +1,183 @@
1
+ //#region src/coverage/core.d.ts
2
+ /**
3
+ * The hot path, and the only module instrumented application code imports.
4
+ *
5
+ * It has **no imports at all**, on purpose. Bundlers pull this file into every
6
+ * layer they build — a Next.js RSC bundle, an SSR bundle, a route-handler
7
+ * bundle, a Temporal workflow sandbox — and each of those rejects a different
8
+ * subset of Node built-ins. `node:http` alone is enough to fail a Next.js
9
+ * webpack build outright. Everything platform-specific lives behind
10
+ * `ccqa-tools/coverage/register`, which only ever loads in real Node.
11
+ *
12
+ * For the same reason the process-wide state hangs off `globalThis` rather than
13
+ * module scope: a bundler produces several copies of this file per process, and
14
+ * a per-copy AsyncLocalStorage would shard attribution across them.
15
+ */
16
+ /** The slice of AsyncLocalStorage this package needs, minus the import. */
17
+ interface ContextStorage<T> {
18
+ getStore(): T | undefined;
19
+ run<R>(store: T, fn: () => R): R;
20
+ }
21
+ /**
22
+ * What one in-flight execution carries.
23
+ *
24
+ * Exactly one of the two says where its reach goes. `specId` is set when the
25
+ * request itself named a spec. `actor` is set when nothing did, and all that is
26
+ * known is who caused the work and when it was first asked for — enough for the
27
+ * run to decide later, and nothing this process could decide on its own.
28
+ */
29
+ interface CoverageStore {
30
+ specId?: string;
31
+ actor?: ActorMark;
32
+ files: Set<string>;
33
+ }
34
+ /** Who caused a request, and the instant it arrived. Never interpreted here. */
35
+ interface ActorMark {
36
+ tag: string;
37
+ at: number;
38
+ }
39
+ /** One identity's reach from one instant, as the collector ships it. */
40
+ interface ActorBucket {
41
+ tag: string;
42
+ at: number;
43
+ files: Set<string>;
44
+ }
45
+ interface CoverageRuntime {
46
+ /**
47
+ * Shape version of this object. Two builds of `ccqa-tools` can end up in
48
+ * one process (an app dependency and a hoisted transitive one); the first to
49
+ * install wins and the rest defer to it, so the shape has to be recognisable.
50
+ */
51
+ readonly protocol: 1;
52
+ readonly als: ContextStorage<CoverageStore>;
53
+ /** specId -> reached file ids. Union is idempotent, so merging is free. */
54
+ readonly buckets: Map<string, Set<string>>;
55
+ /**
56
+ * `<tag> <at>` -> what that identity reached from that instant.
57
+ *
58
+ * Kept apart from `buckets` because nothing here knows which spec they belong
59
+ * to — or whether they belong to one at all. Most of them are other people
60
+ * using the same environment, and the run discards those.
61
+ */
62
+ readonly actors: Map<string, ActorBucket>;
63
+ /**
64
+ * Files reached at module top level. Kept out of the spec buckets because the
65
+ * first spec to import a module would otherwise own it, making the result
66
+ * depend on spec execution order.
67
+ */
68
+ readonly boot: Set<string>;
69
+ /**
70
+ * Number of open buckets, spec and identity alike. `record()` returns on zero
71
+ * before touching anything else — the reason instrumentation costs nothing
72
+ * while nothing is being measured, and the reason this package does not need
73
+ * sampling. A field rather than a `.size` sum because this check is the hot
74
+ * path itself.
75
+ *
76
+ * An identity bucket arms it the same way a spec does, so an application with
77
+ * the actor preset installed pays while any identity has acted recently, not
78
+ * only while a spec is running. That is the price of the application not
79
+ * being told which identities matter.
80
+ */
81
+ active: number;
82
+ /**
83
+ * Executions that ran while a spec was open but outside its async context.
84
+ * A silent gap here would read as "never reached", so it is always counted.
85
+ */
86
+ unattributed: number;
87
+ /**
88
+ * Files a load hook saw but could not turn into recorded coverage — an
89
+ * undecodable source, or a parse error. Uncounted, this would render
90
+ * identically to "reached by no spec", which is a lie.
91
+ */
92
+ uninstrumentedFiles: number;
93
+ /**
94
+ * Set when nothing in this process can be instrumented at all, so every file
95
+ * it runs is missing rather than some of them.
96
+ *
97
+ * A flag and not a count, because the failure is the process: counted as one
98
+ * file it reads as a rounding error next to the thousands it actually hides.
99
+ */
100
+ uninstrumentedProcess: boolean;
101
+ readonly pid: number;
102
+ readonly startedAt: number;
103
+ }
104
+ //#endregion
105
+ //#region src/coverage/runtime-env.d.ts
106
+ interface CoverageConfig {
107
+ enabled: boolean;
108
+ /** Set when the process is dedicated to one spec and has no request to read. */
109
+ ambientSpecId: string | undefined;
110
+ root: string;
111
+ /** Path prefixes, relative to `root`, whose files get instrumented. */
112
+ include: string[];
113
+ debug: boolean;
114
+ }
115
+ //#endregion
116
+ //#region src/coverage/collector.d.ts
117
+ interface CollectorOptions {
118
+ endpoint: string;
119
+ token?: string | undefined;
120
+ intervalMs?: number;
121
+ /** Drops a spec's bucket this long after its last change and last flush. */
122
+ idleTtlMs?: number;
123
+ }
124
+ interface CoveragePush {
125
+ protocol: 1;
126
+ pid: number;
127
+ startedAt: number;
128
+ unattributed: number;
129
+ uninstrumentedFiles: number;
130
+ uninstrumentedProcess: boolean;
131
+ /** Only ids not yet accepted by the sink, per spec. */
132
+ specs: Record<string, string[]>;
133
+ boot: string[];
134
+ /**
135
+ * What each identity reached, and when the work was first asked for. Which
136
+ * spec that is — if any — is the run's to decide; this side only reports.
137
+ */
138
+ actors: Array<{
139
+ tag: string;
140
+ at: number;
141
+ files: string[];
142
+ }>;
143
+ /**
144
+ * Failed push attempts over this process's whole life, not since the last
145
+ * ack. An application outlives the runs measuring it, and every second
146
+ * before the first of them stood up a sink is a failure — reported as a
147
+ * delta since the last ack, all of that arrives inside the first run and
148
+ * reads as that run having lost 25 minutes of reports. The sink subtracts
149
+ * what a process had already dropped when it first heard from it.
150
+ */
151
+ droppedPushes: number;
152
+ }
153
+ /** What survives between ticks: ack state plus the counters it gates on. */
154
+ interface CollectorState {
155
+ /** Per spec, ids the sink has acknowledged. */
156
+ sent: Map<string, Set<string>>;
157
+ sentBoot: Set<string>;
158
+ /** Per identity bucket, ids the sink has acknowledged. */
159
+ sentActors: Map<string, Set<string>>;
160
+ /** Per bucket key (spec id or identity bucket), when it last gained a fresh id. */
161
+ lastChange: Map<string, number>;
162
+ /** `unattributed` as of the last push the sink acknowledged. */
163
+ lastSentUnattributed: number;
164
+ /** The two instrumentation-health figures as of that same push. */
165
+ lastSentUninstrumentedFiles: number;
166
+ lastSentUninstrumentedProcess: boolean;
167
+ /** When the last payload was built, for the blind-process heartbeat. */
168
+ lastSentAt: number;
169
+ /** Lifetime failed attempts. Never reset; the sink baselines it instead. */
170
+ droppedPushes: number;
171
+ }
172
+ declare function createCollectorState(): CollectorState;
173
+ declare function startCollector(options: CollectorOptions, config?: CoverageConfig): () => void;
174
+ /** Exported for tests: the sink's HTTP round trip is the only real-I/O part. */
175
+ declare function diff(runtime: CoverageRuntime, state: CollectorState): CoveragePush | undefined;
176
+ /**
177
+ * Forgets specs that stopped changing and have nothing outstanding, so a long
178
+ * lived server does not accumulate every spec it has ever served — and so the
179
+ * hot-path gate falls back to zero between runs.
180
+ */
181
+ declare function evict(runtime: CoverageRuntime, state: CollectorState, idleTtlMs: number): void;
182
+ //#endregion
183
+ export { CollectorOptions, CollectorState, CoveragePush, createCollectorState, diff, evict, startCollector };
@@ -0,0 +1,225 @@
1
+ //#region src/coverage/core.ts
2
+ const RUNTIME_KEY = Symbol.for("ccqa.coverage.runtime");
3
+ function globals() {
4
+ return globalThis;
5
+ }
6
+ function getRuntime() {
7
+ return globals()[RUNTIME_KEY];
8
+ }
9
+ /**
10
+ * The key both halves of the collector agree on. A space separates them safely:
11
+ * a spec id can never contain one, so identity keys and spec ids stay disjoint
12
+ * where the collector tracks both in one map.
13
+ */
14
+ function actorBucketKey(tag, at) {
15
+ return `${tag} ${at}`;
16
+ }
17
+ /** Drops a spec's bucket once it has been handed to the collector. */
18
+ function closeBucket(runtime, specId) {
19
+ if (runtime.buckets.delete(specId)) armGate(runtime);
20
+ }
21
+ /** Drops an identity's bucket once it has been handed to the collector. */
22
+ function closeActorBucket(runtime, key) {
23
+ if (runtime.actors.delete(key)) armGate(runtime);
24
+ }
25
+ function armGate(runtime) {
26
+ runtime.active = runtime.buckets.size + runtime.actors.size;
27
+ }
28
+ //#endregion
29
+ //#region src/coverage/runtime-env.ts
30
+ /**
31
+ * Diagnostics go to stderr and nowhere else. A `--import` preload is inherited
32
+ * by every child node process, and writing to stdout corrupts whatever the host
33
+ * was parsing there — enough to make a framework's own toolchain fail to start.
34
+ */
35
+ function debugLog(config, message) {
36
+ if (!config.debug) return;
37
+ process.stderr.write(`[ccqa-tools] ${message}\n`);
38
+ }
39
+ //#endregion
40
+ //#region src/coverage/collector.ts
41
+ /**
42
+ * Ships what this process reached to a sink, on a timer.
43
+ *
44
+ * Push, not pull: behind a load balancer nothing can address one replica of N,
45
+ * so an endpoint the runner scrapes would silently report a fraction of the
46
+ * truth. Every replica pushes instead, and the sink unions — file sets make
47
+ * that commutative, associative and idempotent, so the sink never has to know
48
+ * how many replicas there were.
49
+ */
50
+ const DEFAULT_INTERVAL_MS = 1e3;
51
+ const DEFAULT_IDLE_TTL_MS = 12e4;
52
+ /**
53
+ * How many times the exit-time flush may run. More than one because a push the
54
+ * timer started may still be in flight when the first one fires, and that one
55
+ * returns without sending anything.
56
+ */
57
+ const MAX_EXIT_FLUSHES = 3;
58
+ /**
59
+ * How often a process that instrumented nothing re-announces itself when it has
60
+ * nothing else to send.
61
+ *
62
+ * It has no files and no attributions to report, so the delta it pushes is
63
+ * empty forever after the first success — and the run that heard it is over.
64
+ * Without this, every later run against the same process is told nothing
65
+ * reported at all, rather than that one process is blind.
66
+ */
67
+ const BLIND_HEARTBEAT_MS = 3e4;
68
+ function createCollectorState() {
69
+ return {
70
+ sent: /* @__PURE__ */ new Map(),
71
+ sentBoot: /* @__PURE__ */ new Set(),
72
+ sentActors: /* @__PURE__ */ new Map(),
73
+ lastChange: /* @__PURE__ */ new Map(),
74
+ lastSentUnattributed: 0,
75
+ lastSentUninstrumentedFiles: 0,
76
+ lastSentUninstrumentedProcess: false,
77
+ lastSentAt: 0,
78
+ droppedPushes: 0
79
+ };
80
+ }
81
+ function startCollector(options, config) {
82
+ const runtime = getRuntime();
83
+ if (runtime === void 0) return () => {};
84
+ const idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
85
+ const state = createCollectorState();
86
+ let inFlight = false;
87
+ let consecutiveFailures = 0;
88
+ const flush = () => {
89
+ if (inFlight) return Promise.resolve();
90
+ evict(runtime, state, idleTtlMs);
91
+ const payload = diff(runtime, state);
92
+ if (payload === void 0) return Promise.resolve();
93
+ inFlight = true;
94
+ return post(options, payload).then(() => {
95
+ for (const [specId, files] of Object.entries(payload.specs)) {
96
+ const acked = state.sent.get(specId) ?? /* @__PURE__ */ new Set();
97
+ for (const file of files) acked.add(file);
98
+ state.sent.set(specId, acked);
99
+ }
100
+ for (const file of payload.boot) state.sentBoot.add(file);
101
+ for (const bucket of payload.actors) {
102
+ const key = actorBucketKey(bucket.tag, bucket.at);
103
+ const acked = state.sentActors.get(key) ?? /* @__PURE__ */ new Set();
104
+ for (const file of bucket.files) acked.add(file);
105
+ state.sentActors.set(key, acked);
106
+ }
107
+ state.lastSentUnattributed = payload.unattributed;
108
+ state.lastSentUninstrumentedFiles = payload.uninstrumentedFiles;
109
+ state.lastSentUninstrumentedProcess = payload.uninstrumentedProcess;
110
+ consecutiveFailures = 0;
111
+ }).catch((error) => {
112
+ state.droppedPushes++;
113
+ consecutiveFailures++;
114
+ if (config) debugLog(config, `push failed: ${String(error)}`);
115
+ if (consecutiveFailures === 1 || consecutiveFailures % 10 === 0) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
116
+ }).finally(() => {
117
+ inFlight = false;
118
+ });
119
+ };
120
+ const timer = setInterval(() => void flush(), options.intervalMs ?? DEFAULT_INTERVAL_MS);
121
+ timer.unref?.();
122
+ let exitFlushes = 0;
123
+ const onBeforeExit = async () => {
124
+ if (consecutiveFailures > 0 || exitFlushes >= MAX_EXIT_FLUSHES) return;
125
+ exitFlushes++;
126
+ await flush();
127
+ };
128
+ process.on("beforeExit", onBeforeExit);
129
+ return () => {
130
+ clearInterval(timer);
131
+ process.off("beforeExit", onBeforeExit);
132
+ };
133
+ }
134
+ /**
135
+ * `acked` is always a subset of `reached`, and neither ever shrinks, so equal
136
+ * sizes mean nothing new — skip the copy-then-filter and check that first.
137
+ */
138
+ function freshOf(reached, acked) {
139
+ if (acked !== void 0 && acked.size === reached.size) return [];
140
+ const fresh = [];
141
+ for (const file of reached) if (acked === void 0 || !acked.has(file)) fresh.push(file);
142
+ return fresh;
143
+ }
144
+ /** Exported for tests: the sink's HTTP round trip is the only real-I/O part. */
145
+ function diff(runtime, state) {
146
+ const specs = {};
147
+ let any = false;
148
+ const now = Date.now();
149
+ for (const [specId, files] of runtime.buckets) {
150
+ const fresh = freshOf(files, state.sent.get(specId));
151
+ if (fresh.length === 0) continue;
152
+ specs[specId] = fresh;
153
+ state.lastChange.set(specId, now);
154
+ any = true;
155
+ }
156
+ const actors = [];
157
+ for (const [key, bucket] of runtime.actors) {
158
+ const fresh = freshOf(bucket.files, state.sentActors.get(key));
159
+ if (fresh.length === 0) continue;
160
+ actors.push({
161
+ tag: bucket.tag,
162
+ at: bucket.at,
163
+ files: fresh
164
+ });
165
+ state.lastChange.set(key, now);
166
+ }
167
+ const boot = freshOf(runtime.boot, state.sentBoot);
168
+ const unattributedChanged = runtime.unattributed !== state.lastSentUnattributed;
169
+ const healthChanged = runtime.uninstrumentedFiles !== state.lastSentUninstrumentedFiles || runtime.uninstrumentedProcess !== state.lastSentUninstrumentedProcess;
170
+ const reannounce = runtime.uninstrumentedProcess && now - state.lastSentAt >= BLIND_HEARTBEAT_MS;
171
+ if (!any && boot.length === 0 && actors.length === 0 && !unattributedChanged && !healthChanged && !reannounce) return;
172
+ state.lastSentAt = now;
173
+ return {
174
+ protocol: 1,
175
+ pid: runtime.pid,
176
+ startedAt: runtime.startedAt,
177
+ unattributed: runtime.unattributed,
178
+ uninstrumentedFiles: runtime.uninstrumentedFiles,
179
+ uninstrumentedProcess: runtime.uninstrumentedProcess,
180
+ specs,
181
+ boot,
182
+ actors,
183
+ droppedPushes: state.droppedPushes
184
+ };
185
+ }
186
+ /**
187
+ * Forgets specs that stopped changing and have nothing outstanding, so a long
188
+ * lived server does not accumulate every spec it has ever served — and so the
189
+ * hot-path gate falls back to zero between runs.
190
+ */
191
+ function evict(runtime, state, idleTtlMs) {
192
+ const now = Date.now();
193
+ const cutoff = now - idleTtlMs;
194
+ const actors = [...runtime.actors].map(([key, bucket]) => [key, bucket.files]);
195
+ dropQuiet([...runtime.buckets], state.sent, state, cutoff, now, (key) => closeBucket(runtime, key));
196
+ dropQuiet(actors, state.sentActors, state, cutoff, now, (key) => closeActorBucket(runtime, key));
197
+ }
198
+ function dropQuiet(entries, sent, state, cutoff, now, close) {
199
+ for (const [key, files] of entries) {
200
+ const seen = state.lastChange.get(key);
201
+ if (seen === void 0) {
202
+ state.lastChange.set(key, now);
203
+ continue;
204
+ }
205
+ if (seen > cutoff) continue;
206
+ if (freshOf(files, sent.get(key)).length > 0) continue;
207
+ close(key);
208
+ sent.delete(key);
209
+ state.lastChange.delete(key);
210
+ }
211
+ }
212
+ async function post(options, payload) {
213
+ const headers = { "content-type": "application/json" };
214
+ if (options.token) headers.authorization = `Bearer ${options.token}`;
215
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
216
+ const response = await fetch(options.endpoint, {
217
+ method: "POST",
218
+ headers,
219
+ body: JSON.stringify(payload),
220
+ signal: AbortSignal.timeout(intervalMs * 5)
221
+ });
222
+ if (!response.ok) throw new Error(`sink returned ${response.status}`);
223
+ }
224
+ //#endregion
225
+ export { createCollectorState, diff, evict, startCollector };
@@ -0,0 +1,176 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/coverage/core.ts
3
+ const RUNTIME_KEY = Symbol.for("ccqa.coverage.runtime");
4
+ /** The name instrumented code calls through. Kept short; it appears per module. */
5
+ const GLOBAL_RECORD = "__ccqaCoverage";
6
+ function globals() {
7
+ return globalThis;
8
+ }
9
+ function getRuntime() {
10
+ return globals()[RUNTIME_KEY];
11
+ }
12
+ /**
13
+ * Memoized so the hot path skips the `globalThis` read. `installRuntime`
14
+ * primes it and never replaces an installed runtime, so once set it never
15
+ * goes stale — but it must stay undefined (and keep re-reading `globalThis`)
16
+ * until then, or a module that finished loading before `register` installs
17
+ * the runtime would be stuck uninstrumented for the rest of the process.
18
+ */
19
+ let cachedRuntime;
20
+ function runtime() {
21
+ if (cachedRuntime === void 0) cachedRuntime = globals()[RUNTIME_KEY];
22
+ return cachedRuntime;
23
+ }
24
+ /**
25
+ * Installs the process-wide runtime, or returns the one already there.
26
+ * Called by `ccqa-tools/coverage/register`; application code never calls it.
27
+ */
28
+ function installRuntime(als) {
29
+ const g = globals();
30
+ const existing = g[RUNTIME_KEY];
31
+ if (existing) {
32
+ cachedRuntime = existing;
33
+ return existing;
34
+ }
35
+ const created = {
36
+ protocol: 1,
37
+ als,
38
+ buckets: /* @__PURE__ */ new Map(),
39
+ actors: /* @__PURE__ */ new Map(),
40
+ boot: /* @__PURE__ */ new Set(),
41
+ active: 0,
42
+ unattributed: 0,
43
+ uninstrumentedFiles: 0,
44
+ uninstrumentedProcess: false,
45
+ pid: typeof process === "undefined" ? -1 : process.pid,
46
+ startedAt: Date.now()
47
+ };
48
+ g[RUNTIME_KEY] = created;
49
+ g[GLOBAL_RECORD] = record;
50
+ cachedRuntime = created;
51
+ return created;
52
+ }
53
+ /**
54
+ * Marks a file as reached. Instrumented code calls this through
55
+ * `globalThis.__ccqaCoverage`, so the call sites cost one global read per
56
+ * module and one truthiness check per invocation when coverage is off.
57
+ *
58
+ * @param topLevel set by the prologue a module runs on first import.
59
+ */
60
+ function record(fileId, topLevel) {
61
+ const rt = runtime();
62
+ if (rt === void 0) return;
63
+ if (topLevel === true) {
64
+ rt.boot.add(fileId);
65
+ return;
66
+ }
67
+ if (rt.active === 0) return;
68
+ const store = rt.als.getStore();
69
+ if (store === void 0) {
70
+ rt.unattributed++;
71
+ return;
72
+ }
73
+ store.files.add(fileId);
74
+ }
75
+ /** The spec the current async context belongs to, if any. */
76
+ function currentSpecId() {
77
+ return runtime()?.als.getStore()?.specId;
78
+ }
79
+ /**
80
+ * Opens `specId`'s bucket and runs `fn` inside its context. Every entry point —
81
+ * HTTP, Temporal activity, manual — funnels through here.
82
+ *
83
+ * The bucket outlives `fn`: work a request schedules and does not await still
84
+ * belongs to the spec that caused it.
85
+ */
86
+ function runInSpec(specId, fn) {
87
+ const rt = runtime();
88
+ if (rt === void 0) return fn();
89
+ return rt.als.run({
90
+ specId,
91
+ files: openBucket(rt, specId)
92
+ }, fn);
93
+ }
94
+ /**
95
+ * Records that `tag` caused this work at `at`, without deciding whose it is.
96
+ *
97
+ * Carrier wins: a request that already named a spec needs no identity, and
98
+ * overwriting it would replace a fact with something the run still has to
99
+ * interpret. Everything else is recorded whoever it came from — the application
100
+ * is never told which identities are being measured, so it cannot filter, and
101
+ * the run discards the ones it did not ask for.
102
+ */
103
+ function runAsActor(tag, at, fn) {
104
+ const rt = runtime();
105
+ if (rt === void 0) return fn();
106
+ if (rt.als.getStore()?.specId !== void 0) return fn();
107
+ return rt.als.run({
108
+ actor: {
109
+ tag,
110
+ at
111
+ },
112
+ files: openActorBucket(rt, tag, at)
113
+ }, fn);
114
+ }
115
+ /** The identity mark on the current async context, if it has one. */
116
+ function currentActor() {
117
+ return runtime()?.als.getStore()?.actor;
118
+ }
119
+ /** Returns `specId`'s file set, creating it — and arming the gate — if new. */
120
+ function openBucket(runtime, specId) {
121
+ let files = runtime.buckets.get(specId);
122
+ if (files === void 0) {
123
+ files = /* @__PURE__ */ new Set();
124
+ runtime.buckets.set(specId, files);
125
+ armGate(runtime);
126
+ }
127
+ return files;
128
+ }
129
+ /**
130
+ * The key both halves of the collector agree on. A space separates them safely:
131
+ * a spec id can never contain one, so identity keys and spec ids stay disjoint
132
+ * where the collector tracks both in one map.
133
+ */
134
+ function actorBucketKey(tag, at) {
135
+ return `${tag} ${at}`;
136
+ }
137
+ /** Returns the identity's file set for that instant, creating it if new. */
138
+ function openActorBucket(runtime, tag, at) {
139
+ const key = actorBucketKey(tag, at);
140
+ let bucket = runtime.actors.get(key);
141
+ if (bucket === void 0) {
142
+ bucket = {
143
+ tag,
144
+ at,
145
+ files: /* @__PURE__ */ new Set()
146
+ };
147
+ runtime.actors.set(key, bucket);
148
+ armGate(runtime);
149
+ }
150
+ return bucket.files;
151
+ }
152
+ /** Drops a spec's bucket once it has been handed to the collector. */
153
+ function closeBucket(runtime, specId) {
154
+ if (runtime.buckets.delete(specId)) armGate(runtime);
155
+ }
156
+ /** Drops an identity's bucket once it has been handed to the collector. */
157
+ function closeActorBucket(runtime, key) {
158
+ if (runtime.actors.delete(key)) armGate(runtime);
159
+ }
160
+ function armGate(runtime) {
161
+ runtime.active = runtime.buckets.size + runtime.actors.size;
162
+ }
163
+ //#endregion
164
+ exports.GLOBAL_RECORD = GLOBAL_RECORD;
165
+ exports.actorBucketKey = actorBucketKey;
166
+ exports.closeActorBucket = closeActorBucket;
167
+ exports.closeBucket = closeBucket;
168
+ exports.currentActor = currentActor;
169
+ exports.currentSpecId = currentSpecId;
170
+ exports.getRuntime = getRuntime;
171
+ exports.installRuntime = installRuntime;
172
+ exports.openActorBucket = openActorBucket;
173
+ exports.openBucket = openBucket;
174
+ exports.record = record;
175
+ exports.runAsActor = runAsActor;
176
+ exports.runInSpec = runInSpec;