@mettlecast/domain-runtime 0.2.22 → 0.2.23

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.
@@ -35,7 +35,8 @@ export interface ApiDefinition extends ApiConfig {
35
35
  }
36
36
  /**
37
37
  * Register an API primitive with versioned handlers.
38
- * Validates that deprecated versions have a sunset date.
38
+ * Validates that deprecated versions have a sunset date and that input/output
39
+ * schemas are wrapped with `.default(...)` (the Zod v4 example-data pattern).
39
40
  * @throws {ZodError} if config is invalid
40
41
  */
41
42
  export declare function defineApi(config: ApiConfig): ApiDefinition;
@@ -1,20 +1,40 @@
1
1
  import { z } from 'zod';
2
+ // Native enum objects — Zod v4's recommended pattern over `z.enum([...])`.
3
+ // Each `as const` object becomes the source of truth; `z.nativeEnum(...)`
4
+ // derives the inferred string-literal union from the keys.
5
+ const TenancyModeLiteral = { required: 'required', none: 'none', system: 'system' };
6
+ const VersionStatusLiteral = { preview: 'preview', stable: 'stable', deprecated: 'deprecated', sunset: 'sunset' };
7
+ const AuthTypeLiteral = { jwt: 'jwt', 'api-key': 'api-key', none: 'none' };
8
+ const DeploymentTargetLiteral = { single: 'single', split: 'split' };
9
+ /**
10
+ * Returns true when a Zod schema has been wrapped with `.default(...)` (Zod v4
11
+ * pattern). The wrapped schema exposes a `_def.typeName` of `ZodDefault`.
12
+ * `defineApi` requires every version's `input` and `output` to be wrapped this
13
+ * way so that the runtime has a concrete example payload for documentation and
14
+ * schema-publish tooling.
15
+ */
16
+ const hasDefault = (schema) => {
17
+ if (typeof schema !== 'object' || schema === null)
18
+ return false;
19
+ const def = schema._def;
20
+ return def?.typeName === 'ZodDefault';
21
+ };
2
22
  const ApiConfigSchema = z.object({
3
23
  id: z.string().min(1),
4
24
  path: z.string().startsWith('/'),
5
25
  method: z.string().optional(),
6
- tenancy: z.enum(['required', 'none', 'system']),
26
+ tenancy: z.nativeEnum(TenancyModeLiteral),
7
27
  versions: z.record(z.string(), z.object({
8
- status: z.enum(['preview', 'stable', 'deprecated', 'sunset']),
28
+ status: z.nativeEnum(VersionStatusLiteral),
9
29
  sunset: z.string().optional(),
10
- input: z.unknown(),
11
- output: z.unknown(),
30
+ input: z.unknown().refine(hasDefault, { message: 'input schema must have .default()' }),
31
+ output: z.unknown().refine(hasDefault, { message: 'output schema must have .default()' }),
12
32
  handler: z.function(),
13
- }).refine(v => v.status !== 'deprecated' || v.sunset !== undefined, {
33
+ }).refine(v => v.status !== VersionStatusLiteral.deprecated || v.sunset !== undefined, {
14
34
  message: 'sunset date is required when status is deprecated',
15
35
  })),
16
36
  auth: z.object({
17
- type: z.enum(['jwt', 'api-key', 'none']),
37
+ type: z.nativeEnum(AuthTypeLiteral),
18
38
  roles: z.array(z.string()).optional(),
19
39
  }).optional(),
20
40
  rateLimit: z.object({
@@ -22,7 +42,7 @@ const ApiConfigSchema = z.object({
22
42
  perApiKey: z.string().optional(),
23
43
  perIp: z.string().optional(),
24
44
  }).optional(),
25
- deployment: z.object({ target: z.enum(['single', 'split']) }).passthrough().optional(),
45
+ deployment: z.object({ target: z.nativeEnum(DeploymentTargetLiteral) }).passthrough().optional(),
26
46
  observability: z.object({
27
47
  sloP99Ms: z.number().positive().optional(),
28
48
  alertOnErrorRate: z.number().min(0).max(1).optional(),
@@ -30,7 +50,8 @@ const ApiConfigSchema = z.object({
30
50
  }).refine(c => Object.keys(c.versions).length > 0, { message: 'at least one version required' });
31
51
  /**
32
52
  * Register an API primitive with versioned handlers.
33
- * Validates that deprecated versions have a sunset date.
53
+ * Validates that deprecated versions have a sunset date and that input/output
54
+ * schemas are wrapped with `.default(...)` (the Zod v4 example-data pattern).
34
55
  * @throws {ZodError} if config is invalid
35
56
  */
36
57
  export function defineApi(config) {
@@ -1,26 +1,14 @@
1
- import type { TibFetch } from '../ctx/comms.js';
2
- /** Options for the TibFetch factory. */
3
- export interface TibFetchOptions {
4
- /** Maximum number of retry attempts on 5xx or network errors. Defaults to 3. */
5
- maxRetries?: number;
6
- /** Initial backoff delay in milliseconds. Doubles each retry. Defaults to 100. */
7
- initialBackoffMs?: number;
8
- /**
9
- * Number of consecutive failures before the circuit opens (blocks requests).
10
- * Defaults to 5.
11
- */
12
- circuitBreakerThreshold?: number;
13
- /**
14
- * How long the circuit stays open (ms) before allowing a probe request.
15
- * Defaults to 30_000 (30 seconds).
16
- */
17
- circuitBreakerResetMs?: number;
18
- }
19
1
  /**
20
- * Creates an HTTP client with automatic retry (exponential backoff) and per-host
21
- * circuit breaker. Uses the Node.js 18+ global fetch API.
22
- * Retries on 5xx responses and network errors. Does not retry 4xx responses.
23
- * @param options - Configuration for retry and circuit breaker behaviour.
24
- * @returns A TibFetch instance.
2
+ * @deprecated Use `createKyClient` from './ky-client.js' instead.
3
+ *
4
+ * This module is preserved for backward compatibility with code that
5
+ * imported `createFetch` from the runtime barrel. The underlying
6
+ * implementation is now backed by `ky`; the hand-rolled retry and
7
+ * circuit breaker have been removed.
8
+ *
9
+ * Callers passing the legacy `TibFetchOptions` fields
10
+ * (`initialBackoffMs`, `circuitBreakerThreshold`, `circuitBreakerResetMs`)
11
+ * must drop those options — they are no longer supported.
25
12
  */
26
- export declare function createFetch(options?: TibFetchOptions): TibFetch;
13
+ export { createKyClient as createFetch } from './ky-client.js';
14
+ export type { KyFetchOptions as TibFetchOptions } from './ky-client.js';
@@ -1,84 +1,13 @@
1
1
  /**
2
- * Creates an HTTP client with automatic retry (exponential backoff) and per-host
3
- * circuit breaker. Uses the Node.js 18+ global fetch API.
4
- * Retries on 5xx responses and network errors. Does not retry 4xx responses.
5
- * @param options - Configuration for retry and circuit breaker behaviour.
6
- * @returns A TibFetch instance.
2
+ * @deprecated Use `createKyClient` from './ky-client.js' instead.
3
+ *
4
+ * This module is preserved for backward compatibility with code that
5
+ * imported `createFetch` from the runtime barrel. The underlying
6
+ * implementation is now backed by `ky`; the hand-rolled retry and
7
+ * circuit breaker have been removed.
8
+ *
9
+ * Callers passing the legacy `TibFetchOptions` fields
10
+ * (`initialBackoffMs`, `circuitBreakerThreshold`, `circuitBreakerResetMs`)
11
+ * must drop those options — they are no longer supported.
7
12
  */
8
- export function createFetch(options = {}) {
9
- const { maxRetries = 3, initialBackoffMs = 100, circuitBreakerThreshold = 5, circuitBreakerResetMs = 30_000, } = options;
10
- const circuits = new Map();
11
- function getHost(url) {
12
- try {
13
- return new URL(url).host;
14
- }
15
- catch {
16
- return url;
17
- }
18
- }
19
- function getCircuit(host) {
20
- let c = circuits.get(host);
21
- if (!c) {
22
- c = { failures: 0, openedAt: null };
23
- circuits.set(host, c);
24
- }
25
- return c;
26
- }
27
- function isOpen(circuit) {
28
- if (circuit.openedAt === null)
29
- return false;
30
- // Half-open: allow probe after reset window.
31
- if (Date.now() - circuit.openedAt >= circuitBreakerResetMs) {
32
- circuit.openedAt = null; // Reset to half-open probe.
33
- return false;
34
- }
35
- return true;
36
- }
37
- function recordSuccess(circuit) {
38
- circuit.failures = 0;
39
- circuit.openedAt = null;
40
- }
41
- function recordFailure(circuit) {
42
- circuit.failures += 1;
43
- if (circuit.failures >= circuitBreakerThreshold) {
44
- circuit.openedAt = Date.now();
45
- }
46
- }
47
- async function sleep(ms) {
48
- return new Promise(resolve => setTimeout(resolve, ms));
49
- }
50
- return {
51
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
- async fetch(url, init) {
53
- const host = getHost(url);
54
- const circuit = getCircuit(host);
55
- if (isOpen(circuit)) {
56
- throw new Error(`Circuit open for host ${host} — too many consecutive failures`);
57
- }
58
- let lastErr;
59
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
60
- try {
61
- const res = await globalThis.fetch(url, init);
62
- if (res.status >= 500) {
63
- recordFailure(circuit);
64
- if (attempt < maxRetries) {
65
- await sleep(initialBackoffMs * Math.pow(2, attempt));
66
- continue;
67
- }
68
- return res; // Return the 5xx response after exhausting retries.
69
- }
70
- recordSuccess(circuit);
71
- return res;
72
- }
73
- catch (err) {
74
- lastErr = err;
75
- recordFailure(circuit);
76
- if (attempt < maxRetries) {
77
- await sleep(initialBackoffMs * Math.pow(2, attempt));
78
- }
79
- }
80
- }
81
- throw lastErr;
82
- },
83
- };
84
- }
13
+ export { createKyClient as createFetch } from './ky-client.js';
@@ -1,9 +1,20 @@
1
1
  export { createLogger } from './logger.js';
2
- export { createTracer, noopTracer } from './tracer.js';
2
+ export { createTracer, noopTracer, initOtel } from './tracer.js';
3
3
  export { createCache } from './cache.js';
4
+ /**
5
+ * @deprecated Use `createKyClient` instead. The `createFetch` name is kept
6
+ * as a backward-compatible alias for callers that imported it before the
7
+ * migration to `ky`. New code should use `createKyClient` directly.
8
+ */
4
9
  export { createFetch } from './fetch.js';
5
10
  export type { TibFetchOptions } from './fetch.js';
11
+ export { createKyClient } from './ky-client.js';
12
+ export type { KyFetchOptions } from './ky-client.js';
6
13
  export { checkRateLimit, clearRateLimitBuckets } from './rate-limiter.js';
14
+ export { ok, err, notFound } from '../types/result.js';
15
+ export type { AppError, Result } from '../types/result.js';
16
+ export { safeFetch, safeDb, wrapHandler, captureStack } from './typed-errors.js';
17
+ export type { SafeResult } from './typed-errors.js';
7
18
  export { createSecrets, createEmptySecrets } from './secrets.js';
8
19
  export type { SecretsOptions } from './secrets.js';
9
20
  export { createIdempotency, createNoopIdempotency } from './idempotency.js';
@@ -1,9 +1,20 @@
1
1
  // No-dep implementations
2
2
  export { createLogger } from './logger.js';
3
- export { createTracer, noopTracer } from './tracer.js';
3
+ export { createTracer, noopTracer, initOtel } from './tracer.js';
4
4
  export { createCache } from './cache.js';
5
+ /**
6
+ * @deprecated Use `createKyClient` instead. The `createFetch` name is kept
7
+ * as a backward-compatible alias for callers that imported it before the
8
+ * migration to `ky`. New code should use `createKyClient` directly.
9
+ */
5
10
  export { createFetch } from './fetch.js';
11
+ export { createKyClient } from './ky-client.js';
6
12
  export { checkRateLimit, clearRateLimitBuckets } from './rate-limiter.js';
13
+ // ── Typed error handling ────────────────────────────────────────────────────
14
+ // Re-export Result types + adapters so domain handlers can import them from a
15
+ // single barrel. The full type definitions live in `../types/result.js`.
16
+ export { ok, err, notFound } from '../types/result.js';
17
+ export { safeFetch, safeDb, wrapHandler, captureStack } from './typed-errors.js';
7
18
  // AWS SDK implementations
8
19
  export { createSecrets, createEmptySecrets } from './secrets.js';
9
20
  export { createIdempotency, createNoopIdempotency } from './idempotency.js';
@@ -0,0 +1,18 @@
1
+ import type { TibFetch } from '../ctx/comms.js';
2
+ /** Options for the ky-backed TibFetch factory. */
3
+ export interface KyFetchOptions {
4
+ /** Maximum number of retry attempts on retriable status codes. Defaults to 3. */
5
+ maxRetries?: number;
6
+ }
7
+ /**
8
+ * Creates an HTTP client backed by ky with built-in retry on 503 and 429
9
+ * status codes. Replaces the hand-rolled retry + circuit breaker previously
10
+ * implemented in `createFetch`.
11
+ *
12
+ * Retries use ky's exponential backoff. The `beforeRetry` hook is wired up
13
+ * to allow future logging/metrics extensions without modifying the call site.
14
+ *
15
+ * @param options - Configuration for retry behaviour.
16
+ * @returns A TibFetch instance backed by ky.
17
+ */
18
+ export declare function createKyClient(options?: KyFetchOptions): TibFetch;
@@ -0,0 +1,41 @@
1
+ import ky from 'ky';
2
+ /**
3
+ * Creates an HTTP client backed by ky with built-in retry on 503 and 429
4
+ * status codes. Replaces the hand-rolled retry + circuit breaker previously
5
+ * implemented in `createFetch`.
6
+ *
7
+ * Retries use ky's exponential backoff. The `beforeRetry` hook is wired up
8
+ * to allow future logging/metrics extensions without modifying the call site.
9
+ *
10
+ * @param options - Configuration for retry behaviour.
11
+ * @returns A TibFetch instance backed by ky.
12
+ */
13
+ export function createKyClient(options = {}) {
14
+ const { maxRetries = 3 } = options;
15
+ const instance = ky.create({
16
+ retry: {
17
+ limit: maxRetries,
18
+ methods: ['get', 'post', 'put', 'patch', 'delete'],
19
+ statusCodes: [503, 429],
20
+ },
21
+ hooks: {
22
+ beforeRetry: [
23
+ async () => {
24
+ // Placeholder hook for future logging/metrics. Ky performs the retry.
25
+ },
26
+ ],
27
+ },
28
+ });
29
+ return {
30
+ async fetch(url, init) {
31
+ // `RequestInit` (the public TibFetch signature) is a strict subset of
32
+ // ky's `Options`. The double-cast `as unknown as KyOptions` is the
33
+ // canonical TypeScript pattern for "I know these types are related but
34
+ // TS doesn't" — it forces a deliberate acknowledgement that the
35
+ // fields on `init` are a subset of what ky accepts, and is safer than
36
+ // a direct `as KyOptions` cast (which TS allows without the
37
+ // intermediate acknowledgement).
38
+ return instance(url, init);
39
+ },
40
+ };
41
+ }
@@ -6,32 +6,13 @@ export declare const ParsedQuerySchema: z.ZodObject<{
6
6
  limit: z.ZodDefault<z.ZodNumber>;
7
7
  sort: z.ZodDefault<z.ZodArray<z.ZodObject<{
8
8
  field: z.ZodString;
9
- direction: z.ZodEnum<["asc", "desc"]>;
10
- }, "strip", z.ZodTypeAny, {
11
- field: string;
12
- direction: "asc" | "desc";
13
- }, {
14
- field: string;
15
- direction: "asc" | "desc";
16
- }>, "many">>;
9
+ direction: z.ZodEnum<{
10
+ asc: "asc";
11
+ desc: "desc";
12
+ }>;
13
+ }, z.core.$strip>>>;
17
14
  filter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
18
- }, "strip", z.ZodTypeAny, {
19
- sort: {
20
- field: string;
21
- direction: "asc" | "desc";
22
- }[];
23
- filter: Record<string, string>;
24
- limit: number;
25
- cursor?: string | undefined;
26
- }, {
27
- sort?: {
28
- field: string;
29
- direction: "asc" | "desc";
30
- }[] | undefined;
31
- filter?: Record<string, string> | undefined;
32
- cursor?: string | undefined;
33
- limit?: number | undefined;
34
- }>;
15
+ }, z.core.$strip>;
35
16
  export type ParsedQuery = z.infer<typeof ParsedQuerySchema>;
36
17
  /**
37
18
  * Parses standard TIB list query parameters from a raw query string map.
@@ -11,3 +11,38 @@ export declare function createTracer(segmentName: string): Tracer;
11
11
  * `startSegment` calls `fn()` directly. `putAnnotation` is a no-op.
12
12
  */
13
13
  export declare const noopTracer: Tracer;
14
+ /**
15
+ * Initialises OpenTelemetry tracing for the process.
16
+ *
17
+ * **Idempotent** — safe to call at module scope (top of a Lambda handler
18
+ * file) and safe to call repeatedly. The module-level `initialised` flag
19
+ * ensures only the first call has any effect.
20
+ *
21
+ * **Opt-in by design.** This function is NOT called automatically by the
22
+ * runtime; domain handler templates must call `initOtel()` at module
23
+ * scope if they want OTel-exported spans. The `tib doctor` gate
24
+ * (`checkOtelInitInLambdas`) enforces this convention by scanning
25
+ * `domains/.../api/-.ts` for an `initOtel(...)` call.
26
+ *
27
+ * Behaviour:
28
+ * - When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, attaches a
29
+ * `BatchSpanProcessor` with an `OTLPTraceExporter` pointed at that
30
+ * URL.
31
+ * - Otherwise (no endpoint configured), attaches a `SimpleSpanProcessor`
32
+ * with a `ConsoleSpanExporter` so spans are visible during local
33
+ * development.
34
+ * - Sampling is `TraceIdRatioBasedSampler` driven by
35
+ * `OTEL TRACES_SAMPLER_ARG` (default `0.01` = 1%). Invalid values
36
+ * fall back to the 1% default.
37
+ * - The provider is registered globally via `provider.register()`,
38
+ * which sets up the global tracer, context propagation, and (when
39
+ * instrumentations are added) auto-instrumentation.
40
+ *
41
+ * **Auto-instrumentation caveat**: this initialiser does NOT install
42
+ * auto-instrumentations (HTTP, Express, etc.). If you need
43
+ * auto-instrumentation, add the relevant `@opentelemetry/instrumentation-*`
44
+ * packages and call `registerInstrumentations({ instrumentations: [...] })`
45
+ * after `initOtel()`. The basic tracer + context-propagation setup
46
+ * provided here works for manually-created spans.
47
+ */
48
+ export declare function initOtel(): void;
@@ -1,4 +1,11 @@
1
1
  import { Tracer as PowertoolsTracer } from '@aws-lambda-powertools/tracer';
2
+ // OTel deps (`@opentelemetry/*`) are in packages/domain-runtime/package.json
3
+ // (added in Wave 2 of the scaffolder modernization, issue #3831).
4
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
5
+ import { registerInstrumentations } from '@opentelemetry/instrumentation';
6
+ import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
7
+ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
8
+ import { BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor, TraceIdRatioBasedSampler, } from '@opentelemetry/sdk-trace-node';
2
9
  /**
3
10
  * Creates an AWS X-Ray compatible Tracer using Powertools Tracer.
4
11
  * Degrades gracefully when called outside a Lambda context (no active segment).
@@ -51,3 +58,71 @@ export const noopTracer = {
51
58
  // no-op
52
59
  },
53
60
  };
61
+ let initialised = false;
62
+ /**
63
+ * Initialises OpenTelemetry tracing for the process.
64
+ *
65
+ * **Idempotent** — safe to call at module scope (top of a Lambda handler
66
+ * file) and safe to call repeatedly. The module-level `initialised` flag
67
+ * ensures only the first call has any effect.
68
+ *
69
+ * **Opt-in by design.** This function is NOT called automatically by the
70
+ * runtime; domain handler templates must call `initOtel()` at module
71
+ * scope if they want OTel-exported spans. The `tib doctor` gate
72
+ * (`checkOtelInitInLambdas`) enforces this convention by scanning
73
+ * `domains/.../api/-.ts` for an `initOtel(...)` call.
74
+ *
75
+ * Behaviour:
76
+ * - When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, attaches a
77
+ * `BatchSpanProcessor` with an `OTLPTraceExporter` pointed at that
78
+ * URL.
79
+ * - Otherwise (no endpoint configured), attaches a `SimpleSpanProcessor`
80
+ * with a `ConsoleSpanExporter` so spans are visible during local
81
+ * development.
82
+ * - Sampling is `TraceIdRatioBasedSampler` driven by
83
+ * `OTEL TRACES_SAMPLER_ARG` (default `0.01` = 1%). Invalid values
84
+ * fall back to the 1% default.
85
+ * - The provider is registered globally via `provider.register()`,
86
+ * which sets up the global tracer, context propagation, and (when
87
+ * instrumentations are added) auto-instrumentation.
88
+ *
89
+ * **Auto-instrumentation caveat**: this initialiser does NOT install
90
+ * auto-instrumentations (HTTP, Express, etc.). If you need
91
+ * auto-instrumentation, add the relevant `@opentelemetry/instrumentation-*`
92
+ * packages and call `registerInstrumentations({ instrumentations: [...] })`
93
+ * after `initOtel()`. The basic tracer + context-propagation setup
94
+ * provided here works for manually-created spans.
95
+ */
96
+ export function initOtel() {
97
+ if (initialised)
98
+ return;
99
+ initialised = true;
100
+ const endpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'];
101
+ const samplerArg = process.env['OTEL_TRACES_SAMPLER_ARG'] ?? '0.01';
102
+ const parsed = Number.parseFloat(samplerArg);
103
+ const ratio = Number.isNaN(parsed) ? 0.01 : parsed;
104
+ const sampler = new TraceIdRatioBasedSampler(ratio);
105
+ const provider = new NodeTracerProvider({ sampler });
106
+ if (endpoint) {
107
+ provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({ url: endpoint })));
108
+ }
109
+ else {
110
+ provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));
111
+ }
112
+ // Use provider.register() rather than trace.setGlobalTracerProvider() so
113
+ // that context propagation is set up correctly and any future
114
+ // auto-instrumentation (HTTP, Express, etc.) will work without additional
115
+ // wiring. The two calls are equivalent for the simple case of "set the
116
+ // global provider" but `register()` also installs the W3C TraceContext
117
+ // propagators and registers the provider with the metrics + context
118
+ // managers — the recommended OTel SDK pattern.
119
+ provider.register();
120
+ // Register auto-instrumentation for outbound HTTP calls. Every
121
+ // `ctx.fetch()` (which uses Node's `http`/`https` modules under ky)
122
+ // will produce a child span automatically. Inbound HTTP is handled by
123
+ // API Gateway (which creates its own span), so this instrumentation
124
+ // only covers the outbound (service-to-service) call path.
125
+ registerInstrumentations({
126
+ instrumentations: [new HttpInstrumentation()],
127
+ });
128
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Runtime adapters that convert throw-able operations into typed
3
+ * `Result<T, AppError>` values. Domain handlers import these (or the
4
+ * lower-level `ok`/`err` constructors) and return `Result` instead of
5
+ * throwing, so every error path is explicit in the handler's return type.
6
+ */
7
+ import { createKyClient } from './ky-client.js';
8
+ import type { AppError, InternalError, Result } from '../types/result.js';
9
+ export type { AppError, Result } from '../types/result.js';
10
+ export type SafeResult<T> = Result<T, AppError>;
11
+ /**
12
+ * Create an `internal` error from a caught throwable. Extracts a
13
+ * best-effort message and stack trace; the caller should already have
14
+ * logged the full error before returning the Result to the API gateway.
15
+ */
16
+ export declare function captureStack(err: unknown, traceId: string): InternalError;
17
+ /**
18
+ * Wraps a `TibFetch` instance so that every `fetch()` call returns a
19
+ * `Result<Response, AppError>` instead of throwing. Network errors,
20
+ * unexpected status codes, and JSON parse failures are all converted to
21
+ * typed errors.
22
+ *
23
+ * ```ts
24
+ * const http = safeFetch(ctx.fetch);
25
+ * const res = await http.get('https://api.example.com/users');
26
+ * if (!res.ok) return res; // res.error is typed AppError
27
+ * const users = await res.json(); // res.value.json()
28
+ * ```
29
+ */
30
+ export interface SafeFetch {
31
+ fetch(url: string, init?: RequestInit): Promise<SafeResult<Response>>;
32
+ }
33
+ export declare function safeFetch(client: ReturnType<typeof createKyClient>): SafeFetch;
34
+ /**
35
+ * Stub — wraps the existing db client so that common query errors are
36
+ * converted to typed errors. This is a skeleton for future expansion;
37
+ * the current version only catches unexpected throws.
38
+ */
39
+ export interface SafeDb {
40
+ query(domainId: string, sql: string, params?: unknown[]): Promise<SafeResult<unknown[]>>;
41
+ }
42
+ export declare function safeDb(db: {
43
+ query: (domainId: string, sql: string, params?: unknown[]) => Promise<unknown[]>;
44
+ }): SafeDb;
45
+ /**
46
+ * Wraps a domain handler that returns `Result<T, AppError>` in a
47
+ * standard API Gateway lambda wrapper. Success results become 2xx
48
+ * responses; failure results become the appropriate HTTP status code
49
+ * (4xx/5xx) with structured error bodies.
50
+ *
51
+ * Any *unexpected* throw inside the handler is caught and converted to
52
+ * a 500 with the trace ID — the page never crashes.
53
+ */
54
+ export declare function wrapHandler<T>(handler: (input: unknown, ctx: unknown) => Promise<Result<T, AppError>>): (input: unknown, ctx: unknown) => Promise<unknown>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Runtime adapters that convert throw-able operations into typed
3
+ * `Result<T, AppError>` values. Domain handlers import these (or the
4
+ * lower-level `ok`/`err` constructors) and return `Result` instead of
5
+ * throwing, so every error path is explicit in the handler's return type.
6
+ */
7
+ import { ok, err } from '../types/result.js';
8
+ // ── Helpers ─────────────────────────────────────────────────────────────────
9
+ /**
10
+ * Create an `internal` error from a caught throwable. Extracts a
11
+ * best-effort message and stack trace; the caller should already have
12
+ * logged the full error before returning the Result to the API gateway.
13
+ */
14
+ export function captureStack(err, traceId) {
15
+ if (err instanceof Error) {
16
+ return {
17
+ kind: 'internal',
18
+ traceId,
19
+ message: err.message,
20
+ };
21
+ }
22
+ return {
23
+ kind: 'internal',
24
+ traceId,
25
+ message: String(err),
26
+ };
27
+ }
28
+ export function safeFetch(client) {
29
+ return {
30
+ async fetch(url, init) {
31
+ try {
32
+ const response = await client.fetch(url, init);
33
+ return ok(response);
34
+ }
35
+ catch (e) {
36
+ const message = e instanceof Error ? e.message : String(e);
37
+ return err({
38
+ kind: 'external_service',
39
+ service: url,
40
+ status: 0,
41
+ body: message,
42
+ });
43
+ }
44
+ },
45
+ };
46
+ }
47
+ export function safeDb(db) {
48
+ return {
49
+ async query(domainId, sql, params) {
50
+ try {
51
+ const rows = await db.query(domainId, sql, params);
52
+ return ok(rows);
53
+ }
54
+ catch (e) {
55
+ const message = e instanceof Error ? e.message : String(e);
56
+ return err({
57
+ kind: 'external_service',
58
+ service: 'database',
59
+ status: 0,
60
+ body: message,
61
+ });
62
+ }
63
+ },
64
+ };
65
+ }
66
+ // ── wrapHandler ─────────────────────────────────────────────────────────────
67
+ /**
68
+ * Wraps a domain handler that returns `Result<T, AppError>` in a
69
+ * standard API Gateway lambda wrapper. Success results become 2xx
70
+ * responses; failure results become the appropriate HTTP status code
71
+ * (4xx/5xx) with structured error bodies.
72
+ *
73
+ * Any *unexpected* throw inside the handler is caught and converted to
74
+ * a 500 with the trace ID — the page never crashes.
75
+ */
76
+ export function wrapHandler(handler) {
77
+ return async (input, ctx) => {
78
+ try {
79
+ const result = await handler(input, ctx);
80
+ if (result.ok)
81
+ return { statusCode: 200, body: JSON.stringify(result.value) };
82
+ return errorToResponse(result.error);
83
+ }
84
+ catch (e) {
85
+ const message = e instanceof Error ? e.message : String(e);
86
+ return errorToResponse({ kind: 'internal', traceId: '', message });
87
+ }
88
+ };
89
+ }
90
+ // ── HTTP mapping ────────────────────────────────────────────────────────────
91
+ function errorToResponse(error) {
92
+ const statusMap = {
93
+ not_found: 404,
94
+ validation: 400,
95
+ conflict: 409,
96
+ unauthorized: 401,
97
+ forbidden: 403,
98
+ rate_limited: 429,
99
+ external_service: 502,
100
+ internal: 500,
101
+ };
102
+ return {
103
+ statusCode: statusMap[error.kind],
104
+ body: JSON.stringify({ error }),
105
+ };
106
+ }
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  /** UUID v4/v7 string schema. */
3
- export declare const uuid: () => z.ZodString;
3
+ export declare const uuid: () => z.ZodUUID;
4
4
  /** ISO 8601 date-time string schema. */
5
- export declare const isoDate: () => z.ZodString;
5
+ export declare const isoDate: () => z.ZodISODateTime;
6
6
  /**
7
7
  * Semver version string schema (e.g. "1.2.3", "^1.0.0", "~2.1").
8
8
  * Accepts a loose superset sufficient for package version ranges.
@@ -14,4 +14,4 @@ export declare const semver: () => z.ZodString;
14
14
  */
15
15
  export declare const cronExpression: () => z.ZodString;
16
16
  /** UUID v7 tenant-id string schema (same shape as uuid but named for clarity). */
17
- export declare const tenantId: () => z.ZodString;
17
+ export declare const tenantId: () => z.ZodUUID;
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  /** UUID v4/v7 string schema. */
3
- export const uuid = () => z.string().uuid();
3
+ export const uuid = () => z.uuid();
4
4
  /** ISO 8601 date-time string schema. */
5
- export const isoDate = () => z.string().datetime({ offset: true });
5
+ export const isoDate = () => z.iso.datetime({ offset: true });
6
6
  /**
7
7
  * Semver version string schema (e.g. "1.2.3", "^1.0.0", "~2.1").
8
8
  * Accepts a loose superset sufficient for package version ranges.
@@ -14,4 +14,4 @@ export const semver = () => z.string().regex(/^[\^~]?\d+\.\d+\.\d+(?:-[\w.]+)?(?
14
14
  */
15
15
  export const cronExpression = () => z.string().regex(/^(?:cron\([^)]+\)|rate\(\d+ (?:minute|minutes|hour|hours|day|days)\))$/, 'Must be a valid cron(…) or rate(…) expression');
