@adhd/apigen-engine-runtime 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.
@@ -0,0 +1,4 @@
1
+ import { MiddlewareDef, ApiPackageOptions, ApiPackageResult } from './types';
2
+
3
+ export declare function assertNoSelfSubscription(middlewares: readonly MiddlewareDef[]): void;
4
+ export declare function createApiPackage<M extends readonly MiddlewareDef[]>(options: ApiPackageOptions<M>): ApiPackageResult;
@@ -0,0 +1 @@
1
+ export declare function apigenRuntime(): string;
@@ -0,0 +1,4 @@
1
+ import { EventBus } from './event-bus';
2
+ import { MiddlewareDef } from './types';
3
+
4
+ export declare function buildContext(middlewares: readonly MiddlewareDef[], envelope: Record<string, unknown>, bus: EventBus): Promise<object>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * FEAT-APIGEN-022 / BACKLOG point 3 — query-string coercion for GET domain args.
3
+ *
4
+ * HTTP query strings carry every value as a string (or string[] for repeated
5
+ * keys). The composed input schema's validate-Layer (`makeValidateLayer`,
6
+ * `@adhd/apigen-engine-runtime/validate-layer`) uses a single Ajv instance
7
+ * with NO `coerceTypes`, shared by both GET (query) and POST (JSON body)
8
+ * validation — enabling Ajv-level `coerceTypes` globally would silently
9
+ * coerce POST body values too (e.g. a JSON string erroneously accepted where
10
+ * a number was required), which SPEC §6 forbids. Coercion therefore happens
11
+ * here, at the TRANSPORT layer, scoped to exactly the GET/query-string path,
12
+ * before the already-strict (non-coercing) validate-Layer ever sees the
13
+ * value — POST/body validation is completely untouched.
14
+ *
15
+ * Only `number`/`integer`/`boolean` are coerced (the non-`string` members of
16
+ * FEAT-APIGEN-022's "properly typed primitives" set — `string` values are
17
+ * already correctly typed as they arrive). A value that fails to parse (e.g.
18
+ * `?count=abc` against a `number` param) is left AS-IS so the validate-Layer
19
+ * still rejects it with a clear `invalid_argument` error, rather than being
20
+ * silently dropped or coerced to `NaN`/`0`.
21
+ */
22
+ /**
23
+ * Coerces a request's query-string params to the JS types their domain
24
+ * schema declares (`number`/`integer`/`boolean`), based on the function's own
25
+ * composed schema. `string`-typed and unrecognised params pass through
26
+ * unchanged; array-valued query entries (repeated keys) pass through
27
+ * unchanged (arrays are outside FEAT-APIGEN-022's primitive-only hoist
28
+ * boundary, so no domain param reaching here should be array-typed — but this
29
+ * stays defensive rather than assuming that invariant).
30
+ *
31
+ * @param query The raw `req.query` / `request.query` object.
32
+ * @param schema The function's composed schema entry (`pkg.schemas[fnName]`) —
33
+ * loosely typed like `describeParams`'s `schema` param, since
34
+ * callers pass the untyped `ComposedSchemas` entry.
35
+ */
36
+ export declare function coerceQueryParams(query: Record<string, unknown>, schema: {
37
+ input?: unknown;
38
+ } | undefined): Record<string, unknown>;
@@ -0,0 +1,5 @@
1
+ import { MiddlewareDef } from './types';
2
+
3
+ export declare function defineMiddleware<TId extends string, TEnvelope extends object, TContext extends object>(def: MiddlewareDef<TEnvelope, TContext> & {
4
+ id: TId;
5
+ }): typeof def;
@@ -0,0 +1,18 @@
1
+ export interface ParamInfo {
2
+ name: string;
3
+ type: string;
4
+ required: boolean;
5
+ }
6
+ /**
7
+ * Extract a composed schema entry's domain parameters for logging.
8
+ *
9
+ * Params live under `input.properties.data.properties` — the `data` envelope
10
+ * wrapper is always present (see [def:ComposedSchemas]). Returns both a
11
+ * structured list (for JSON logs) and a `name?: type` summary string.
12
+ */
13
+ export declare function describeParams(schema: {
14
+ input?: unknown;
15
+ } | undefined): {
16
+ params: ParamInfo[];
17
+ text: string;
18
+ };
@@ -0,0 +1,23 @@
1
+ import { ComposedSchemas } from './types';
2
+
3
+ /** Returns true when the composed schema has `field` in input.properties (i.e. an envelope field). */
4
+ export declare function needsEnvelopeField(fnSchema: ComposedSchemas[string], field: string): boolean;
5
+ /** Returns ordered domain parameter names (keys of the data: {} sub-object). */
6
+ export declare function dataParamNames(fnSchema: ComposedSchemas[string]): string[];
7
+ /**
8
+ * Single canonical dispatch path used by ALL plugins in both generate and run modes.
9
+ * No plugin may inline this logic. [inv:dispatch-single-path]
10
+ *
11
+ * Logical-type decode/encode seam (DESIGN.md §4.4 / §6):
12
+ * - BEFORE calling the fn: each domain arg is decoded from wire → host using
13
+ * the schema node at `input.properties.data.properties[k]`. A param whose
14
+ * node carries `{type:string, format:date-time}` arrives as a real `Date`;
15
+ * a plain `string` node passes through unchanged.
16
+ * - AFTER the fn returns: the result is encoded from host → wire using the
17
+ * output schema. A `Date` return becomes an RFC 3339 string; plain JSON
18
+ * values pass through unchanged.
19
+ *
20
+ * All existing behaviors (session ctx injection, BUG-APIGEN-001 ctx-param path,
21
+ * envelope handling) are preserved unchanged.
22
+ */
23
+ export declare function dispatch(fns: Record<string, (...args: unknown[]) => unknown>, createClient: ((e: Record<string, unknown>) => Promise<unknown>) | undefined, schema: ComposedSchemas[string], fnName: string, envelope: Record<string, unknown>, domainArgs: Record<string, unknown>): Promise<unknown>;
@@ -0,0 +1,19 @@
1
+ import { MiddlewareDef, MiddlewareEvent } from './types';
2
+
3
+ type EventHandler = (event: MiddlewareEvent) => void | Promise<void>;
4
+ export declare class EventBus {
5
+ private handlers;
6
+ /**
7
+ * Register a handler. Returns an unsubscribe function — handlers registered
8
+ * per-request/per-package MUST be removed (via the returned fn, {@link off},
9
+ * or {@link clear}) or they accumulate for the bus's lifetime.
10
+ */
11
+ on(selector: string, handler: EventHandler): () => void;
12
+ /** Remove a previously registered handler (no-op if absent). */
13
+ off(selector: string, handler: EventHandler): void;
14
+ /** Remove every handler — call on package/server teardown. */
15
+ clear(): void;
16
+ emit(event: MiddlewareEvent): Promise<void>;
17
+ }
18
+ export declare function wireObservers(middlewares: readonly MiddlewareDef[], bus: EventBus): void;
19
+ export {};
@@ -0,0 +1,16 @@
1
+ export type AnyFn = (...args: unknown[]) => unknown;
2
+ /**
3
+ * Build a `name → function` table from an imported module namespace, matching how
4
+ * schema extraction names functions so `dispatch` can always resolve them.
5
+ *
6
+ * Robust to every export + interop shape:
7
+ * - **named exports** (`export function f` / `export const f = …`) → keyed by name.
8
+ * - **single default-exported function** (`export default f`) → keyed by the
9
+ * function's declaration name (ESM keys it `default`; CJS-compiled deps double-
10
+ * wrap it as `default.default` / `module.exports.default`). We unwrap those
11
+ * layers and key every function found by its `.name`.
12
+ * - **default object** (`export default { a, b }`) → each function keyed by name.
13
+ *
14
+ * Earlier keys win; explicit named exports are never overwritten by unwrapped ones.
15
+ */
16
+ export declare function buildFnTable(mod: Record<string, unknown>): Record<string, AnyFn>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Instance registry for class-export instance dispatch (SPEC §10).
3
+ *
4
+ * When a `kind:'constructor'` op fires, the runtime constructs the target class
5
+ * and stores the live instance here under a unique `instanceId`. Subsequent
6
+ * `kind:'instance-method'` ops look up the instance by `instanceId` and
7
+ * dispatch the method against it.
8
+ *
9
+ * Lifecycle (TTL + dispose):
10
+ * - Every entry carries an optional TTL (milliseconds). A sweeper clears
11
+ * expired entries automatically; the sweep interval is configurable.
12
+ * - Calling `dispose(instanceId)` removes the entry immediately and runs the
13
+ * instance's `dispose()` method if it exists.
14
+ * - `disposeAll()` tears down every live instance and stops the sweeper.
15
+ *
16
+ * Stateful caveat: the registry lives in-process; horizontal scaling without
17
+ * sticky routing or an external store will route requests to the wrong instance.
18
+ * Document this to API consumers (SPEC §10 note).
19
+ *
20
+ * Usage:
21
+ * ```ts
22
+ * const registry = new InstanceRegistry({ defaultTtlMs: 30_000 })
23
+ *
24
+ * // constructor op handler
25
+ * const { instanceId } = registry.create(Counter, [0])
26
+ *
27
+ * // instance-method op handler
28
+ * const counter = registry.get<Counter>(instanceId)
29
+ * counter.increment()
30
+ *
31
+ * // explicit teardown
32
+ * await registry.dispose(instanceId)
33
+ * await registry.disposeAll()
34
+ * ```
35
+ */
36
+ /** Any class constructor (unknown args → unknown instance). */
37
+ export type AnyConstructor = new (...args: unknown[]) => unknown;
38
+ /** Options for the registry. */
39
+ export interface InstanceRegistryOptions {
40
+ /**
41
+ * Default TTL in milliseconds for each instance. When omitted, instances
42
+ * live until explicitly `dispose()`d or `disposeAll()` is called.
43
+ */
44
+ defaultTtlMs?: number;
45
+ /**
46
+ * How often (ms) the background sweeper checks for expired entries.
47
+ * Defaults to `Math.max(1_000, defaultTtlMs / 4)` when a TTL is set,
48
+ * or `5_000` otherwise. Pass `0` to disable the automatic sweeper.
49
+ */
50
+ sweepIntervalMs?: number;
51
+ }
52
+ /** The result returned by `registry.create()` — the `instanceId` is what the
53
+ * client passes back on every `kind:'instance-method'` call. */
54
+ export interface CreateResult {
55
+ instanceId: string;
56
+ }
57
+ /**
58
+ * In-process instance store for SPEC §10 class-instance dispatch.
59
+ *
60
+ * Thread-safe within a single Node.js event-loop (single-threaded JS).
61
+ * Stateful: does NOT survive process restarts; scale with sticky routing.
62
+ */
63
+ export declare class InstanceRegistry {
64
+ private readonly _store;
65
+ private readonly _defaultTtlMs;
66
+ private _sweeper;
67
+ constructor(opts?: InstanceRegistryOptions);
68
+ /**
69
+ * Construct an instance of `Ctor` with `args` and store it in the registry.
70
+ *
71
+ * @param Ctor - The class to instantiate.
72
+ * @param args - Constructor arguments (positional).
73
+ * @param ttlMs - Per-entry TTL override. Falls back to `defaultTtlMs`.
74
+ * @returns `{ instanceId }` — pass this to `dispatch()` / `get()`.
75
+ */
76
+ create(Ctor: AnyConstructor, args?: unknown[], ttlMs?: number): CreateResult;
77
+ /**
78
+ * Retrieve the instance stored under `instanceId`.
79
+ *
80
+ * @throws When `instanceId` is unknown or the entry has expired.
81
+ */
82
+ get<T = unknown>(instanceId: string): T;
83
+ /**
84
+ * Look up `instanceId` and call `method` on it with `args`.
85
+ *
86
+ * @throws When `instanceId` is unknown/expired, or `method` is not a function
87
+ * on the instance.
88
+ */
89
+ dispatch(instanceId: string, method: string, args?: unknown[]): Promise<unknown>;
90
+ /**
91
+ * Remove `instanceId` from the registry and call `instance.dispose()` if the
92
+ * instance exposes one (opt-in lifecycle hook).
93
+ */
94
+ dispose(instanceId: string): Promise<void>;
95
+ /**
96
+ * Dispose every live instance and stop the background sweeper. Call this
97
+ * when the server is shutting down.
98
+ */
99
+ disposeAll(): Promise<void>;
100
+ /** Number of live (non-expired) entries currently in the registry. */
101
+ get size(): number;
102
+ private _sweep;
103
+ private _evict;
104
+ }
@@ -0,0 +1,112 @@
1
+ import { ComposedSchemas } from './types';
2
+ import { Operation } from '@adhd/apigen-core-client';
3
+
4
+ /**
5
+ * A symbol-keyed or constructor-keyed typed extension map. Each value is
6
+ * stored under a unique token so Layers can share typed data without a mutable
7
+ * property bag. Modeled after Tower/`http::Extensions` — the only `ctx` shape
8
+ * expressible under Rust's borrow checker, so TS matches it for dual-host
9
+ * alignment.
10
+ */
11
+ export declare class LayerContext {
12
+ private readonly _map;
13
+ /** Store `value` under `token` (a constructor or a unique symbol). */
14
+ set<T>(token: abstract new (...args: never[]) => T, value: T): void;
15
+ set<T>(token: symbol, value: T): void;
16
+ /**
17
+ * Retrieve the value stored under `token`. Returns `undefined` when the
18
+ * token has not been inserted — callers are responsible for presence checks.
19
+ */
20
+ get<T>(token: abstract new (...args: never[]) => T): T | undefined;
21
+ get<T>(token: symbol): T | undefined;
22
+ /** Returns true when the token has a stored value. */
23
+ has(token: unknown): boolean;
24
+ }
25
+ /** The resolved domain call, threaded through the Layer stack. */
26
+ export interface Call {
27
+ /**
28
+ * The canonical operation descriptor. For v1 callers that do not have a
29
+ * full Operation, only `id` is guaranteed; the remaining fields are optional.
30
+ */
31
+ operation: Pick<Operation, 'id'> & Partial<Operation>;
32
+ /** Typed extension map — insert/read per §8.1 rule 3. */
33
+ ctx: LayerContext;
34
+ /** Middleware envelope (side-channel from transport metadata). */
35
+ envelope: Record<string, unknown>;
36
+ /** Domain arguments (the `data` sub-object from the composed input). */
37
+ domainArgs: Record<string, unknown>;
38
+ /** Cancellation signal (AbortSignal) — §11. */
39
+ signal?: AbortSignal;
40
+ }
41
+ /**
42
+ * The primitive result a Layer or dispatch can return.
43
+ *
44
+ * §11: a streaming operation returns `AsyncIterable<unknown>`; a normal
45
+ * operation returns `unknown`. Both are covered by this union.
46
+ */
47
+ export type LayerResult = unknown | AsyncIterable<unknown>;
48
+ /**
49
+ * `next` continuation — the inner Layer or dispatch Service.
50
+ *
51
+ * Returns a promise of either a scalar result or an AsyncIterable stream
52
+ * (§11). A Layer that short-circuits (§8.1 rule 1) never calls this.
53
+ */
54
+ export type Next = () => Promise<LayerResult>;
55
+ /**
56
+ * A Layer in the TS harness (§8.1 normative closure signature).
57
+ *
58
+ * ```ts
59
+ * const logger: Layer = async (call, next) => {
60
+ * const t = Date.now()
61
+ * try { const r = await next(); return r }
62
+ * catch (e) { throw e } // error unwinds outward — §8.1 rule 2
63
+ * }
64
+ * ```
65
+ *
66
+ * Rule 1 (short-circuit): return a value WITHOUT calling `next`.
67
+ * Rule 2 (error propagation): `throw` propagates to the enclosing Layer.
68
+ * Rule 4 (poll_ready): not present — TS host omits it entirely (host-optional).
69
+ * Rule 5 (streaming): `next()` return type includes `AsyncIterable<unknown>`.
70
+ * Rule 6 (codegen-weave): composition is a pure function; same semantics.
71
+ */
72
+ export type Layer = (call: Call, next: Next) => Promise<LayerResult>;
73
+ /**
74
+ * Runtime context needed by `invoke` to reach the core dispatch Service.
75
+ *
76
+ * v1 callers supply `fns` + `createClient` + `schemas` directly; future
77
+ * callers may pre-build an invoker from an Operation descriptor.
78
+ */
79
+ export interface InvokeOptions {
80
+ /** The live function table (fn-name → implementation). */
81
+ fns: Record<string, (...args: unknown[]) => unknown>;
82
+ /** Optional client factory (for session-ctx middleware). */
83
+ createClient?: (envelope: Record<string, unknown>) => Promise<unknown>;
84
+ /** Composed schemas for the target namespace. */
85
+ schemas: ComposedSchemas;
86
+ }
87
+ /**
88
+ * The composed invoke function returned by {@link createInvoker}.
89
+ *
90
+ * Accepts an operation name, a {@link Call}, and runtime dispatch options.
91
+ * Returns the Service result, streaming or scalar.
92
+ */
93
+ export type InvokeFn = (fnName: string, call: Call, opts: InvokeOptions) => Promise<LayerResult>;
94
+ /**
95
+ * Compose a Layer stack and return a typed `invoke` function.
96
+ *
97
+ * Layers are applied **outermost-first**: `layers[0]` wraps `layers[1]`
98
+ * which wraps … which wraps `dispatch`. Equivalent to Tower's
99
+ * `ServiceBuilder::layer` / `layer_fn` composition order.
100
+ *
101
+ * ```ts
102
+ * const invoke = createInvoker([authLayer, loggerLayer])
103
+ * const result = await invoke('getUser', call, opts)
104
+ * ```
105
+ *
106
+ * When `layers` is empty, `invoke` calls `dispatch` directly.
107
+ *
108
+ * §8.1 rule 6 (codegen-weave): static hosts receive the composed list at
109
+ * codegen time; `createInvoker` is called once per plugin instantiation, not
110
+ * once per request.
111
+ */
112
+ export declare function createInvoker(layers?: readonly Layer[]): InvokeFn;
@@ -0,0 +1,29 @@
1
+ import { Logger } from 'pino';
2
+
3
+ export type { Logger };
4
+ /** Log output format. `pretty` is colorized human-readable; `json` is raw jsonl. */
5
+ export type LogFormat = 'json' | 'pretty';
6
+ export interface CreateLoggerOptions {
7
+ /** pino log level. Default: `info`. */
8
+ level?: string;
9
+ /**
10
+ * Output format. Default: `pretty` when the destination is a TTY, else `json`.
11
+ * `pretty` routes through pino-pretty (colorized, timestamped); `json` emits jsonl.
12
+ */
13
+ format?: LogFormat;
14
+ /**
15
+ * Where logs are written. Default: stderr (fd 2) — NEVER stdout, which is the
16
+ * MCP stdio JSON-RPC channel. Pass a filesystem path to write logs to a file.
17
+ */
18
+ destination?: string;
19
+ }
20
+ /**
21
+ * Build the shared apigen pino logger.
22
+ *
23
+ * Logging always targets stderr or a file — never stdout — so the MCP stdio
24
+ * transport's JSON-RPC channel on stdout stays free of log noise.
25
+ *
26
+ * @param opts - level, format (`json` | `pretty`), and destination (stderr or a file path).
27
+ * @returns a configured pino {@link Logger}.
28
+ */
29
+ export declare function createLogger(opts?: CreateLoggerOptions): Logger;
@@ -0,0 +1,41 @@
1
+ import { LogicalTypeCodec, LogicalTypeId } from '@adhd/apigen-base-logical';
2
+
3
+ /**
4
+ * @stable A host binding maps a LogicalTypeId to that host's native
5
+ * encode/decode primitives (DESIGN.md §4.6).
6
+ *
7
+ * - `host` — identifies the language/runtime.
8
+ * - `logicalTypeVersion` — pinned to `LOGICAL_TYPE_VERSION` from
9
+ * `@adhd/apigen-base-logical`; bumped on any wire-table
10
+ * or pinned-lib-version change (DESIGN §8.6).
11
+ * - `codecs` — frozen ReadonlyMap keyed by LogicalTypeId. Must
12
+ * cover every well-known id (DESIGN §4.6 "Must cover
13
+ * every well-known id.").
14
+ */
15
+ export interface HostBinding {
16
+ readonly host: 'ts' | 'python' | 'rust' | 'go' | 'java';
17
+ readonly logicalTypeVersion: string;
18
+ /** The codecs this host provides, keyed by LogicalTypeId. */
19
+ readonly codecs: ReadonlyMap<LogicalTypeId, LogicalTypeCodec>;
20
+ }
21
+ /**
22
+ * @stable Ordered array of all well-known scalar codec instances for the TS
23
+ * host. Derived from the same import list used by `registerWellKnown` in
24
+ * `@adhd/apigen-base-logical`; kept in sync by importing the exact same named
25
+ * exports (a future addition to that barrel automatically lands here).
26
+ */
27
+ export declare const WELL_KNOWN_TS_CODECS: ReadonlyArray<LogicalTypeCodec>;
28
+ /**
29
+ * @stable The TypeScript HostBinding (DESIGN.md §4.6).
30
+ *
31
+ * A frozen object exposing all TS codec instances keyed by their
32
+ * `LogicalTypeId`. Consumers obtain a per-surface registry by calling
33
+ * `registerWellKnown(registry)` from `@adhd/apigen-base-logical`, then
34
+ * registering the generated nominal/union codecs on top.
35
+ *
36
+ * `logicalTypeVersion` is pinned to `LOGICAL_TYPE_VERSION` exported by
37
+ * `@adhd/apigen-base-logical` (`descriptor-ext.ts`) — `'0.1.0'` at authoring time.
38
+ * Any wire-table or pinned-lib-version change bumps that constant and this
39
+ * binding automatically reflects the new version.
40
+ */
41
+ export declare const tsHostBinding: HostBinding;
@@ -0,0 +1,73 @@
1
+ import { LogicalTypeCodec, LogicalTypeId, SchemaNode } from '@adhd/apigen-base-logical';
2
+
3
+ /**
4
+ * Reserved descriptor keyword carrying the extractor's **opt-in-instances**
5
+ * decision (DESIGN.md §4.5 / §13 — risk 8.5, RESOLVED). A class the extractor
6
+ * could not prove reconstructable (sockets, closures, opaque handles) is emitted
7
+ * with `x-apigen-instances:false`; its codec is **encode-only** and `decode`
8
+ * throws a clear `E_NOMINAL_NONRECONSTRUCTABLE` diagnostic rather than minting a
9
+ * half-built instance.
10
+ */
11
+ export declare const X_APIGEN_INSTANCES: "x-apigen-instances";
12
+ /**
13
+ * Stable, transport-neutral error codes raised by the nominal codec. They are
14
+ * carried on a typed `Error` subclass so callers can branch on `err.code`
15
+ * without string-matching the message.
16
+ */
17
+ export type NominalCodecErrorCode = 'E_NOMINAL_CYCLE' | 'E_NOMINAL_NONRECONSTRUCTABLE' | 'E_NOMINAL_INVALID_WIRE';
18
+ /** @stable Error carrier for the custom-class (`kind:'nominal'`) codec. */
19
+ export declare class NominalCodecError extends Error {
20
+ readonly code: NominalCodecErrorCode;
21
+ constructor(code: NominalCodecErrorCode, message: string);
22
+ }
23
+ /**
24
+ * A host class that knows how to rebuild itself from its field bag. The codec
25
+ * looks up the static reconstructor named by `x-apigen-ctor` (default
26
+ * `fromJSON`) on the registered host class; absent that, it falls back to a
27
+ * schema-projected plain construction.
28
+ */
29
+ export type NominalCtor = {
30
+ new (...args: any[]): object;
31
+ fromJSON?(bag: Record<string, unknown>): object;
32
+ [staticCtor: string]: unknown;
33
+ };
34
+ /** Options threaded into {@link createNominalCodec} for one custom class. */
35
+ export interface NominalCodecOptions {
36
+ /** Stable LogicalTypeId (namespace-qualified, e.g. `cli.User`). */
37
+ readonly id: LogicalTypeId;
38
+ /** Resolved `$def` object schema this codec owns (carries `properties`/`required`). */
39
+ readonly schema: SchemaNode;
40
+ /**
41
+ * Host class used to reconstruct instances on decode. OPTIONAL: when absent
42
+ * the codec decodes to a schema-projected plain object (still a faithful
43
+ * round-trip of the field bag — see `[inv:hints-advisory]`).
44
+ */
45
+ readonly ctor?: NominalCtor;
46
+ }
47
+ /**
48
+ * @stable Codec factory for a single custom class (`kind:'nominal'`).
49
+ *
50
+ * Encode (host → wire), DESIGN.md §4/§13:
51
+ * 1. **Cycle guard** — track each visited instance in `ctx.seen`; a back-edge
52
+ * in `strict` mode throws `E_NOMINAL_CYCLE` (the approved strict policy,
53
+ * decision 8.3). `lossy` mode breaks the cycle by emitting `null`.
54
+ * 2. **`toJSON` hint** — when the class exposes `toJSON` (named by
55
+ * `x-apigen-tojson`, default `toJSON`), use its field bag.
56
+ * 3. **Field projection** — otherwise project the schema's declared
57
+ * `properties`. Either way every field VALUE is recursed through the
58
+ * registry so nested logical types (e.g. `Date`) encode correctly.
59
+ *
60
+ * Decode (wire → host), validate-then-construct:
61
+ * 1. **Validate** the wire against the node FIRST (object shape + required
62
+ * fields present) → `E_NOMINAL_INVALID_WIRE` on a mismatch.
63
+ * 2. **Reconstructable gate** — `x-apigen-instances:false` ⇒ encode-only;
64
+ * decode throws `E_NOMINAL_NONRECONSTRUCTABLE`.
65
+ * 3. **Construct** — via the static reconstructor named by `x-apigen-ctor`
66
+ * (default `fromJSON`) when the host class is supplied, else a
67
+ * schema-projected plain object. Field VALUES are recursed first.
68
+ *
69
+ * `[inv:hints-advisory]`: the `x-apigen-*` keys only *accelerate* the choice of
70
+ * hook. With every hint stripped the codec still round-trips via schema
71
+ * projection — proven by a negative-control vector.
72
+ */
73
+ export declare function createNominalCodec(opts: NominalCodecOptions): LogicalTypeCodec<object>;
@@ -0,0 +1,63 @@
1
+ import { LogicalTypeCodec, LogicalTypeId, SchemaNode } from '@adhd/apigen-base-logical';
2
+
3
+ /**
4
+ * Stable, transport-neutral error codes raised by the union codec. Carried on
5
+ * a typed `Error` subclass so callers can branch on `err.code` without
6
+ * string-matching the message.
7
+ */
8
+ export type UnionCodecErrorCode = 'E_UNION_NO_DISCRIMINATOR' | 'E_UNION_UNKNOWN_TAG' | 'E_UNION_INVALID_WIRE';
9
+ /** @stable Error carrier for the polymorphic-union (`kind:'union'`) codec. */
10
+ export declare class UnionCodecError extends Error {
11
+ readonly code: UnionCodecErrorCode;
12
+ constructor(code: UnionCodecErrorCode, message: string);
13
+ }
14
+ /** Options threaded into {@link createUnionCodec} for one polymorphic union. */
15
+ export interface UnionCodecOptions {
16
+ /** Stable LogicalTypeId (namespace-qualified, e.g. `cli.Pet`). */
17
+ readonly id: LogicalTypeId;
18
+ /**
19
+ * The union schema node: `{ oneOf:[...], discriminator:{propertyName, mapping} }`.
20
+ * This is the authoritative ownership schema stored on the codec; individual
21
+ * `encode`/`decode` calls may pass a node with `x-apigen-logical` stripped
22
+ * (or a structurally equivalent node) — the codec MUST behave identically.
23
+ */
24
+ readonly schema: SchemaNode;
25
+ }
26
+ /**
27
+ * @stable Codec factory for a discriminated union (`kind:'union'`).
28
+ *
29
+ * Wire shape (DESIGN §3 + §4.1): the value is a JSON object that carries a
30
+ * const discriminator property (e.g. `kind`). The schema node looks like:
31
+ * ```json
32
+ * { "oneOf": [{"$ref":"#/$defs/Dog"},{"$ref":"#/$defs/Cat"}],
33
+ * "discriminator": {"propertyName":"kind","mapping":{"dog":"#/$defs/Dog","cat":"#/$defs/Cat"}},
34
+ * "x-apigen-logical": "union" }
35
+ * ```
36
+ *
37
+ * Ownership test — `matches(node)`:
38
+ * true when the node has BOTH `oneOf` (array) AND `discriminator` (object with
39
+ * `propertyName`). The `x-apigen-logical:"union"` hint accelerates dispatch but
40
+ * is NOT required — structure is authoritative (`[inv:hints-advisory]`).
41
+ *
42
+ * Encode (host → wire):
43
+ * 1. Read `discriminator.propertyName` off the **value** to get the tag.
44
+ * Throws `E_UNION_NO_DISCRIMINATOR` if the tag prop is absent/undefined.
45
+ * 2. Map tag → branch `$ref` via `discriminator.mapping[tag]`.
46
+ * Throws `E_UNION_UNKNOWN_TAG` if the tag isn't in the mapping.
47
+ * 3. Resolve the branch schema node via `ctx.resolve(ref)`.
48
+ * 4. Delegate encoding of value to the branch codec via `ctx.registry.resolve`,
49
+ * falling back to structural identity pass-through for plain object schemas.
50
+ *
51
+ * Decode (wire → host) — mirror of encode:
52
+ * 1. Validate the wire is a non-null, non-array object.
53
+ * Throws `E_UNION_INVALID_WIRE` on a non-object.
54
+ * 2. Read the discriminator tag off the wire object.
55
+ * Throws `E_UNION_NO_DISCRIMINATOR` if absent.
56
+ * 3. Map tag → branch `$ref` + resolve, then delegate to the branch codec.
57
+ * Throws `E_UNION_UNKNOWN_TAG` if the tag isn't in the mapping.
58
+ *
59
+ * `[inv:hints-advisory]`: stripping `x-apigen-logical:"union"` from the node
60
+ * passed to `encode`/`decode` produces identical output. Proven by the
61
+ * negative-control vector in the spec.
62
+ */
63
+ export declare function createUnionCodec(opts: UnionCodecOptions): LogicalTypeCodec<object>;
@@ -0,0 +1,21 @@
1
+ /** Result of adapting a composed-schema `output` fragment for MCP's `outputSchema` field. */
2
+ export interface McpOutputAdapter {
3
+ /** The MCP `Tool.outputSchema` value, or `undefined` when `output` carries no usable schema. */
4
+ outputSchema: Record<string, unknown> | undefined;
5
+ /** True when the output wasn't top-level `type:"object"` and had to be wrapped under `result`. */
6
+ wrapped: boolean;
7
+ }
8
+ /**
9
+ * Builds the MCP `outputSchema` for a composed schema's `output` fragment.
10
+ *
11
+ * @param output - `ComposedSchemas[fn].output`, whatever `buildSchema` produced
12
+ * (may be `type:"object"`, `oneOf`+`discriminator` for a union, an array, a
13
+ * bare scalar, or `undefined`/`{}` for an unresolved/void return).
14
+ */
15
+ export declare function buildMcpOutputSchema(output: unknown): McpOutputAdapter;
16
+ /**
17
+ * Wraps a tool's actual return value to match the `outputSchema` shape built by
18
+ * `buildMcpOutputSchema` for the SAME `output` fragment, for use as MCP's
19
+ * `structuredContent` (also constrained to a plain object by the SDK).
20
+ */
21
+ export declare function wrapMcpStructuredContent(wrapped: boolean, value: unknown): Record<string, unknown> | undefined;