@ontrails/library 0.2.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/src/derive.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `deriveLibraryApi` — the pure rendering from a topo to a `LibraryRenderingPlan`.
3
+ *
4
+ * This is the single semantic authority for the library surface: trail
5
+ * selection, export naming, collision resolution, and the per-export contract
6
+ * data the emitter renders all live here. The in-memory surface and the package
7
+ * emitter both consume the rendering; neither re-reads the topo nor reinvents
8
+ * selection. Pure — no fs/network/db reads (derive* purity contract).
9
+ */
10
+ import { filterSurfaceTrails, isDraftId } from '@ontrails/core';
11
+ import type { Layer, Topo, Trail } from '@ontrails/core';
12
+ import type { ZodType } from 'zod';
13
+
14
+ import { renderLibraryInput } from './layer-input.js';
15
+ import type { LibraryLayerInputRendering } from './layer-input.js';
16
+
17
+ type AnyTrail = Trail<unknown, unknown, unknown>;
18
+
19
+ /** Where a rendered export name came from. */
20
+ export type LibraryExportSource = 'derived' | 'trail-hint' | 'package-config';
21
+
22
+ /** A single rendered export: one trail rendered as a library entrypoint. */
23
+ export interface LibraryExport {
24
+ /** Consumer-native export name (camelCased trail id by default). */
25
+ readonly exportName: string;
26
+ /** The trail id this export renders (the source of truth). */
27
+ readonly trailId: string;
28
+ /** How `exportName` was chosen. v0 derives; hints/config override later. */
29
+ readonly nameSource: LibraryExportSource;
30
+ /**
31
+ * Authored intent, carried for safety presets and docs. Always present —
32
+ * core resolves an unset intent to `'write'`, so there is no fallback here.
33
+ */
34
+ readonly intent: 'read' | 'write' | 'destroy';
35
+ /** Current version for versioned trails; undefined when unversioned. */
36
+ readonly version: number | undefined;
37
+ /** Trail description — the JSDoc source the emitter carries forward. */
38
+ readonly description: string | undefined;
39
+ /**
40
+ * Input schema reference: the emitter's method-signature and `/schemas`
41
+ * source. An in-memory Zod reference here; it serializes to JSON Schema when
42
+ * the rendering is persisted to the artifact family.
43
+ */
44
+ readonly input: ZodType;
45
+ /** Layer input routing rendered onto this export's public library input. */
46
+ readonly layerInputs: readonly LibraryLayerInputRendering[];
47
+ /** Output schema reference, when the trail declares one. */
48
+ readonly output: ZodType | undefined;
49
+ /**
50
+ * Resource ids this export depends on. Empty means a stateless function
51
+ * export; non-empty means the emitter renders the export behind a
52
+ * `createX()` factory/client, grouped by shared resources. Carrying ids (not
53
+ * just a boolean) keeps the factory-grouping decision in this authority.
54
+ */
55
+ readonly resources: readonly string[];
56
+ }
57
+
58
+ /** Why a trail did not render into the library (doctrinally-meaningful only). */
59
+ export type LibraryExclusionReason = 'internal' | 'draft' | 'activation';
60
+
61
+ /**
62
+ * A trail deliberately excluded from the rendering, recorded for legibility.
63
+ * `reason` is the primary (first-matched) reason in precedence order
64
+ * draft > activation > internal; a trail may technically satisfy more than one.
65
+ */
66
+ export interface LibraryExclusion {
67
+ readonly trailId: string;
68
+ readonly reason: LibraryExclusionReason;
69
+ }
70
+
71
+ /** Two or more trails deriving the same export name. */
72
+ export interface LibraryCollision {
73
+ readonly exportName: string;
74
+ readonly trailIds: readonly string[];
75
+ }
76
+
77
+ /** The resolved library rendering — the story of a topo as a TypeScript library. */
78
+ export interface LibraryRenderingPlan {
79
+ /** The topo name. */
80
+ readonly app: string;
81
+ /** Rendered exports, in stable (trail-id-sorted) order. */
82
+ readonly exports: readonly LibraryExport[];
83
+ /** Trails excluded by visibility, draft state, or activation. */
84
+ readonly excluded: readonly LibraryExclusion[];
85
+ /** Export-name collisions; first-by-id wins in `exports`, all recorded here. */
86
+ readonly collisions: readonly LibraryCollision[];
87
+ }
88
+
89
+ /**
90
+ * Options for narrowing the rendering. Selectors reuse the trail-filter
91
+ * grammar (exact ids, `*`, `**`). v0 exposes include/exclude only; intent-based
92
+ * filtering is deferred (the packet promises the filter grammar, not intent
93
+ * narrowing, for the library surface).
94
+ */
95
+ export interface DeriveLibraryApiOptions {
96
+ /** Narrowing include patterns. Never widens drafts/internal. */
97
+ readonly include?: readonly string[];
98
+ /** Exclude patterns. */
99
+ readonly exclude?: readonly string[];
100
+ /** Surface-scope layers to render alongside each trail's own input. */
101
+ readonly layers?: readonly Layer[] | undefined;
102
+ }
103
+
104
+ /**
105
+ * Derive a consumer-native export name from a trail id: camelCase across `.`
106
+ * and `-` segments, preserving the full path so distinct trails stay distinct.
107
+ * `widget.get` -> `widgetGet`, `entity.list-all` -> `entityListAll`.
108
+ */
109
+ const deriveExportName = (trailId: string): string => {
110
+ const words = trailId.split(/[.-]/u).filter((word) => word.length > 0);
111
+ return words
112
+ .map((word, index) =>
113
+ index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)
114
+ )
115
+ .join('');
116
+ };
117
+
118
+ /**
119
+ * Whether a trail is surface-internal. Mirrors `effectiveVisibility` in
120
+ * `@ontrails/core` `surface-filter.ts`, which is not exported — so this is a
121
+ * deliberate local copy. Selection still routes through `filterSurfaceTrails`
122
+ * (authoritative); this duplication only labels exclusion reasons. If core's
123
+ * visibility rule gains a case, update this in lockstep.
124
+ */
125
+ const isInternal = (trail: AnyTrail): boolean =>
126
+ trail.visibility === 'internal' || trail.meta?.['internal'] === true;
127
+
128
+ /**
129
+ * Derive a topo into a `LibraryRenderingPlan`. Selection composes
130
+ * `filterSurfaceTrails` (visibility, activation, intent, include/exclude) and
131
+ * adds draft exclusion. Established public, current-version trails become
132
+ * exports; drafts, internal, and activation-driven trails are excluded.
133
+ *
134
+ * @example
135
+ * const rendering = deriveLibraryApi(app);
136
+ * for (const entry of rendering.exports) {
137
+ * console.log(entry.exportName, '->', entry.trailId);
138
+ * }
139
+ */
140
+ export const deriveLibraryApi = (
141
+ graph: Topo,
142
+ options: DeriveLibraryApiOptions = {}
143
+ ): LibraryRenderingPlan => {
144
+ const all = graph.list();
145
+
146
+ const selected = filterSurfaceTrails(all, {
147
+ exclude: options.exclude,
148
+ include: options.include,
149
+ }).filter((trail) => !isDraftId(trail.id));
150
+ const selectedIds = new Set(selected.map((trail) => trail.id));
151
+
152
+ const excluded: LibraryExclusion[] = [];
153
+ for (const trail of all) {
154
+ if (selectedIds.has(trail.id)) {
155
+ continue;
156
+ }
157
+ // Precedence: draft > activation > internal. A trail may satisfy more than
158
+ // one; the first match is recorded as the primary reason.
159
+ if (isDraftId(trail.id)) {
160
+ excluded.push({ reason: 'draft', trailId: trail.id });
161
+ } else if (trail.activationSources.length > 0) {
162
+ excluded.push({ reason: 'activation', trailId: trail.id });
163
+ } else if (isInternal(trail)) {
164
+ excluded.push({ reason: 'internal', trailId: trail.id });
165
+ }
166
+ // Trails dropped purely by an explicit include/exclude filter are not
167
+ // surprising exclusions and are not recorded.
168
+ }
169
+
170
+ const sorted = selected.toSorted((left, right) =>
171
+ left.id.localeCompare(right.id)
172
+ );
173
+ const namesToTrailIds = new Map<string, string[]>();
174
+ const collisionNames = new Set<string>();
175
+ const exports: LibraryExport[] = [];
176
+
177
+ for (const trail of sorted) {
178
+ const exportName = deriveExportName(trail.id);
179
+ const existing = namesToTrailIds.get(exportName);
180
+ if (existing) {
181
+ existing.push(trail.id);
182
+ collisionNames.add(exportName);
183
+ continue;
184
+ }
185
+ namesToTrailIds.set(exportName, [trail.id]);
186
+ const inputRendering = renderLibraryInput(graph, trail, options.layers);
187
+ exports.push({
188
+ description: trail.description,
189
+ exportName,
190
+ input: inputRendering.input,
191
+ intent: trail.intent,
192
+ layerInputs: inputRendering.layers,
193
+ nameSource: 'derived',
194
+ output: trail.output,
195
+ resources: trail.resources.map((resource) => resource.id),
196
+ trailId: trail.id,
197
+ version: trail.version,
198
+ });
199
+ }
200
+
201
+ const collisions: LibraryCollision[] = [...collisionNames].map((name) => ({
202
+ exportName: name,
203
+ trailIds: namesToTrailIds.get(name) ?? [],
204
+ }));
205
+
206
+ return { app: graph.name, collisions, excluded, exports };
207
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,191 @@
1
+ /* oxlint-disable max-classes-per-file -- package-facing error taxonomy stays co-located */
2
+ import {
3
+ createSurfaceErrorMapper,
4
+ isTrailsError,
5
+ renderPublicError,
6
+ } from '@ontrails/core';
7
+ import type { ErrorCategory } from '@ontrails/core';
8
+
9
+ export interface LibraryErrorOptions {
10
+ readonly cause?: Error | undefined;
11
+ readonly originalName?: string | undefined;
12
+ }
13
+
14
+ /** Base class for package-facing errors thrown or returned by the library surface. */
15
+ export class LibraryError extends Error {
16
+ readonly category: ErrorCategory;
17
+ readonly originalName: string;
18
+ readonly retryable: boolean;
19
+
20
+ constructor(
21
+ message: string,
22
+ options: LibraryErrorOptions & {
23
+ readonly category: ErrorCategory;
24
+ readonly name: string;
25
+ readonly retryable: boolean;
26
+ }
27
+ ) {
28
+ super(message, { cause: options.cause });
29
+ this.name = options.name;
30
+ this.category = options.category;
31
+ this.originalName = options.originalName ?? options.cause?.name ?? 'Error';
32
+ this.retryable = options.retryable;
33
+ }
34
+ }
35
+
36
+ export class LibraryValidationError extends LibraryError {
37
+ constructor(message: string, options?: LibraryErrorOptions) {
38
+ super(message, {
39
+ ...options,
40
+ category: 'validation',
41
+ name: 'LibraryValidationError',
42
+ retryable: false,
43
+ });
44
+ }
45
+ }
46
+
47
+ export class LibraryNotFoundError extends LibraryError {
48
+ constructor(message: string, options?: LibraryErrorOptions) {
49
+ super(message, {
50
+ ...options,
51
+ category: 'not_found',
52
+ name: 'LibraryNotFoundError',
53
+ retryable: false,
54
+ });
55
+ }
56
+ }
57
+
58
+ export class LibraryConflictError extends LibraryError {
59
+ constructor(message: string, options?: LibraryErrorOptions) {
60
+ super(message, {
61
+ ...options,
62
+ category: 'conflict',
63
+ name: 'LibraryConflictError',
64
+ retryable: false,
65
+ });
66
+ }
67
+ }
68
+
69
+ export class LibraryPermissionError extends LibraryError {
70
+ constructor(message: string, options?: LibraryErrorOptions) {
71
+ super(message, {
72
+ ...options,
73
+ category: 'permission',
74
+ name: 'LibraryPermissionError',
75
+ retryable: false,
76
+ });
77
+ }
78
+ }
79
+
80
+ export class LibraryTimeoutError extends LibraryError {
81
+ constructor(message: string, options?: LibraryErrorOptions) {
82
+ super(message, {
83
+ ...options,
84
+ category: 'timeout',
85
+ name: 'LibraryTimeoutError',
86
+ retryable: true,
87
+ });
88
+ }
89
+ }
90
+
91
+ export class LibraryRateLimitError extends LibraryError {
92
+ constructor(message: string, options?: LibraryErrorOptions) {
93
+ super(message, {
94
+ ...options,
95
+ category: 'rate_limit',
96
+ name: 'LibraryRateLimitError',
97
+ retryable: true,
98
+ });
99
+ }
100
+ }
101
+
102
+ export class LibraryNetworkError extends LibraryError {
103
+ constructor(message: string, options?: LibraryErrorOptions) {
104
+ super(message, {
105
+ ...options,
106
+ category: 'network',
107
+ name: 'LibraryNetworkError',
108
+ retryable: true,
109
+ });
110
+ }
111
+ }
112
+
113
+ export class LibraryShiftError extends LibraryError {
114
+ constructor(message: string, options?: LibraryErrorOptions) {
115
+ super(message, {
116
+ ...options,
117
+ category: 'shift',
118
+ name: 'LibraryShiftError',
119
+ retryable: true,
120
+ });
121
+ }
122
+ }
123
+
124
+ export class LibraryInternalError extends LibraryError {
125
+ constructor(message: string, options?: LibraryErrorOptions) {
126
+ super(message, {
127
+ ...options,
128
+ category: 'internal',
129
+ name: 'LibraryInternalError',
130
+ retryable: false,
131
+ });
132
+ }
133
+ }
134
+
135
+ export class LibraryAuthError extends LibraryError {
136
+ constructor(message: string, options?: LibraryErrorOptions) {
137
+ super(message, {
138
+ ...options,
139
+ category: 'auth',
140
+ name: 'LibraryAuthError',
141
+ retryable: false,
142
+ });
143
+ }
144
+ }
145
+
146
+ export class LibraryCancelledError extends LibraryError {
147
+ constructor(message: string, options?: LibraryErrorOptions) {
148
+ super(message, {
149
+ ...options,
150
+ category: 'cancelled',
151
+ name: 'LibraryCancelledError',
152
+ retryable: false,
153
+ });
154
+ }
155
+ }
156
+
157
+ const libraryErrorClasses = {
158
+ auth: LibraryAuthError,
159
+ cancelled: LibraryCancelledError,
160
+ conflict: LibraryConflictError,
161
+ internal: LibraryInternalError,
162
+ network: LibraryNetworkError,
163
+ not_found: LibraryNotFoundError,
164
+ permission: LibraryPermissionError,
165
+ rate_limit: LibraryRateLimitError,
166
+ shift: LibraryShiftError,
167
+ timeout: LibraryTimeoutError,
168
+ validation: LibraryValidationError,
169
+ } as const;
170
+
171
+ const mapLibraryErrorClass = createSurfaceErrorMapper(libraryErrorClasses);
172
+
173
+ export const toLibraryError = (error: Error): LibraryError => {
174
+ if (error instanceof LibraryError) {
175
+ return error;
176
+ }
177
+
178
+ const rendering = renderPublicError(error);
179
+ if (!isTrailsError(error)) {
180
+ return new LibraryInternalError(rendering.message, {
181
+ cause: error,
182
+ originalName: error.name || 'Error',
183
+ });
184
+ }
185
+
186
+ const ErrorClass = mapLibraryErrorClass(error);
187
+ return new ErrorClass(rendering.message, {
188
+ cause: error,
189
+ originalName: error.name,
190
+ });
191
+ };
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `@ontrails/library` — render a Trails topo as an idiomatic TypeScript library.
3
+ *
4
+ * The library surface is a peer of CLI, MCP, and HTTP. Its public ladder:
5
+ *
6
+ * - `deriveLibraryApi(graph, options)` — pure rendering → `LibraryRenderingPlan`
7
+ * - `surface(graph, options)` — in-memory callable client
8
+ * - `compile(graph, options)` — TypeScript package emitter
9
+ *
10
+ * Those land across the rendering, surface, and emitter lanes (Linear project
11
+ * "Library surface & compiler"). This scaffold establishes the package and the
12
+ * runtime-kernel seam; see `./kernel` and the Library Surface and Compiler ADR.
13
+ */
14
+ export { compile } from './compile.js';
15
+ export type { CompiledFile, CompileOptions, CompileResult } from './compile.js';
16
+ export { deriveLibraryApi } from './derive.js';
17
+ export type {
18
+ DeriveLibraryApiOptions,
19
+ LibraryCollision,
20
+ LibraryExclusion,
21
+ LibraryExclusionReason,
22
+ LibraryExport,
23
+ LibraryExportSource,
24
+ LibraryRenderingPlan,
25
+ } from './derive.js';
26
+ export {
27
+ LibraryAuthError,
28
+ LibraryCancelledError,
29
+ LibraryConflictError,
30
+ LibraryError,
31
+ LibraryInternalError,
32
+ LibraryNetworkError,
33
+ LibraryNotFoundError,
34
+ LibraryPermissionError,
35
+ LibraryRateLimitError,
36
+ LibraryShiftError,
37
+ LibraryTimeoutError,
38
+ LibraryValidationError,
39
+ toLibraryError,
40
+ } from './errors.js';
41
+ export type { LibraryErrorOptions } from './errors.js';
42
+ export { kernelRun } from './kernel.js';
43
+ export type { TrailInput, TrailOutput } from '@ontrails/core';
44
+ export type {
45
+ KernelRunOptions,
46
+ Result,
47
+ Topo,
48
+ TrailContextInit,
49
+ } from './kernel.js';
50
+ export { runLibraryResult, surface } from './surface.js';
51
+ export type {
52
+ LibraryClient,
53
+ LibraryMethod,
54
+ LibraryResultMethod,
55
+ SurfaceLibraryOptions,
56
+ } from './surface.js';
package/src/kernel.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The runtime kernel: the minimal, single-sourced surface through which the
3
+ * library surface (and, later, emitted packages) reach Trails execution.
4
+ *
5
+ * **This is the only module in `@ontrails/library` that imports execution
6
+ * primitives from `@ontrails/core`.** Everything else — the in-memory surface,
7
+ * the emitter's generated runtime — routes through here.
8
+ *
9
+ * Why this exists: standalone output (a generated package with no `@ontrails/*`
10
+ * runtime dependency) must be reachable as a *vendoring step*, not a rewrite.
11
+ * Confining the framework-runtime dependency to this one seam means "go
12
+ * standalone" reduces to "vendor this module's imports," with consumer code and
13
+ * package derivation unchanged. See the runtime-kernel section of the Library
14
+ * Surface and Compiler ADR.
15
+ *
16
+ * The kernel grows only as materialization demands: today it wraps execution;
17
+ * error rendering and the context/compose shim join it as the surface and
18
+ * emitter lanes land. Keep it minimal and dependency-light on purpose.
19
+ */
20
+ import { run } from '@ontrails/core';
21
+ import type {
22
+ Result,
23
+ RunOptions,
24
+ Topo,
25
+ TrailContextInit,
26
+ } from '@ontrails/core';
27
+
28
+ export type { Result, Topo, TrailContextInit };
29
+
30
+ /** Runtime options the kernel forwards to execution (permit, abort, layers, etc.). */
31
+ export type KernelRunOptions = Pick<
32
+ RunOptions,
33
+ | 'abortSignal'
34
+ | 'configValues'
35
+ | 'createContext'
36
+ | 'ctx'
37
+ | 'dryRun'
38
+ | 'layerInputs'
39
+ | 'permit'
40
+ | 'resources'
41
+ | 'surfaceLayers'
42
+ | 'topoLayers'
43
+ | 'version'
44
+ >;
45
+
46
+ /**
47
+ * Execute a trail by id through the shared Trails pipeline. Never throws —
48
+ * resolves to `Result.ok` on success or `Result.err(TrailsError)` on failure,
49
+ * exactly as `run()` does. The library surface unwraps this into return/throw
50
+ * (root API) or returns it directly (`/result`).
51
+ *
52
+ * @example
53
+ * const result = await kernelRun(topo, 'thing.check', { root: '.' });
54
+ * if (result.isOk()) {
55
+ * // result.value is the trail output
56
+ * }
57
+ */
58
+ export const kernelRun = (
59
+ topo: Topo,
60
+ id: string,
61
+ input: unknown,
62
+ options: KernelRunOptions = {}
63
+ ): Promise<Result<unknown, Error>> => run(topo, id, input, options);
@@ -0,0 +1,188 @@
1
+ import type { AttachedTypedLayer, Layer, Topo, Trail } from '@ontrails/core';
2
+ import {
3
+ LAYER_FIELD_RESERVED_NAMES,
4
+ collectAttachedTypedLayers,
5
+ renderLayerFieldName,
6
+ zodToJsonSchema,
7
+ } from '@ontrails/core';
8
+ import { z } from 'zod';
9
+
10
+ type AnyTrail = Trail<unknown, unknown, unknown>;
11
+ type MutableLayerShape = Record<string, z.ZodRawShape[string]>;
12
+
13
+ export interface LibraryLayerFieldRendering {
14
+ readonly claimedName: string;
15
+ readonly routingTarget: string;
16
+ }
17
+
18
+ export interface LibraryLayerInputRendering {
19
+ readonly fields: readonly LibraryLayerFieldRendering[];
20
+ readonly input: z.ZodObject<z.ZodRawShape>;
21
+ readonly layerName: string;
22
+ }
23
+
24
+ export interface LibraryInputRendering {
25
+ readonly input: z.ZodType;
26
+ readonly layers: readonly LibraryLayerInputRendering[];
27
+ }
28
+
29
+ const isJsonObjectSchema = (
30
+ value: unknown
31
+ ): value is { properties?: Record<string, unknown> } =>
32
+ typeof value === 'object' && value !== null && !Array.isArray(value);
33
+
34
+ const isObjectRecord = (
35
+ value: unknown
36
+ ): value is Readonly<Record<string, unknown>> =>
37
+ typeof value === 'object' && value !== null && !Array.isArray(value);
38
+
39
+ const capitalized = (value: string): string => {
40
+ if (value.length === 0) {
41
+ return value;
42
+ }
43
+ return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
44
+ };
45
+
46
+ const buildLibraryRenameTarget = (
47
+ layerName: string,
48
+ originalName: string
49
+ ): string => `${layerName}${capitalized(originalName)}`;
50
+
51
+ const objectPropertiesFor = (schema: z.ZodType): readonly string[] => {
52
+ const jsonSchema = zodToJsonSchema(schema);
53
+ if (!isJsonObjectSchema(jsonSchema) || jsonSchema.properties === undefined) {
54
+ return [];
55
+ }
56
+ return Object.keys(jsonSchema.properties);
57
+ };
58
+
59
+ const isJsonObjectInput = (schema: z.ZodType): boolean =>
60
+ isJsonObjectSchema(zodToJsonSchema(schema));
61
+
62
+ const renderLayerInputFields = (
63
+ attached: AttachedTypedLayer,
64
+ claimedNames: Set<string>,
65
+ layerShape: MutableLayerShape
66
+ ): LibraryLayerInputRendering => {
67
+ const { layer } = attached;
68
+ if (layer.input === undefined) {
69
+ return { fields: [], input: z.object({}), layerName: layer.name };
70
+ }
71
+
72
+ const fields: LibraryLayerFieldRendering[] = [];
73
+ for (const [fieldName, fieldSchema] of Object.entries(layer.input.shape)) {
74
+ const rendering = renderLayerFieldName(
75
+ layer.name,
76
+ fieldName,
77
+ fieldName,
78
+ buildLibraryRenameTarget(layer.name, fieldName),
79
+ claimedNames,
80
+ LAYER_FIELD_RESERVED_NAMES
81
+ );
82
+ fields.push({
83
+ claimedName: rendering.claimedName,
84
+ routingTarget: rendering.routingTarget,
85
+ });
86
+ layerShape[rendering.claimedName] = fieldSchema;
87
+ }
88
+
89
+ return { fields, input: layer.input, layerName: layer.name };
90
+ };
91
+
92
+ /**
93
+ * Render a trail's public library input: authored trail input plus any typed
94
+ * layer input fields attached at topo, surface, or trail scope.
95
+ */
96
+ export const renderLibraryInput = (
97
+ graph: Topo,
98
+ trail: AnyTrail,
99
+ surfaceLayers?: readonly Layer[]
100
+ ): LibraryInputRendering => {
101
+ const attachedLayers = collectAttachedTypedLayers(
102
+ graph,
103
+ trail,
104
+ surfaceLayers
105
+ );
106
+ if (attachedLayers.length === 0) {
107
+ return { input: trail.input, layers: [] };
108
+ }
109
+
110
+ const trailProperties = objectPropertiesFor(trail.input);
111
+ const claimedNames = new Set(trailProperties);
112
+ if (trailProperties.length === 0 && !isJsonObjectInput(trail.input)) {
113
+ throw new Error(
114
+ `Library layer input rendering requires object input for trail "${trail.id}".`
115
+ );
116
+ }
117
+
118
+ const layerShape: MutableLayerShape = {};
119
+ const layers: LibraryLayerInputRendering[] = [];
120
+ for (const attached of attachedLayers) {
121
+ const rendering = renderLayerInputFields(
122
+ attached,
123
+ claimedNames,
124
+ layerShape
125
+ );
126
+ if (rendering.fields.length > 0) {
127
+ layers.push(rendering);
128
+ }
129
+ }
130
+
131
+ if (layers.length === 0) {
132
+ return { input: trail.input, layers: [] };
133
+ }
134
+
135
+ return {
136
+ input: trail.input.and(z.object(layerShape)),
137
+ layers,
138
+ };
139
+ };
140
+
141
+ /**
142
+ * Split a library method input back into trail input plus per-layer runtime
143
+ * input slots using the rendering routing table.
144
+ */
145
+ export const partitionLibraryInput = (
146
+ input: unknown,
147
+ renderings: readonly LibraryLayerInputRendering[]
148
+ ): {
149
+ readonly layerInputs: Record<string, unknown>;
150
+ readonly trailInput: unknown;
151
+ } => {
152
+ if (renderings.length === 0 || !isObjectRecord(input)) {
153
+ return { layerInputs: {}, trailInput: input };
154
+ }
155
+
156
+ const claimedKeys = new Set<string>();
157
+ const layerInputs: Record<string, unknown> = {};
158
+ for (const rendering of renderings) {
159
+ const layerInput: Record<string, unknown> = {};
160
+ let received = false;
161
+ for (const field of rendering.fields) {
162
+ claimedKeys.add(field.claimedName);
163
+ const value = input[field.claimedName];
164
+ if (value === undefined) {
165
+ continue;
166
+ }
167
+ layerInput[field.routingTarget] = value;
168
+ received = true;
169
+ }
170
+ if (received) {
171
+ layerInputs[rendering.layerName] = layerInput;
172
+ continue;
173
+ }
174
+
175
+ const emptyInput = rendering.input.safeParse({});
176
+ if (emptyInput.success) {
177
+ layerInputs[rendering.layerName] = emptyInput.data;
178
+ }
179
+ }
180
+
181
+ const trailInput: Record<string, unknown> = {};
182
+ for (const [key, value] of Object.entries(input)) {
183
+ if (!claimedKeys.has(key)) {
184
+ trailInput[key] = value;
185
+ }
186
+ }
187
+ return { layerInputs, trailInput };
188
+ };