16
16
  /** UUID v7 tenant-id string schema (same shape as uuid but named for clarity). */
17
- export const tenantId = () => z.string().uuid();
17
+ export const tenantId = () => z.uuid();
@@ -25,12 +25,10 @@ export interface DomainModuleConfig {
25
25
  * Used by the CLI config loader to validate `.mc/domain-module.config.ts` exports.
26
26
  */
27
27
  export declare const DomainModuleConfigSchema: z.ZodObject<{
28
- mode: z.ZodEnum<["optional", "enforced", "strict"]>;
29
- legacyAllowlist: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
30
- }, "strip", z.ZodTypeAny, {
31
- mode: "optional" | "enforced" | "strict";
32
- legacyAllowlist?: string[] | undefined;
33
- }, {
34
- mode: "optional" | "enforced" | "strict";
35
- legacyAllowlist?: string[] | undefined;
36
- }>;
28
+ mode: z.ZodEnum<{
29
+ optional: "optional";
30
+ enforced: "enforced";
31
+ strict: "strict";
32
+ }>;
33
+ legacyAllowlist: z.ZodOptional<z.ZodArray<z.ZodString>>;
34
+ }, z.core.$strip>;
@@ -4,3 +4,5 @@ export type { AuthConfig, RateLimitConfig, TenancyMode } from './auth.js';
4
4
  export type { DeploymentConfig, ObservabilityConfig, EnforcementMode } from './deployment.js';
