@couimet/execution-context 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charles Ouimet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @couimet/execution-context
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@couimet/execution-context.svg?style=flat-square)](https://www.npmjs.com/package/@couimet/execution-context) [![Coverage](https://codecov.io/gh/couimet/ts-npm-packages/branch/main/graph/badge.svg?flag=execution-context)](https://codecov.io/gh/couimet/ts-npm-packages?flag=execution-context) [![npm downloads](https://img.shields.io/npm/dm/@couimet/execution-context.svg?style=flat-square)](https://www.npmjs.com/package/@couimet/execution-context)
4
+
5
+ `ExecutionContext.run()` opens a scope that carries a correlation id, a request id, and an attribute bag. Code inside the scope, including work resumed after `await`, reads the same ids and attributes. The scope follows OpenTelemetry's context propagation, which `AsyncLocalStorage` carries across async boundaries. `run()` pins a provided id and generates a fresh one when a field is `undefined` or blank. Typical priming sites are an application bootstrap, a middleware that scopes one HTTP request, and a timer that scopes one scheduled run.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @couimet/execution-context
11
+ ```
12
+
13
+ The package declares `@opentelemetry/api` and `@couimet/detailed-error` as peer dependencies, and pnpm or npm installs them automatically. The `AsyncLocalStorage` context manager that `run()` relies on ships as the direct dependency `@opentelemetry/context-async-hooks`.
14
+
15
+ ## Usage
16
+
17
+ ```typescript
18
+ import { CorrelationId, ExecutionContext } from '@couimet/execution-context';
19
+
20
+ ExecutionContext.run({ correlationId: 'my-job', requestId: 'abc-123', attributes: { userId: 'u-42' } }, () => {
21
+ const id = ExecutionContext.correlationId.toString(); // 'my-job'
22
+ ExecutionContext.addAttributes({ attempt: 2 });
23
+ // awaited work below still reads this scope
24
+ });
25
+
26
+ ExecutionContext.isActive(); // false, once the run has returned
27
+ ```
28
+
29
+ Pass an id to pin it. Pass `undefined` or a blank string, and `run()` generates a fresh one through `fromStringOrCreate()`. An explicit blank passed to `fromString()` throws instead. A nested `run()` starts a fresh scope, so it does not inherit the outer ids unless the caller passes them in.
30
+
31
+ ## How it works
32
+
33
+ A `run()` executes its callback inside a new OpenTelemetry context. It first builds a store that holds a `CorrelationId`, a `RequestId`, and an attribute bag. Then it installs that store on the active context and runs the callback. When the callback returns or throws, the previous context is restored. Sibling `run()` calls therefore never see each other's values.
34
+
35
+ The callback may be sync or async. `run` returns whatever the callback returns. An async callback's awaited work still reads the same context, because `AsyncLocalStorage` propagates the context through the async chain. One request id can therefore follow a log line emitted deep inside an awaited service call.
36
+
37
+ The package installs the OpenTelemetry global context manager exactly once. The first `run()` performs the install lazily. Callers that never `run` can still install the manager up front with `ensureContextManagerInitialized()`. The global manager can be set only once, and another component may already own the slot. When it does, `run()` and `ensureContextManagerInitialized()` throw a `DetailedError` with code `CONTEXT_MANAGER_REGISTRATION_FAILED` rather than propagate ids through a manager this package cannot verify.
38
+
39
+ ## API reference
40
+
41
+ The barrel re-exports four modules: `ExecutionContext`, the `CorrelationId` and `RequestId` value objects, and the `ExecutionContextErrorCodes` enum.
42
+
43
+ ### CorrelationId
44
+
45
+ An opaque value object that wraps a correlation id string. The wrapped value is private, so `toString()` is the only way to read it. Compare two ids through their `toString()` results, not by reference.
46
+
47
+ ```typescript
48
+ class CorrelationId {
49
+ static create(): CorrelationId;
50
+ static fromString(value: string): CorrelationId;
51
+ static fromStringOrCreate(value: string | undefined): CorrelationId;
52
+ toString(): string;
53
+ }
54
+ ```
55
+
56
+ - `create()` returns a fresh id from a UUID v4.
57
+ - `fromString(value)` wraps `value` when it is non-blank. Otherwise it throws a `DetailedError` with code `INVALID_BLANK_CORRELATION_ID`, message `correlationId must be a non-blank string`, and `functionName` `CorrelationId.fromString`. The `details` carry the offending value. A non-primitive value is rejected by the shared string guard with code `INVALID_STRING_TYPE`.
58
+ - `fromStringOrCreate(value)` never throws for a primitive string or `undefined`. A non-blank value is pinned through `fromString`, a blank or missing value falls back to `create()`, and a non-primitive value is rejected by the shared string guard with code `INVALID_STRING_TYPE`.
59
+ - `toString()` returns the wrapped string.
60
+
61
+ ### ExecutionContext
62
+
63
+ A static-only runner whose constructor is private. Every member reads or writes the store active on the current OpenTelemetry context.
64
+
65
+ ```typescript
66
+ type ContextAttributes = Record<string, unknown>;
67
+
68
+ interface RunParams {
69
+ readonly correlationId: string | undefined;
70
+ readonly requestId: string | undefined;
71
+ readonly attributes?: ContextAttributes;
72
+ }
73
+
74
+ class ExecutionContext {
75
+ static ensureContextManagerInitialized(): void;
76
+ static run<T>(data: RunParams, fn: () => T): T;
77
+ static isActive(): boolean;
78
+ static get correlationId(): CorrelationId;
79
+ static get requestId(): RequestId;
80
+ static getAttribute(key: string): unknown;
81
+ static addAttributes(attrs: ContextAttributes): void;
82
+ static getAttributes(): ContextAttributes;
83
+ }
84
+ ```
85
+
86
+ - `ensureContextManagerInitialized()` installs the OpenTelemetry `AsyncLocalStorage` context manager as the global manager. It is idempotent, so a second call is a no-op. `run()` calls it automatically on first use. It throws a `DetailedError` with code `CONTEXT_MANAGER_REGISTRATION_FAILED` when another component already owns the global manager slot.
87
+ - `run<T>(data, fn)` primes a fresh store and runs `fn` inside that context. Both ids go through `fromStringOrCreate`, and attributes default to `{}`. Attributes that are null, an array, or not an object throw a `DetailedError` with code `INVALID_CONTEXT_ATTRIBUTES`. It returns whatever `fn` returns. Anything previously active is replaced for the duration of `fn`, then restored when `fn` returns or throws.
88
+ - `isActive()` returns true when the current code is executing inside a `run` block.
89
+ - The `correlationId` and `requestId` getters return the primed id. Called outside a run, they throw a `DetailedError` with code `NO_ACTIVE_CONTEXT`.
90
+ - `getAttribute(key)` returns the stored attribute for `key`, or `undefined` when the key is absent or no scope is active.
91
+ - `addAttributes(attrs)` merges `attrs` into the active attribute bag, with later keys winning. Attributes that are null, an array, or not an object throw a `DetailedError` with code `INVALID_CONTEXT_ATTRIBUTES` before the merge. It replaces the bag object, so a reference captured earlier does not see the merge. It is a no-op when no scope is active.
92
+ - `getAttributes()` returns the active attribute bag, which is the live bag rather than a copy. It returns `{}` when no scope is active.
93
+
94
+ ### ExecutionContextErrorCodes
95
+
96
+ Error codes used by the `DetailedError` instances that this package throws:
97
+
98
+ ```typescript
99
+ enum ExecutionContextErrorCodes {
100
+ INVALID_BLANK_CORRELATION_ID = 'INVALID_BLANK_CORRELATION_ID',
101
+ INVALID_BLANK_REQUEST_ID = 'INVALID_BLANK_REQUEST_ID',
102
+ NO_ACTIVE_CONTEXT = 'NO_ACTIVE_CONTEXT',
103
+ CONTEXT_MANAGER_REGISTRATION_FAILED = 'CONTEXT_MANAGER_REGISTRATION_FAILED',
104
+ INVALID_CONTEXT_ATTRIBUTES = 'INVALID_CONTEXT_ATTRIBUTES',
105
+ INVALID_STRING_TYPE = 'INVALID_STRING_TYPE',
106
+ }
107
+ ```
108
+
109
+ `fromString` on either id throws `INVALID_BLANK_CORRELATION_ID` or `INVALID_BLANK_REQUEST_ID`. The two getters throw `NO_ACTIVE_CONTEXT`. `ensureContextManagerInitialized()` throws `CONTEXT_MANAGER_REGISTRATION_FAILED` when another component already owns the global context manager slot. `run()` and `addAttributes()` throw `INVALID_CONTEXT_ATTRIBUTES` when attributes are null, an array, or not an object. `fromString` and `fromStringOrCreate` on either id throw `INVALID_STRING_TYPE` when the value is not a primitive string, such as a boxed `String`. Every error is a `DetailedError` from [`@couimet/detailed-error`](https://github.com/couimet/ts-npm-packages/tree/main/packages/detailed-error).
110
+
111
+ ### RequestId
112
+
113
+ Identical in shape to `CorrelationId`, holding a request id string:
114
+
115
+ ```typescript
116
+ class RequestId {
117
+ static create(): RequestId;
118
+ static fromString(value: string): RequestId;
119
+ static fromStringOrCreate(value: string | undefined): RequestId;
120
+ toString(): string;
121
+ }
122
+ ```
123
+
124
+ The behavior matches `CorrelationId`, with `INVALID_BLANK_REQUEST_ID` thrown by `fromString` on a blank value and `INVALID_STRING_TYPE` thrown when the value is not a primitive string.
125
+
126
+ ## Package family
127
+
128
+ `@couimet/execution-context` holds only the scope machinery. It ships the `ExecutionContext` runner, the `CorrelationId` and `RequestId` value objects, the attribute-bag types, and `ExecutionContextErrorCodes`. It carries no knowledge of HTTP or of any framework.
129
+
130
+ Companion packages group the transport and framework concerns, and each carries its integration in the package name. One companion exists today:
131
+
132
+ - `@couimet/execution-context-http` exports the wire header names (`x-correlation-id`, `x-request-id`) as the `HttpHeaders` enum. It has no framework dependency.
133
+
134
+ Future framework adapters follow the same shape. The adapter reads the two header names from `@couimet/execution-context-http`, pulls the ids off an incoming request, and calls `ExecutionContext.run()` with them. Each adapter lives in its own package, such as `@couimet/execution-context-http-middy` or `@couimet/execution-context-http-koa`. The core package never depends on a framework.
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,62 @@
1
+ declare class CorrelationId {
2
+ private readonly value;
3
+ private constructor();
4
+ static create(): CorrelationId;
5
+ static fromString(value: string): CorrelationId;
6
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
7
+ static fromStringOrCreate(value: string | undefined): CorrelationId;
8
+ toString(): string;
9
+ }
10
+
11
+ declare class RequestId {
12
+ private readonly value;
13
+ private constructor();
14
+ static create(): RequestId;
15
+ static fromString(value: string): RequestId;
16
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
17
+ static fromStringOrCreate(value: string | undefined): RequestId;
18
+ toString(): string;
19
+ }
20
+
21
+ type ContextAttributes = Record<string, unknown>;
22
+ interface RunParams {
23
+ readonly correlationId: string | undefined;
24
+ readonly requestId: string | undefined;
25
+ readonly attributes?: ContextAttributes;
26
+ }
27
+ declare class ExecutionContext {
28
+ private static contextInitialized;
29
+ private constructor();
30
+ /**
31
+ * Installs the global context manager exactly once. Idempotent: later calls
32
+ * are a no-op once installed. Throws when another component already owns the
33
+ * global manager slot, because the package then cannot guarantee that async
34
+ * work inherits the primed ids and failing loud beats silent loss of ids.
35
+ */
36
+ static ensureContextManagerInitialized(): void;
37
+ private static getStore;
38
+ /**
39
+ * Primes the execution context; anything previously set gets wiped.
40
+ * Call sites are the app bootstrap, middleware priming the context from a
41
+ * request, and timer runs scoping a single execution.
42
+ */
43
+ static run<T>(data: RunParams, fn: () => T): T;
44
+ static isActive(): boolean;
45
+ private static requireStore;
46
+ static get correlationId(): CorrelationId;
47
+ static get requestId(): RequestId;
48
+ static getAttribute(key: string): unknown;
49
+ static addAttributes(attrs: ContextAttributes): void;
50
+ static getAttributes(): ContextAttributes;
51
+ }
52
+
53
+ declare enum ExecutionContextErrorCodes {
54
+ INVALID_BLANK_CORRELATION_ID = "INVALID_BLANK_CORRELATION_ID",
55
+ INVALID_BLANK_REQUEST_ID = "INVALID_BLANK_REQUEST_ID",
56
+ INVALID_CONTEXT_ATTRIBUTES = "INVALID_CONTEXT_ATTRIBUTES",
57
+ INVALID_STRING_TYPE = "INVALID_STRING_TYPE",
58
+ NO_ACTIVE_CONTEXT = "NO_ACTIVE_CONTEXT",
59
+ CONTEXT_MANAGER_REGISTRATION_FAILED = "CONTEXT_MANAGER_REGISTRATION_FAILED"
60
+ }
61
+
62
+ export { type ContextAttributes, CorrelationId, ExecutionContext, ExecutionContextErrorCodes, RequestId, type RunParams };
@@ -0,0 +1,62 @@
1
+ declare class CorrelationId {
2
+ private readonly value;
3
+ private constructor();
4
+ static create(): CorrelationId;
5
+ static fromString(value: string): CorrelationId;
6
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
7
+ static fromStringOrCreate(value: string | undefined): CorrelationId;
8
+ toString(): string;
9
+ }
10
+
11
+ declare class RequestId {
12
+ private readonly value;
13
+ private constructor();
14
+ static create(): RequestId;
15
+ static fromString(value: string): RequestId;
16
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
17
+ static fromStringOrCreate(value: string | undefined): RequestId;
18
+ toString(): string;
19
+ }
20
+
21
+ type ContextAttributes = Record<string, unknown>;
22
+ interface RunParams {
23
+ readonly correlationId: string | undefined;
24
+ readonly requestId: string | undefined;
25
+ readonly attributes?: ContextAttributes;
26
+ }
27
+ declare class ExecutionContext {
28
+ private static contextInitialized;
29
+ private constructor();
30
+ /**
31
+ * Installs the global context manager exactly once. Idempotent: later calls
32
+ * are a no-op once installed. Throws when another component already owns the
33
+ * global manager slot, because the package then cannot guarantee that async
34
+ * work inherits the primed ids and failing loud beats silent loss of ids.
35
+ */
36
+ static ensureContextManagerInitialized(): void;
37
+ private static getStore;
38
+ /**
39
+ * Primes the execution context; anything previously set gets wiped.
40
+ * Call sites are the app bootstrap, middleware priming the context from a
41
+ * request, and timer runs scoping a single execution.
42
+ */
43
+ static run<T>(data: RunParams, fn: () => T): T;
44
+ static isActive(): boolean;
45
+ private static requireStore;
46
+ static get correlationId(): CorrelationId;
47
+ static get requestId(): RequestId;
48
+ static getAttribute(key: string): unknown;
49
+ static addAttributes(attrs: ContextAttributes): void;
50
+ static getAttributes(): ContextAttributes;
51
+ }
52
+
53
+ declare enum ExecutionContextErrorCodes {
54
+ INVALID_BLANK_CORRELATION_ID = "INVALID_BLANK_CORRELATION_ID",
55
+ INVALID_BLANK_REQUEST_ID = "INVALID_BLANK_REQUEST_ID",
56
+ INVALID_CONTEXT_ATTRIBUTES = "INVALID_CONTEXT_ATTRIBUTES",
57
+ INVALID_STRING_TYPE = "INVALID_STRING_TYPE",
58
+ NO_ACTIVE_CONTEXT = "NO_ACTIVE_CONTEXT",
59
+ CONTEXT_MANAGER_REGISTRATION_FAILED = "CONTEXT_MANAGER_REGISTRATION_FAILED"
60
+ }
61
+
62
+ export { type ContextAttributes, CorrelationId, ExecutionContext, ExecutionContextErrorCodes, RequestId, type RunParams };
package/dist/index.js ADDED
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ CorrelationId: () => CorrelationId,
24
+ ExecutionContext: () => ExecutionContext,
25
+ ExecutionContextErrorCodes: () => ExecutionContextErrorCodes,
26
+ RequestId: () => RequestId
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/executionContextErrorCodes.ts
31
+ var ExecutionContextErrorCodes = /* @__PURE__ */ ((ExecutionContextErrorCodes2) => {
32
+ ExecutionContextErrorCodes2["INVALID_BLANK_CORRELATION_ID"] = "INVALID_BLANK_CORRELATION_ID";
33
+ ExecutionContextErrorCodes2["INVALID_BLANK_REQUEST_ID"] = "INVALID_BLANK_REQUEST_ID";
34
+ ExecutionContextErrorCodes2["INVALID_CONTEXT_ATTRIBUTES"] = "INVALID_CONTEXT_ATTRIBUTES";
35
+ ExecutionContextErrorCodes2["INVALID_STRING_TYPE"] = "INVALID_STRING_TYPE";
36
+ ExecutionContextErrorCodes2["NO_ACTIVE_CONTEXT"] = "NO_ACTIVE_CONTEXT";
37
+ ExecutionContextErrorCodes2["CONTEXT_MANAGER_REGISTRATION_FAILED"] = "CONTEXT_MANAGER_REGISTRATION_FAILED";
38
+ return ExecutionContextErrorCodes2;
39
+ })(ExecutionContextErrorCodes || {});
40
+
41
+ // src/isNonBlank.ts
42
+ var import_detailed_error = require("@couimet/detailed-error");
43
+ var isNonBlank = (value) => {
44
+ if (value === void 0) {
45
+ return false;
46
+ }
47
+ if (typeof value !== "string") {
48
+ throw new import_detailed_error.DetailedError({
49
+ code: "INVALID_STRING_TYPE" /* INVALID_STRING_TYPE */,
50
+ message: "expected a primitive string or undefined",
51
+ functionName: "isNonBlank",
52
+ details: {}
53
+ });
54
+ }
55
+ return value.trim() !== "";
56
+ };
57
+
58
+ // src/correlationId.ts
59
+ var import_detailed_error2 = require("@couimet/detailed-error");
60
+
61
+ // ../../node_modules/.pnpm/uuid@14.0.1/node_modules/uuid/dist-node/stringify.js
62
+ var byteToHex = [];
63
+ for (let i = 0; i < 256; ++i) {
64
+ byteToHex.push((i + 256).toString(16).slice(1));
65
+ }
66
+ function unsafeStringify(arr, offset = 0) {
67
+ return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
68
+ }
69
+
70
+ // ../../node_modules/.pnpm/uuid@14.0.1/node_modules/uuid/dist-node/rng.js
71
+ var rnds8 = new Uint8Array(16);
72
+ function rng() {
73
+ return crypto.getRandomValues(rnds8);
74
+ }
75
+
76
+ // ../../node_modules/.pnpm/uuid@14.0.1/node_modules/uuid/dist-node/v4.js
77
+ function v4(options, buf, offset) {
78
+ if (!buf && !options && crypto.randomUUID) {
79
+ return crypto.randomUUID();
80
+ }
81
+ return _v4(options, buf, offset);
82
+ }
83
+ function _v4(options, buf, offset) {
84
+ options = options || {};
85
+ const rnds = options.random ?? options.rng?.() ?? rng();
86
+ if (rnds.length < 16) {
87
+ throw new Error("Random bytes length must be >= 16");
88
+ }
89
+ rnds[6] = rnds[6] & 15 | 64;
90
+ rnds[8] = rnds[8] & 63 | 128;
91
+ if (buf) {
92
+ offset = offset || 0;
93
+ if (offset < 0 || offset + 16 > buf.length) {
94
+ throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
95
+ }
96
+ for (let i = 0; i < 16; ++i) {
97
+ buf[offset + i] = rnds[i];
98
+ }
99
+ return buf;
100
+ }
101
+ return unsafeStringify(rnds);
102
+ }
103
+ var v4_default = v4;
104
+
105
+ // src/correlationId.ts
106
+ var CorrelationId = class _CorrelationId {
107
+ constructor(value) {
108
+ this.value = value;
109
+ }
110
+ value;
111
+ static create() {
112
+ return new _CorrelationId(v4_default());
113
+ }
114
+ static fromString(value) {
115
+ if (isNonBlank(value)) {
116
+ return new _CorrelationId(value);
117
+ }
118
+ throw new import_detailed_error2.DetailedError({
119
+ code: "INVALID_BLANK_CORRELATION_ID" /* INVALID_BLANK_CORRELATION_ID */,
120
+ message: "correlationId must be a non-blank string",
121
+ functionName: "CorrelationId.fromString",
122
+ details: { value }
123
+ });
124
+ }
125
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
126
+ static fromStringOrCreate(value) {
127
+ if (isNonBlank(value)) {
128
+ return _CorrelationId.fromString(value);
129
+ }
130
+ return _CorrelationId.create();
131
+ }
132
+ toString() {
133
+ return this.value;
134
+ }
135
+ };
136
+
137
+ // src/requestId.ts
138
+ var import_detailed_error3 = require("@couimet/detailed-error");
139
+ var RequestId = class _RequestId {
140
+ constructor(value) {
141
+ this.value = value;
142
+ }
143
+ value;
144
+ static create() {
145
+ return new _RequestId(v4_default());
146
+ }
147
+ static fromString(value) {
148
+ if (isNonBlank(value)) {
149
+ return new _RequestId(value);
150
+ }
151
+ throw new import_detailed_error3.DetailedError({
152
+ code: "INVALID_BLANK_REQUEST_ID" /* INVALID_BLANK_REQUEST_ID */,
153
+ message: "requestId must be a non-blank string",
154
+ functionName: "RequestId.fromString",
155
+ details: { value }
156
+ });
157
+ }
158
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
159
+ static fromStringOrCreate(value) {
160
+ if (isNonBlank(value)) {
161
+ return _RequestId.fromString(value);
162
+ }
163
+ return _RequestId.create();
164
+ }
165
+ toString() {
166
+ return this.value;
167
+ }
168
+ };
169
+
170
+ // src/executionContext.ts
171
+ var import_detailed_error4 = require("@couimet/detailed-error");
172
+ var import_api = require("@opentelemetry/api");
173
+ var import_context_async_hooks = require("@opentelemetry/context-async-hooks");
174
+ var EXECUTION_CONTEXT_KEY = (0, import_api.createContextKey)("ExecutionContext");
175
+ function assertContextAttributes(attributes, functionName) {
176
+ if (attributes === void 0) {
177
+ return;
178
+ }
179
+ const isRecord = attributes !== null && typeof attributes === "object" && !Array.isArray(attributes);
180
+ if (!isRecord) {
181
+ throw new import_detailed_error4.DetailedError({
182
+ code: "INVALID_CONTEXT_ATTRIBUTES" /* INVALID_CONTEXT_ATTRIBUTES */,
183
+ message: "attributes must be a record of string keys to unknown values",
184
+ functionName,
185
+ details: {}
186
+ });
187
+ }
188
+ }
189
+ var ExecutionContext = class _ExecutionContext {
190
+ static contextInitialized = false;
191
+ /* istanbul ignore next */
192
+ constructor() {
193
+ }
194
+ /**
195
+ * Installs the global context manager exactly once. Idempotent: later calls
196
+ * are a no-op once installed. Throws when another component already owns the
197
+ * global manager slot, because the package then cannot guarantee that async
198
+ * work inherits the primed ids and failing loud beats silent loss of ids.
199
+ */
200
+ static ensureContextManagerInitialized() {
201
+ if (_ExecutionContext.contextInitialized) {
202
+ return;
203
+ }
204
+ const manager = new import_context_async_hooks.AsyncLocalStorageContextManager().enable();
205
+ const registered = import_api.context.setGlobalContextManager(manager);
206
+ if (!registered) {
207
+ manager.disable();
208
+ throw new import_detailed_error4.DetailedError({
209
+ code: "CONTEXT_MANAGER_REGISTRATION_FAILED" /* CONTEXT_MANAGER_REGISTRATION_FAILED */,
210
+ message: "a global context manager is already registered",
211
+ functionName: "ExecutionContext.ensureContextManagerInitialized",
212
+ details: {}
213
+ });
214
+ }
215
+ _ExecutionContext.contextInitialized = true;
216
+ }
217
+ static getStore() {
218
+ return import_api.context.active().getValue(EXECUTION_CONTEXT_KEY);
219
+ }
220
+ /**
221
+ * Primes the execution context; anything previously set gets wiped.
222
+ * Call sites are the app bootstrap, middleware priming the context from a
223
+ * request, and timer runs scoping a single execution.
224
+ */
225
+ static run(data, fn) {
226
+ this.ensureContextManagerInitialized();
227
+ assertContextAttributes(data.attributes, "ExecutionContext.run");
228
+ const newContext = {
229
+ correlationId: CorrelationId.fromStringOrCreate(data.correlationId),
230
+ requestId: RequestId.fromStringOrCreate(data.requestId),
231
+ attributes: data.attributes ?? {}
232
+ };
233
+ const ctx = import_api.context.active().setValue(EXECUTION_CONTEXT_KEY, newContext);
234
+ return import_api.context.with(ctx, fn);
235
+ }
236
+ static isActive() {
237
+ return this.getStore() !== void 0;
238
+ }
239
+ // The ids are guaranteed when the context is active; a missing store is a programming error.
240
+ static requireStore() {
241
+ const store = this.getStore();
242
+ if (store === void 0) {
243
+ throw new import_detailed_error4.DetailedError({
244
+ code: "NO_ACTIVE_CONTEXT" /* NO_ACTIVE_CONTEXT */,
245
+ message: "execution context is not active",
246
+ functionName: "ExecutionContext.requireStore",
247
+ details: {}
248
+ });
249
+ }
250
+ return store;
251
+ }
252
+ static get correlationId() {
253
+ return this.requireStore().correlationId;
254
+ }
255
+ static get requestId() {
256
+ return this.requireStore().requestId;
257
+ }
258
+ static getAttribute(key) {
259
+ const attributes = this.getStore()?.attributes;
260
+ return attributes !== void 0 && Object.prototype.hasOwnProperty.call(attributes, key) ? attributes[key] : void 0;
261
+ }
262
+ static addAttributes(attrs) {
263
+ assertContextAttributes(attrs, "ExecutionContext.addAttributes");
264
+ const store = this.getStore();
265
+ if (!store) return;
266
+ store.attributes = {
267
+ ...store.attributes,
268
+ ...attrs
269
+ };
270
+ }
271
+ static getAttributes() {
272
+ return this.getStore()?.attributes ?? {};
273
+ }
274
+ };
275
+ // Annotate the CommonJS export names for ESM import in node:
276
+ 0 && (module.exports = {
277
+ CorrelationId,
278
+ ExecutionContext,
279
+ ExecutionContextErrorCodes,
280
+ RequestId
281
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // src/executionContextErrorCodes.ts
2
+ var ExecutionContextErrorCodes = /* @__PURE__ */ ((ExecutionContextErrorCodes2) => {
3
+ ExecutionContextErrorCodes2["INVALID_BLANK_CORRELATION_ID"] = "INVALID_BLANK_CORRELATION_ID";
4
+ ExecutionContextErrorCodes2["INVALID_BLANK_REQUEST_ID"] = "INVALID_BLANK_REQUEST_ID";
5
+ ExecutionContextErrorCodes2["INVALID_CONTEXT_ATTRIBUTES"] = "INVALID_CONTEXT_ATTRIBUTES";
6
+ ExecutionContextErrorCodes2["INVALID_STRING_TYPE"] = "INVALID_STRING_TYPE";
7
+ ExecutionContextErrorCodes2["NO_ACTIVE_CONTEXT"] = "NO_ACTIVE_CONTEXT";
8
+ ExecutionContextErrorCodes2["CONTEXT_MANAGER_REGISTRATION_FAILED"] = "CONTEXT_MANAGER_REGISTRATION_FAILED";
9
+ return ExecutionContextErrorCodes2;
10
+ })(ExecutionContextErrorCodes || {});
11
+
12
+ // src/isNonBlank.ts
13
+ import { DetailedError } from "@couimet/detailed-error";
14
+ var isNonBlank = (value) => {
15
+ if (value === void 0) {
16
+ return false;
17
+ }
18
+ if (typeof value !== "string") {
19
+ throw new DetailedError({
20
+ code: "INVALID_STRING_TYPE" /* INVALID_STRING_TYPE */,
21
+ message: "expected a primitive string or undefined",
22
+ functionName: "isNonBlank",
23
+ details: {}
24
+ });
25
+ }
26
+ return value.trim() !== "";
27
+ };
28
+
29
+ // src/correlationId.ts
30
+ import { DetailedError as DetailedError2 } from "@couimet/detailed-error";
31
+
32
+ // ../../node_modules/.pnpm/uuid@14.0.1/node_modules/uuid/dist-node/stringify.js
33
+ var byteToHex = [];
34
+ for (let i = 0; i < 256; ++i) {
35
+ byteToHex.push((i + 256).toString(16).slice(1));
36
+ }
37
+ function unsafeStringify(arr, offset = 0) {
38
+ return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
39
+ }
40
+
41
+ // ../../node_modules/.pnpm/uuid@14.0.1/node_modules/uuid/dist-node/rng.js
42
+ var rnds8 = new Uint8Array(16);
43
+ function rng() {
44
+ return crypto.getRandomValues(rnds8);
45
+ }
46
+
47
+ // ../../node_modules/.pnpm/uuid@14.0.1/node_modules/uuid/dist-node/v4.js
48
+ function v4(options, buf, offset) {
49
+ if (!buf && !options && crypto.randomUUID) {
50
+ return crypto.randomUUID();
51
+ }
52
+ return _v4(options, buf, offset);
53
+ }
54
+ function _v4(options, buf, offset) {
55
+ options = options || {};
56
+ const rnds = options.random ?? options.rng?.() ?? rng();
57
+ if (rnds.length < 16) {
58
+ throw new Error("Random bytes length must be >= 16");
59
+ }
60
+ rnds[6] = rnds[6] & 15 | 64;
61
+ rnds[8] = rnds[8] & 63 | 128;
62
+ if (buf) {
63
+ offset = offset || 0;
64
+ if (offset < 0 || offset + 16 > buf.length) {
65
+ throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
66
+ }
67
+ for (let i = 0; i < 16; ++i) {
68
+ buf[offset + i] = rnds[i];
69
+ }
70
+ return buf;
71
+ }
72
+ return unsafeStringify(rnds);
73
+ }
74
+ var v4_default = v4;
75
+
76
+ // src/correlationId.ts
77
+ var CorrelationId = class _CorrelationId {
78
+ constructor(value) {
79
+ this.value = value;
80
+ }
81
+ value;
82
+ static create() {
83
+ return new _CorrelationId(v4_default());
84
+ }
85
+ static fromString(value) {
86
+ if (isNonBlank(value)) {
87
+ return new _CorrelationId(value);
88
+ }
89
+ throw new DetailedError2({
90
+ code: "INVALID_BLANK_CORRELATION_ID" /* INVALID_BLANK_CORRELATION_ID */,
91
+ message: "correlationId must be a non-blank string",
92
+ functionName: "CorrelationId.fromString",
93
+ details: { value }
94
+ });
95
+ }
96
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
97
+ static fromStringOrCreate(value) {
98
+ if (isNonBlank(value)) {
99
+ return _CorrelationId.fromString(value);
100
+ }
101
+ return _CorrelationId.create();
102
+ }
103
+ toString() {
104
+ return this.value;
105
+ }
106
+ };
107
+
108
+ // src/requestId.ts
109
+ import { DetailedError as DetailedError3 } from "@couimet/detailed-error";
110
+ var RequestId = class _RequestId {
111
+ constructor(value) {
112
+ this.value = value;
113
+ }
114
+ value;
115
+ static create() {
116
+ return new _RequestId(v4_default());
117
+ }
118
+ static fromString(value) {
119
+ if (isNonBlank(value)) {
120
+ return new _RequestId(value);
121
+ }
122
+ throw new DetailedError3({
123
+ code: "INVALID_BLANK_REQUEST_ID" /* INVALID_BLANK_REQUEST_ID */,
124
+ message: "requestId must be a non-blank string",
125
+ functionName: "RequestId.fromString",
126
+ details: { value }
127
+ });
128
+ }
129
+ /** Never throws for a primitive string or undefined: blank or missing values fall back to a generated id. */
130
+ static fromStringOrCreate(value) {
131
+ if (isNonBlank(value)) {
132
+ return _RequestId.fromString(value);
133
+ }
134
+ return _RequestId.create();
135
+ }
136
+ toString() {
137
+ return this.value;
138
+ }
139
+ };
140
+
141
+ // src/executionContext.ts
142
+ import { DetailedError as DetailedError4 } from "@couimet/detailed-error";
143
+ import { context, createContextKey } from "@opentelemetry/api";
144
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
145
+ var EXECUTION_CONTEXT_KEY = createContextKey("ExecutionContext");
146
+ function assertContextAttributes(attributes, functionName) {
147
+ if (attributes === void 0) {
148
+ return;
149
+ }
150
+ const isRecord = attributes !== null && typeof attributes === "object" && !Array.isArray(attributes);
151
+ if (!isRecord) {
152
+ throw new DetailedError4({
153
+ code: "INVALID_CONTEXT_ATTRIBUTES" /* INVALID_CONTEXT_ATTRIBUTES */,
154
+ message: "attributes must be a record of string keys to unknown values",
155
+ functionName,
156
+ details: {}
157
+ });
158
+ }
159
+ }
160
+ var ExecutionContext = class _ExecutionContext {
161
+ static contextInitialized = false;
162
+ /* istanbul ignore next */
163
+ constructor() {
164
+ }
165
+ /**
166
+ * Installs the global context manager exactly once. Idempotent: later calls
167
+ * are a no-op once installed. Throws when another component already owns the
168
+ * global manager slot, because the package then cannot guarantee that async
169
+ * work inherits the primed ids and failing loud beats silent loss of ids.
170
+ */
171
+ static ensureContextManagerInitialized() {
172
+ if (_ExecutionContext.contextInitialized) {
173
+ return;
174
+ }
175
+ const manager = new AsyncLocalStorageContextManager().enable();
176
+ const registered = context.setGlobalContextManager(manager);
177
+ if (!registered) {
178
+ manager.disable();
179
+ throw new DetailedError4({
180
+ code: "CONTEXT_MANAGER_REGISTRATION_FAILED" /* CONTEXT_MANAGER_REGISTRATION_FAILED */,
181
+ message: "a global context manager is already registered",
182
+ functionName: "ExecutionContext.ensureContextManagerInitialized",
183
+ details: {}
184
+ });
185
+ }
186
+ _ExecutionContext.contextInitialized = true;
187
+ }
188
+ static getStore() {
189
+ return context.active().getValue(EXECUTION_CONTEXT_KEY);
190
+ }
191
+ /**
192
+ * Primes the execution context; anything previously set gets wiped.
193
+ * Call sites are the app bootstrap, middleware priming the context from a
194
+ * request, and timer runs scoping a single execution.
195
+ */
196
+ static run(data, fn) {
197
+ this.ensureContextManagerInitialized();
198
+ assertContextAttributes(data.attributes, "ExecutionContext.run");
199
+ const newContext = {
200
+ correlationId: CorrelationId.fromStringOrCreate(data.correlationId),
201
+ requestId: RequestId.fromStringOrCreate(data.requestId),
202
+ attributes: data.attributes ?? {}
203
+ };
204
+ const ctx = context.active().setValue(EXECUTION_CONTEXT_KEY, newContext);
205
+ return context.with(ctx, fn);
206
+ }
207
+ static isActive() {
208
+ return this.getStore() !== void 0;
209
+ }
210
+ // The ids are guaranteed when the context is active; a missing store is a programming error.
211
+ static requireStore() {
212
+ const store = this.getStore();
213
+ if (store === void 0) {
214
+ throw new DetailedError4({
215
+ code: "NO_ACTIVE_CONTEXT" /* NO_ACTIVE_CONTEXT */,
216
+ message: "execution context is not active",
217
+ functionName: "ExecutionContext.requireStore",
218
+ details: {}
219
+ });
220
+ }
221
+ return store;
222
+ }
223
+ static get correlationId() {
224
+ return this.requireStore().correlationId;
225
+ }
226
+ static get requestId() {
227
+ return this.requireStore().requestId;
228
+ }
229
+ static getAttribute(key) {
230
+ const attributes = this.getStore()?.attributes;
231
+ return attributes !== void 0 && Object.prototype.hasOwnProperty.call(attributes, key) ? attributes[key] : void 0;
232
+ }
233
+ static addAttributes(attrs) {
234
+ assertContextAttributes(attrs, "ExecutionContext.addAttributes");
235
+ const store = this.getStore();
236
+ if (!store) return;
237
+ store.attributes = {
238
+ ...store.attributes,
239
+ ...attrs
240
+ };
241
+ }
242
+ static getAttributes() {
243
+ return this.getStore()?.attributes ?? {};
244
+ }
245
+ };
246
+ export {
247
+ CorrelationId,
248
+ ExecutionContext,
249
+ ExecutionContextErrorCodes,
250
+ RequestId
251
+ };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@couimet/execution-context",
3
+ "version": "0.1.0",
4
+ "description": "Execution context carrying correlation id, request id, and typed attributes across async boundaries",
5
+ "homepage": "https://github.com/couimet/ts-npm-packages/tree/main/packages/execution-context#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/couimet/ts-npm-packages/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git@github.com:couimet/ts-npm-packages.git",
12
+ "directory": "packages/execution-context"
13
+ },
14
+ "license": "MIT",
15
+ "author": "Charles Ouimet <charles.ouimet@gmail.com>",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.mjs",
20
+ "require": "./dist/index.js"
21
+ }
22
+ },
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.mjs",
25
+ "types": "./dist/index.d.ts",
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "prettier": "@couimet/eslint-config/prettier",
30
+ "dependencies": {
31
+ "@opentelemetry/context-async-hooks": ">=2.10.0",
32
+ "uuid": "^14.0.1"
33
+ },
34
+ "devDependencies": {
35
+ "@babel/preset-env": "^7.29.7",
36
+ "@opentelemetry/api": ">=1.9.1",
37
+ "@types/jest": "^29.5.14",
38
+ "@types/node": "^26.2.0",
39
+ "babel-jest": "^29.7.0",
40
+ "eslint": "^10.8.1",
41
+ "jest": "^29.7.0",
42
+ "prettier": "^3.9.6",
43
+ "ts-jest": "^29.4.12",
44
+ "tsup": "^8.5.1",
45
+ "typescript": "^6.0.3",
46
+ "@couimet/detailed-error": "1.0.0",
47
+ "@couimet/dynamic-testing": "1.0.1",
48
+ "@couimet/detailed-error-testing": "0.1.5",
49
+ "@couimet/eslint-config": "1.3.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@couimet/detailed-error": ">=1.0.0",
53
+ "@opentelemetry/api": ">=1.9.1"
54
+ },
55
+ "engines": {
56
+ "node": ">=24"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "scripts": {
62
+ "build": "tsup",
63
+ "clean": "rm -rf dist coverage *.tsbuildinfo",
64
+ "clean:all": "pnpm clean && rm -rf node_modules .eslintcache *.log",
65
+ "clean:deps": "rm -rf node_modules",
66
+ "format": "prettier --check .",
67
+ "test": "jest --coverage",
68
+ "typecheck": "tsc --noEmit"
69
+ }
70
+ }