5
5
  export type { DomainModuleConfig } from './enforcement.js';
6
6
  export { DomainModuleConfigSchema } from './enforcement.js';
7
+ export type { AppError, Result, NotFoundError, ValidationError, ConflictError, UnauthorizedError, ForbiddenError, RateLimitedError, ExternalServiceError, InternalError } from './result.js';
8
+ export { ok, err, notFound } from './result.js';
@@ -1 +1,2 @@
1
1
  export { DomainModuleConfigSchema } from './enforcement.js';
2
+ export { ok, err, notFound } from './result.js';
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Typed error handling for domain handlers.
3
+ *
4
+ * The `AppError` union replaces bare `throw` statements with structured,
5
+ * categorised errors that every handler can match against. Result<T>
6
+ * encodes the success-or-failure outcome so callers are forced to check
7
+ * before using the value — no unhandled promise rejections, no catch-all
8
+ * "Internal Server Error" pages.
9
+ *
10
+ * Usage:
11
+ *
12
+ * ```ts
13
+ * function getTenant(id: string): Result<Tenant, AppError> {
14
+ * const row = await db.query(...);
15
+ * if (!row) return err({ kind: 'not_found', resource: 'tenant', id });
16
+ * return ok(row);
17
+ * }
18
+ * ```
19
+ */
20
+ /** Every possible error kind a domain handler can return. */
21
+ export type AppError = NotFoundError | ValidationError | ConflictError | UnauthorizedError | ForbiddenError | RateLimitedError | ExternalServiceError | InternalError;
22
+ /** The requested resource does not exist. */
23
+ export interface NotFoundError {
24
+ kind: 'not_found';
25
+ resource: string;
26
+ id: string;
27
+ }
28
+ /** Input validation failed. Field-level errors for form highlighting. */
29
+ export interface ValidationError {
30
+ kind: 'validation';
31
+ fieldErrors: Record<string, string[]>;
32
+ message: string;
33
+ }
34
+ /** The operation conflicts with the current state (e.g. duplicate). */
35
+ export interface ConflictError {
36
+ kind: 'conflict';
37
+ message: string;
38
+ }
39
+ /** The caller must authenticate first. */
40
+ export interface UnauthorizedError {
41
+ kind: 'unauthorized';
42
+ message: string;
43
+ }
44
+ /** The caller is authenticated but lacks permission. */
45
+ export interface ForbiddenError {
46
+ kind: 'forbidden';
47
+ message: string;
48
+ }
49
+ /** Rate limit exceeded. The caller should retry after the specified
50
+ * number of seconds. */
51
+ export interface RateLimitedError {
52
+ kind: 'rate_limited';
53
+ retryAfterSeconds: number;
54
+ }
55
+ /** An external service returned an error. The handler should surface
56
+ * the original status and a sanitised summary — do NOT log raw
57
+ * response bodies from third-party APIs. */
58
+ export interface ExternalServiceError {
59
+ kind: 'external_service';
60
+ service: string;
61
+ status: number;
62
+ body: unknown;
63
+ }
64
+ /** Something unexpected happened. The `traceId` is a correlation
65
+ * identifier for the current request — include it in user-facing
66
+ * error messages so support can look up the details in CloudWatch. */
67
+ export interface InternalError {
68
+ kind: 'internal';
69
+ traceId: string;
70
+ message: string;
71
+ }
72
+ /** Discriminated union: either `ok` with a value, or not-ok with an error. */
73
+ export type Result<T, E = AppError> = {
74
+ ok: true;
75
+ value: T;
76
+ } | {
77
+ ok: false;
78
+ error: E;
79
+ };
80
+ /** Create a success Result. */
81
+ export declare function ok<T>(value: T): Result<T, never>;
82
+ /** Create a failure Result. */
83
+ export declare function err<E extends AppError>(error: E): Result<never, E>;
84
+ /**
85
+ * Convenience: return a pre-built 404-style error for a missing resource.
86
+ *
87
+ * ```ts
88
+ * if (!row) return notFound('tenant', id);
89
+ * ```
90
+ */
91
+ export declare function notFound(resource: string, id: string): Result<never, AppError>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Typed error handling for domain handlers.
3
+ *
4
+ * The `AppError` union replaces bare `throw` statements with structured,
5
+ * categorised errors that every handler can match against. Result<T>
6
+ * encodes the success-or-failure outcome so callers are forced to check
7
+ * before using the value — no unhandled promise rejections, no catch-all
8
+ * "Internal Server Error" pages.
9
+ *
10
+ * Usage:
11
+ *
12
+ * ```ts
13
+ * function getTenant(id: string): Result<Tenant, AppError> {
14
+ * const row = await db.query(...);
15
+ * if (!row) return err({ kind: 'not_found', resource: 'tenant', id });
16
+ * return ok(row);
17
+ * }
18
+ * ```
19
+ */
20
+ // ── Constructors ────────────────────────────────────────────────────────────
21
+ /** Create a success Result. */
22
+ export function ok(value) {
23
+ return { ok: true, value };
24
+ }
25
+ /** Create a failure Result. */
26
+ export function err(error) {
27
+ return { ok: false, error };
28
+ }
29
+ /**
30
+ * Convenience: return a pre-built 404-style error for a missing resource.
31
+ *
32
+ * ```ts
33
+ * if (!row) return notFound('tenant', id);
34
+ * ```
35
+ */
36
+ export function notFound(resource, id) {
37
+ return err({ kind: 'not_found', resource, id });
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-runtime",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -58,10 +58,19 @@
58
58
  "@aws-lambda-powertools/logger": "^2.0.0",
59
59
  "@aws-lambda-powertools/metrics": "^2.0.0",
60
60
  "@aws-lambda-powertools/tracer": "^2.0.0",
61
+ "@opentelemetry/api": "~1.8.0",
62
+ "@opentelemetry/exporter-trace-otlp-http": "^0.50.0",
63
+ "@opentelemetry/instrumentation": "^0.50.0",
64
+ "@opentelemetry/instrumentation-http": "^0.50.0",
65
+ "@opentelemetry/sdk-trace-node": "^1.0.0",
61
66
  "drizzle-orm": "*",
67
+ "ky": "^1.7.0",
62
68
  "pg": "^8.0.0",
63
69
  "semver": "^7.0.0",
64
- "zod": "^3.0.0"
70
+ "zod": "^4.0.0"
71
+ },
72
+ "peerDependencies": {
73
+ "zod": "^4.0.0"
65
74
  },
66
75
  "devDependencies": {
67
76
  "@types/aws-lambda": "^8.10.0",