@ontrails/core 1.0.0-beta.3 → 1.0.0-beta.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +323 -8
  2. package/README.md +114 -33
  3. package/package.json +11 -1
  4. package/src/activation-provenance.ts +111 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-projection.ts +221 -0
  7. package/src/activation-source.ts +91 -0
  8. package/src/blob-ref.ts +51 -0
  9. package/src/branded.ts +1 -1
  10. package/src/compose-batch.ts +69 -0
  11. package/src/compose-schema.ts +36 -0
  12. package/src/context.ts +44 -10
  13. package/src/contour.ts +346 -0
  14. package/src/derive.ts +250 -19
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/error-projection.ts +51 -0
  19. package/src/errors.ts +335 -48
  20. package/src/execute.ts +1577 -0
  21. package/src/fire.ts +1169 -0
  22. package/src/glob.ts +81 -0
  23. package/src/index.ts +526 -35
  24. package/src/internal/fork-ctx.ts +69 -0
  25. package/src/layer-projection.ts +193 -0
  26. package/src/layer.ts +43 -6
  27. package/src/observe.ts +361 -0
  28. package/src/path-scope.ts +66 -0
  29. package/src/path-security.ts +1 -1
  30. package/src/permits.ts +12 -0
  31. package/src/redaction/patterns.ts +6 -3
  32. package/src/resilience.ts +11 -11
  33. package/src/resource-config.ts +792 -0
  34. package/src/resource.ts +194 -0
  35. package/src/result.ts +18 -0
  36. package/src/run.ts +76 -0
  37. package/src/schedule-runtime.ts +689 -0
  38. package/src/schedule.ts +326 -0
  39. package/src/serialization.ts +92 -55
  40. package/src/signal-diagnostics.ts +633 -0
  41. package/src/signal-ref.ts +87 -0
  42. package/src/signal.ts +104 -0
  43. package/src/store/accessor-protocol.ts +56 -0
  44. package/src/store/index.ts +4 -0
  45. package/src/structured-examples.ts +248 -0
  46. package/src/surface-derivation.ts +91 -0
  47. package/src/surface-filter.ts +101 -0
  48. package/src/surface-versioning.ts +42 -0
  49. package/src/topo.ts +769 -67
  50. package/src/tracing.ts +345 -0
  51. package/src/trail-id-glob.ts +15 -0
  52. package/src/trail.ts +1168 -30
  53. package/src/trails/derive-trail.ts +842 -0
  54. package/src/trails/index.ts +9 -0
  55. package/src/trails/ingest.ts +139 -0
  56. package/src/trails-db.ts +206 -0
  57. package/src/transport-error-map.ts +174 -0
  58. package/src/type-utils.ts +87 -0
  59. package/src/types.ts +254 -12
  60. package/src/validate-established-topo.ts +69 -0
  61. package/src/validate-topo.ts +414 -56
  62. package/src/validation.ts +152 -4
  63. package/src/version-marker.ts +716 -0
  64. package/src/version-resolution.ts +308 -0
  65. package/src/version-runtime.ts +120 -0
  66. package/src/webhook.ts +278 -0
  67. package/src/workspace.ts +162 -1
  68. package/src/zod-wrappers.ts +72 -0
  69. package/.turbo/turbo-build.log +0 -1
  70. package/.turbo/turbo-lint.log +0 -3
  71. package/.turbo/turbo-typecheck.log +0 -1
  72. package/dist/adapters.d.ts +0 -39
  73. package/dist/adapters.d.ts.map +0 -1
  74. package/dist/adapters.js +0 -2
  75. package/dist/adapters.js.map +0 -1
  76. package/dist/blob-ref.d.ts +0 -20
  77. package/dist/blob-ref.d.ts.map +0 -1
  78. package/dist/blob-ref.js +0 -22
  79. package/dist/blob-ref.js.map +0 -1
  80. package/dist/branded.d.ts +0 -36
  81. package/dist/branded.d.ts.map +0 -1
  82. package/dist/branded.js +0 -89
  83. package/dist/branded.js.map +0 -1
  84. package/dist/collections.d.ts +0 -31
  85. package/dist/collections.d.ts.map +0 -1
  86. package/dist/collections.js +0 -60
  87. package/dist/collections.js.map +0 -1
  88. package/dist/context.d.ts +0 -10
  89. package/dist/context.d.ts.map +0 -1
  90. package/dist/context.js +0 -15
  91. package/dist/context.js.map +0 -1
  92. package/dist/derive.d.ts +0 -33
  93. package/dist/derive.d.ts.map +0 -1
  94. package/dist/derive.js +0 -125
  95. package/dist/derive.js.map +0 -1
  96. package/dist/errors.d.ts +0 -83
  97. package/dist/errors.d.ts.map +0 -1
  98. package/dist/errors.js +0 -142
  99. package/dist/errors.js.map +0 -1
  100. package/dist/event.d.ts +0 -45
  101. package/dist/event.d.ts.map +0 -1
  102. package/dist/event.js +0 -17
  103. package/dist/event.js.map +0 -1
  104. package/dist/fetch.d.ts +0 -15
  105. package/dist/fetch.d.ts.map +0 -1
  106. package/dist/fetch.js +0 -102
  107. package/dist/fetch.js.map +0 -1
  108. package/dist/guards.d.ts +0 -17
  109. package/dist/guards.d.ts.map +0 -1
  110. package/dist/guards.js +0 -25
  111. package/dist/guards.js.map +0 -1
  112. package/dist/health.d.ts +0 -18
  113. package/dist/health.d.ts.map +0 -1
  114. package/dist/health.js +0 -5
  115. package/dist/health.js.map +0 -1
  116. package/dist/hike.d.ts +0 -36
  117. package/dist/hike.d.ts.map +0 -1
  118. package/dist/hike.js +0 -20
  119. package/dist/hike.js.map +0 -1
  120. package/dist/index.d.ts +0 -36
  121. package/dist/index.d.ts.map +0 -1
  122. package/dist/index.js +0 -40
  123. package/dist/index.js.map +0 -1
  124. package/dist/job.d.ts +0 -24
  125. package/dist/job.d.ts.map +0 -1
  126. package/dist/job.js +0 -17
  127. package/dist/job.js.map +0 -1
  128. package/dist/layer.d.ts +0 -17
  129. package/dist/layer.d.ts.map +0 -1
  130. package/dist/layer.js +0 -21
  131. package/dist/layer.js.map +0 -1
  132. package/dist/path-security.d.ts +0 -28
  133. package/dist/path-security.d.ts.map +0 -1
  134. package/dist/path-security.js +0 -63
  135. package/dist/path-security.js.map +0 -1
  136. package/dist/patterns/bulk.d.ts +0 -15
  137. package/dist/patterns/bulk.d.ts.map +0 -1
  138. package/dist/patterns/bulk.js +0 -14
  139. package/dist/patterns/bulk.js.map +0 -1
  140. package/dist/patterns/change.d.ts +0 -10
  141. package/dist/patterns/change.d.ts.map +0 -1
  142. package/dist/patterns/change.js +0 -10
  143. package/dist/patterns/change.js.map +0 -1
  144. package/dist/patterns/date-range.d.ts +0 -10
  145. package/dist/patterns/date-range.d.ts.map +0 -1
  146. package/dist/patterns/date-range.js +0 -10
  147. package/dist/patterns/date-range.js.map +0 -1
  148. package/dist/patterns/index.d.ts +0 -9
  149. package/dist/patterns/index.d.ts.map +0 -1
  150. package/dist/patterns/index.js +0 -9
  151. package/dist/patterns/index.js.map +0 -1
  152. package/dist/patterns/pagination.d.ts +0 -18
  153. package/dist/patterns/pagination.d.ts.map +0 -1
  154. package/dist/patterns/pagination.js +0 -18
  155. package/dist/patterns/pagination.js.map +0 -1
  156. package/dist/patterns/progress.d.ts +0 -11
  157. package/dist/patterns/progress.d.ts.map +0 -1
  158. package/dist/patterns/progress.js +0 -11
  159. package/dist/patterns/progress.js.map +0 -1
  160. package/dist/patterns/sorting.d.ts +0 -13
  161. package/dist/patterns/sorting.d.ts.map +0 -1
  162. package/dist/patterns/sorting.js +0 -10
  163. package/dist/patterns/sorting.js.map +0 -1
  164. package/dist/patterns/status.d.ts +0 -15
  165. package/dist/patterns/status.d.ts.map +0 -1
  166. package/dist/patterns/status.js +0 -9
  167. package/dist/patterns/status.js.map +0 -1
  168. package/dist/patterns/timestamps.d.ts +0 -10
  169. package/dist/patterns/timestamps.d.ts.map +0 -1
  170. package/dist/patterns/timestamps.js +0 -10
  171. package/dist/patterns/timestamps.js.map +0 -1
  172. package/dist/redaction/index.d.ts +0 -4
  173. package/dist/redaction/index.d.ts.map +0 -1
  174. package/dist/redaction/index.js +0 -3
  175. package/dist/redaction/index.js.map +0 -1
  176. package/dist/redaction/patterns.d.ts +0 -9
  177. package/dist/redaction/patterns.d.ts.map +0 -1
  178. package/dist/redaction/patterns.js +0 -39
  179. package/dist/redaction/patterns.js.map +0 -1
  180. package/dist/redaction/redactor.d.ts +0 -27
  181. package/dist/redaction/redactor.d.ts.map +0 -1
  182. package/dist/redaction/redactor.js +0 -89
  183. package/dist/redaction/redactor.js.map +0 -1
  184. package/dist/resilience.d.ts +0 -34
  185. package/dist/resilience.d.ts.map +0 -1
  186. package/dist/resilience.js +0 -164
  187. package/dist/resilience.js.map +0 -1
  188. package/dist/result.d.ts +0 -57
  189. package/dist/result.d.ts.map +0 -1
  190. package/dist/result.js +0 -156
  191. package/dist/result.js.map +0 -1
  192. package/dist/serialization.d.ts +0 -27
  193. package/dist/serialization.d.ts.map +0 -1
  194. package/dist/serialization.js +0 -153
  195. package/dist/serialization.js.map +0 -1
  196. package/dist/topo.d.ts +0 -18
  197. package/dist/topo.d.ts.map +0 -1
  198. package/dist/topo.js +0 -80
  199. package/dist/topo.js.map +0 -1
  200. package/dist/trail.d.ts +0 -83
  201. package/dist/trail.d.ts.map +0 -1
  202. package/dist/trail.js +0 -16
  203. package/dist/trail.js.map +0 -1
  204. package/dist/types.d.ts +0 -46
  205. package/dist/types.d.ts.map +0 -1
  206. package/dist/types.js +0 -2
  207. package/dist/types.js.map +0 -1
  208. package/dist/validate-topo.d.ts +0 -24
  209. package/dist/validate-topo.d.ts.map +0 -1
  210. package/dist/validate-topo.js +0 -156
  211. package/dist/validate-topo.js.map +0 -1
  212. package/dist/validation.d.ts +0 -27
  213. package/dist/validation.d.ts.map +0 -1
  214. package/dist/validation.js +0 -134
  215. package/dist/validation.js.map +0 -1
  216. package/dist/workspace.d.ts +0 -25
  217. package/dist/workspace.d.ts.map +0 -1
  218. package/dist/workspace.js +0 -57
  219. package/dist/workspace.js.map +0 -1
  220. package/src/__tests__/blob-ref.test.ts +0 -103
  221. package/src/__tests__/branded.test.ts +0 -148
  222. package/src/__tests__/collections.test.ts +0 -126
  223. package/src/__tests__/context.test.ts +0 -66
  224. package/src/__tests__/derive.test.ts +0 -203
  225. package/src/__tests__/errors.test.ts +0 -309
  226. package/src/__tests__/event.test.ts +0 -82
  227. package/src/__tests__/fetch.test.ts +0 -217
  228. package/src/__tests__/guards.test.ts +0 -102
  229. package/src/__tests__/hike.test.ts +0 -117
  230. package/src/__tests__/job.test.ts +0 -98
  231. package/src/__tests__/layer.test.ts +0 -224
  232. package/src/__tests__/path-security.test.ts +0 -114
  233. package/src/__tests__/patterns.test.ts +0 -273
  234. package/src/__tests__/redaction.test.ts +0 -244
  235. package/src/__tests__/resilience.test.ts +0 -246
  236. package/src/__tests__/result.test.ts +0 -155
  237. package/src/__tests__/serialization.test.ts +0 -401
  238. package/src/__tests__/topo.test.ts +0 -220
  239. package/src/__tests__/trail.test.ts +0 -179
  240. package/src/__tests__/validate-topo.test.ts +0 -280
  241. package/src/__tests__/validation.test.ts +0 -283
  242. package/src/__tests__/workspace.test.ts +0 -183
  243. package/src/adapters.ts +0 -68
  244. package/src/event.ts +0 -77
  245. package/src/health.ts +0 -23
  246. package/src/hike.ts +0 -77
  247. package/src/job.ts +0 -20
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
package/src/contour.ts ADDED
@@ -0,0 +1,346 @@
1
+ import { z } from 'zod';
2
+
3
+ import type { Branded } from './branded.js';
4
+
5
+ /**
6
+ * Runtime options for a contour declaration.
7
+ */
8
+ export interface ContourOptions<
9
+ TShape extends z.ZodRawShape,
10
+ TIdentity extends keyof TShape & string,
11
+ > {
12
+ /** Field name that acts as the contour's primary identity. */
13
+ readonly identity: TIdentity;
14
+ /** Example instances validated against the contour schema at declaration time. */
15
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
16
+ /** Reserved for future contour-specific design; trail versioning is trail-only. */
17
+ readonly version?: never;
18
+ }
19
+
20
+ /** Type-level brand name applied to a contour's identity schema. */
21
+ export type ContourIdBrand<TName extends string> = `${Capitalize<TName>}Id`;
22
+
23
+ type BrandedSchema<
24
+ TSchema extends z.core.$ZodType,
25
+ TBrand extends string,
26
+ > = TSchema & z.ZodType<Branded<z.output<TSchema>, TBrand>>;
27
+
28
+ type BrandableSchema<TSchema extends z.core.$ZodType> = TSchema & {
29
+ brand<TBrand extends string>(): BrandedSchema<TSchema, TBrand>;
30
+ };
31
+
32
+ /** Output value of a branded contour identity schema. */
33
+ export type ContourIdValue<
34
+ TSchema extends z.core.$ZodType,
35
+ TName extends string,
36
+ > = Branded<z.output<TSchema>, ContourIdBrand<TName>>;
37
+
38
+ /** Runtime metadata attached to schemas returned from `contour.id()`. */
39
+ export interface ContourIdMetadata<
40
+ TName extends string = string,
41
+ TIdentity extends string = string,
42
+ > {
43
+ readonly contour: TName;
44
+ readonly identity: TIdentity;
45
+ }
46
+
47
+ /** A structural contour reference declared by another contour field schema. */
48
+ export interface ContourReference<
49
+ TName extends string = string,
50
+ TIdentity extends string = string,
51
+ > extends ContourIdMetadata<TName, TIdentity> {
52
+ readonly field: string;
53
+ }
54
+
55
+ /** Symbol used to tag branded contour reference schemas at runtime. */
56
+ export const CONTOUR_ID_METADATA = Symbol.for('@ontrails/core/contour-id');
57
+
58
+ /**
59
+ * Module-level WeakMap storing contour identity metadata keyed by schema object.
60
+ *
61
+ * First-write-wins: when multiple contours share the same underlying schema
62
+ * (e.g. `contour('admin', { id: user.shape.id }, ...)`), the first contour to
63
+ * brand the schema claims it. Subsequent calls skip the write to prevent
64
+ * silent metadata corruption.
65
+ */
66
+ const contourIdMetadata = new WeakMap<object, ContourIdMetadata>();
67
+
68
+ /**
69
+ * A contour identity schema branded for one contour and tagged with runtime
70
+ * metadata so the topo layer can recognize declared references later on.
71
+ */
72
+ export type ContourIdSchema<
73
+ TSchema extends z.core.$ZodType = z.core.$ZodType,
74
+ TName extends string = string,
75
+ TIdentity extends string = string,
76
+ > = BrandedSchema<TSchema, ContourIdBrand<TName>> & {
77
+ /** @deprecated Use `getContourIdMetadata()` — metadata lives in a WeakMap, not on the schema. */
78
+ readonly [CONTOUR_ID_METADATA]?: ContourIdMetadata<TName, TIdentity>;
79
+ };
80
+
81
+ /**
82
+ * A first-class domain object with schema, identity metadata, and examples.
83
+ *
84
+ * A contour behaves like the `ZodObject` it wraps, so standard Zod composition
85
+ * helpers such as `.pick()`, `.extend()`, and `.array()` continue to work.
86
+ */
87
+ export type Contour<
88
+ TName extends string = string,
89
+ TShape extends z.ZodRawShape = z.ZodRawShape,
90
+ TIdentity extends keyof TShape & string = keyof TShape & string,
91
+ > = z.ZodObject<TShape> & {
92
+ readonly kind: 'contour';
93
+ readonly name: TName;
94
+ readonly identity: TIdentity;
95
+ readonly identitySchema: TShape[TIdentity];
96
+ readonly id: () => ContourIdSchema<TShape[TIdentity], TName, TIdentity>;
97
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
98
+ };
99
+
100
+ const formatExampleIssues = (issues: readonly z.core.$ZodIssue[]): string =>
101
+ issues
102
+ .map((issue) => {
103
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
104
+ return `${path}: ${issue.message}`;
105
+ })
106
+ .join('; ');
107
+
108
+ const assertIdentityField = <
109
+ TShape extends z.ZodRawShape,
110
+ TIdentity extends keyof TShape & string,
111
+ >(
112
+ name: string,
113
+ shape: TShape,
114
+ identity: TIdentity
115
+ ): void => {
116
+ if (!Object.hasOwn(shape, identity)) {
117
+ throw new TypeError(
118
+ `contour("${name}") identity "${identity}" must match a declared field`
119
+ );
120
+ }
121
+ };
122
+
123
+ const assertExamples = <TShape extends z.ZodRawShape>(
124
+ name: string,
125
+ schema: z.ZodObject<TShape>,
126
+ examples: readonly z.output<z.ZodObject<TShape>>[]
127
+ ): void => {
128
+ for (const [index, example] of examples.entries()) {
129
+ const parsed = schema.safeParse(example);
130
+ if (!parsed.success) {
131
+ throw new TypeError(
132
+ `contour("${name}") example ${index} is invalid: ${formatExampleIssues(parsed.error.issues)}`
133
+ );
134
+ }
135
+ }
136
+ };
137
+
138
+ const validateExamples = <TShape extends z.ZodRawShape>(
139
+ name: string,
140
+ schema: z.ZodObject<TShape>,
141
+ examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined
142
+ ): void => {
143
+ if (examples) {
144
+ assertExamples(name, schema, examples);
145
+ }
146
+ };
147
+
148
+ const brandIdentitySchema = <
149
+ TSchema extends z.core.$ZodType,
150
+ TName extends string,
151
+ TIdentity extends string,
152
+ >(
153
+ contour: TName,
154
+ identity: TIdentity,
155
+ schema: TSchema
156
+ ): ContourIdSchema<TSchema, TName, TIdentity> => {
157
+ const branded = (schema as BrandableSchema<TSchema>).brand<
158
+ ContourIdBrand<TName>
159
+ >();
160
+
161
+ // First-write-wins: if another contour already claimed this schema object
162
+ // (possible when Zod v4 brand() returns `this`), preserve the original
163
+ // metadata rather than silently overwriting it.
164
+ if (!contourIdMetadata.has(branded)) {
165
+ contourIdMetadata.set(branded, {
166
+ contour,
167
+ identity,
168
+ } satisfies ContourIdMetadata<TName, TIdentity>);
169
+ }
170
+
171
+ return branded as ContourIdSchema<TSchema, TName, TIdentity>;
172
+ };
173
+
174
+ const attachContourMetadata = <
175
+ TName extends string,
176
+ TShape extends z.ZodRawShape,
177
+ TIdentity extends keyof TShape & string,
178
+ >(
179
+ schema: z.ZodObject<TShape>,
180
+ metadata: {
181
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
182
+ readonly idSchema: ContourIdSchema<TShape[TIdentity], TName, TIdentity>;
183
+ readonly identity: TIdentity;
184
+ readonly identitySchema: TShape[TIdentity];
185
+ readonly name: TName;
186
+ }
187
+ ): void => {
188
+ Object.defineProperties(schema, {
189
+ examples: {
190
+ enumerable: true,
191
+ value: metadata.examples,
192
+ writable: false,
193
+ },
194
+ id: {
195
+ enumerable: true,
196
+ value: () => metadata.idSchema,
197
+ writable: false,
198
+ },
199
+ identity: {
200
+ enumerable: true,
201
+ value: metadata.identity,
202
+ writable: false,
203
+ },
204
+ identitySchema: {
205
+ enumerable: true,
206
+ value: metadata.identitySchema,
207
+ writable: false,
208
+ },
209
+ kind: {
210
+ enumerable: true,
211
+ value: 'contour',
212
+ writable: false,
213
+ },
214
+ name: {
215
+ enumerable: true,
216
+ value: metadata.name,
217
+ writable: false,
218
+ },
219
+ });
220
+ };
221
+
222
+ /** Read contour identity metadata from the module-level WeakMap, if present. */
223
+ const readMetadata = (schema: unknown): ContourIdMetadata | undefined =>
224
+ typeof schema === 'object' && schema !== null
225
+ ? contourIdMetadata.get(schema)
226
+ : undefined;
227
+
228
+ /** Resolve the inner schema from a Zod wrapper (ZodOptional, ZodNullable, etc.). */
229
+ const unwrapInner = (schema: unknown): unknown => {
230
+ const def = (schema as { _def?: Record<string, unknown> })._def;
231
+ return (def?.['innerType'] ?? def?.['schema']) as unknown;
232
+ };
233
+
234
+ /**
235
+ * Walk through Zod wrapper layers searching for `CONTOUR_ID_METADATA`.
236
+ *
237
+ * `.nullish()` produces `ZodOptional<ZodNullable<T>>` — two wrapper levels —
238
+ * so a single-step unwrap is insufficient. This iterates until it finds the
239
+ * metadata or exhausts all wrapper layers.
240
+ */
241
+ const unwrapToMetadata = (schema: unknown): ContourIdMetadata | undefined => {
242
+ let current: unknown = schema;
243
+ while (typeof current === 'object' && current !== null) {
244
+ const inner = unwrapInner(current);
245
+ if (typeof inner !== 'object' || inner === null) {
246
+ return undefined;
247
+ }
248
+ const metadata = readMetadata(inner);
249
+ if (metadata !== undefined) {
250
+ return metadata;
251
+ }
252
+ current = inner;
253
+ }
254
+ return undefined;
255
+ };
256
+
257
+ /**
258
+ * Read contour-reference metadata from a schema returned by `contour.id()`.
259
+ *
260
+ * When the schema is wrapped by Zod combinators (`.optional()`, `.nullable()`,
261
+ * `.default()`, `.nullish()`, etc.) the `CONTOUR_ID_METADATA` symbol lives on
262
+ * the inner schema, not on the wrapper. The unwrap handles arbitrarily nested
263
+ * wrapper levels.
264
+ */
265
+ export const getContourIdMetadata = (
266
+ schema: unknown
267
+ ): ContourIdMetadata | undefined =>
268
+ readMetadata(schema) ?? unwrapToMetadata(schema);
269
+
270
+ /** Inspect a contour schema for fields that reference other contours via `.id()`. */
271
+ export const getContourReferences = (
272
+ contour: AnyContour
273
+ ): readonly ContourReference[] =>
274
+ Object.entries(contour.shape)
275
+ .flatMap(([field, schema]) => {
276
+ if (field === contour.identity) {
277
+ return [];
278
+ }
279
+ const metadata = getContourIdMetadata(schema);
280
+ if (metadata === undefined) {
281
+ return [];
282
+ }
283
+
284
+ return [{ field, ...metadata }];
285
+ })
286
+ .toSorted((left, right) =>
287
+ left.field === right.field
288
+ ? left.contour.localeCompare(right.contour)
289
+ : left.field.localeCompare(right.field)
290
+ );
291
+
292
+ /**
293
+ * Create a contour definition from a raw Zod object shape.
294
+ *
295
+ * @example
296
+ * ```typescript
297
+ * const user = contour(
298
+ * 'user',
299
+ * {
300
+ * id: z.string().uuid(),
301
+ * email: z.string().email(),
302
+ * name: z.string(),
303
+ * },
304
+ * { identity: 'id' }
305
+ * );
306
+ * ```
307
+ */
308
+ export const contour = <
309
+ TName extends string,
310
+ TShape extends z.ZodRawShape,
311
+ TIdentity extends keyof TShape & string,
312
+ >(
313
+ name: TName,
314
+ shape: TShape,
315
+ options: ContourOptions<TShape, TIdentity>
316
+ ): Contour<TName, TShape, TIdentity> => {
317
+ assertIdentityField(name, shape, options.identity);
318
+
319
+ const schema = z.object(shape);
320
+ validateExamples(name, schema, options.examples);
321
+
322
+ const identitySchema = shape[options.identity];
323
+ if (!identitySchema) {
324
+ throw new TypeError(
325
+ `contour("${name}") identity "${options.identity}" must resolve to a schema`
326
+ );
327
+ }
328
+
329
+ const idSchema = brandIdentitySchema(name, options.identity, identitySchema);
330
+ const examples = options.examples
331
+ ? Object.freeze([...options.examples])
332
+ : undefined;
333
+
334
+ attachContourMetadata(schema, {
335
+ examples,
336
+ idSchema,
337
+ identity: options.identity,
338
+ identitySchema,
339
+ name,
340
+ });
341
+
342
+ return schema as Contour<TName, TShape, TIdentity>;
343
+ };
344
+
345
+ /** Existential type for heterogeneous contour collections. */
346
+ export type AnyContour = Contour<string, z.ZodRawShape, string>;
package/src/derive.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * Schema-driven field derivation for @ontrails/core
3
3
  *
4
- * Introspects Zod v4 schemas to produce a surface-agnostic Field[] descriptor
5
- * that UI layers (CLI prompts, web forms, etc.) can consume.
4
+ * Introspects Zod v4 schemas to produce a runtime-agnostic Field[] descriptor
5
+ * that UI consumers (CLI prompts, web forms, etc.) can consume.
6
6
  */
7
7
 
8
8
  import type { z } from 'zod';
9
9
 
10
+ import { ValidationError } from './errors.js';
11
+
10
12
  // ---------------------------------------------------------------------------
11
13
  // Public types
12
14
  // ---------------------------------------------------------------------------
13
15
 
14
- /** A surface-agnostic field descriptor derived from a Zod schema. */
16
+ /** A runtime-agnostic field descriptor derived from a Zod schema. */
15
17
  export interface Field {
16
18
  readonly name: string;
17
19
  readonly type:
@@ -48,6 +50,57 @@ export interface FieldOverride {
48
50
  | undefined;
49
51
  }
50
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // CLI command route projection
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Authored CLI command path shape. Strings are split on whitespace. */
58
+ export type CliCommandPathInput = string | readonly string[];
59
+
60
+ /**
61
+ * Authored CLI command alias shape.
62
+ *
63
+ * String aliases are sibling leaf aliases. Array aliases are absolute command
64
+ * paths.
65
+ */
66
+ export type CliCommandAliasInput = string | readonly string[];
67
+
68
+ /** Source that produced a resolved CLI command route. */
69
+ export type CliCommandRouteSource = 'derived' | 'trail' | 'surface';
70
+
71
+ /** Whether a resolved CLI command route is canonical or an alias. */
72
+ export type CliCommandRouteKind = 'alias' | 'canonical';
73
+
74
+ /** Trail-authored CLI projection metadata. */
75
+ export interface TrailCliProjection {
76
+ readonly aliases?: readonly CliCommandAliasInput[] | undefined;
77
+ readonly path?: CliCommandPathInput | undefined;
78
+ }
79
+
80
+ /** A resolved command path accepted by a CLI surface for one trail. */
81
+ export interface CliCommandRoute {
82
+ readonly kind: CliCommandRouteKind;
83
+ readonly path: readonly string[];
84
+ readonly source: CliCommandRouteSource;
85
+ readonly target: string;
86
+ }
87
+
88
+ /** Resolved CLI projection for one trail. */
89
+ export interface TrailCliCommandProjection {
90
+ readonly path: readonly string[];
91
+ readonly routes: readonly CliCommandRoute[];
92
+ }
93
+
94
+ interface TrailCliProjectionInput {
95
+ readonly cli?: CliCommandPathInput | TrailCliProjection | undefined;
96
+ readonly id: string;
97
+ }
98
+
99
+ export interface DeriveTrailCliCommandProjectionOptions {
100
+ readonly aliases?: readonly CliCommandAliasInput[] | undefined;
101
+ readonly aliasSource?: Extract<CliCommandRouteSource, 'surface' | 'trail'>;
102
+ }
103
+
51
104
  // ---------------------------------------------------------------------------
52
105
  // Zod v4 internals accessor
53
106
  // ---------------------------------------------------------------------------
@@ -92,7 +145,7 @@ const propagateDescription = (
92
145
  }
93
146
  };
94
147
 
95
- /** Step one level of optional/default unwrapping. Returns null if not a wrapper type. */
148
+ /** Step one level of transparent wrapper unwrapping. Returns null if not a wrapper type. */
96
149
  const unwrapStep = (
97
150
  current: ZodInternals,
98
151
  state: {
@@ -102,10 +155,16 @@ const unwrapStep = (
102
155
  }
103
156
  ): ZodInternals | null => {
104
157
  const defType = current._zod.def['type'] as string;
105
- if (defType !== 'optional' && defType !== 'default') {
158
+ if (
159
+ defType !== 'optional' &&
160
+ defType !== 'default' &&
161
+ defType !== 'readonly'
162
+ ) {
106
163
  return null;
107
164
  }
108
- state.required = false;
165
+ if (defType !== 'readonly') {
166
+ state.required = false;
167
+ }
109
168
  if (defType === 'default') {
110
169
  state.defaultValue = current._zod.def['defaultValue'];
111
170
  }
@@ -140,14 +199,21 @@ interface DerivedFieldType {
140
199
  type: Field['type'];
141
200
  }
142
201
 
143
- const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
202
+ const fieldTypeByDef: Record<
203
+ string,
204
+ (s: ZodInternals) => DerivedFieldType | null
205
+ > = {
144
206
  array: (s) => {
145
207
  const element = s._zod.def['element'] as unknown as ZodInternals;
146
- const elementType = element._zod.def['type'] as string;
208
+ const { inner } = unwrap(element);
209
+ const elementType = inner._zod.def['type'] as string;
147
210
  if (elementType === 'enum') {
148
- const entries = element._zod.def['entries'] as Record<string, string>;
211
+ const entries = inner._zod.def['entries'] as Record<string, string>;
149
212
  return { options: Object.values(entries), type: 'multiselect' };
150
213
  }
214
+ if (elementType !== 'number' && elementType !== 'string') {
215
+ return null;
216
+ }
151
217
  return {
152
218
  options: undefined,
153
219
  type: elementType === 'number' ? 'number[]' : 'string[]',
@@ -163,10 +229,10 @@ const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
163
229
  };
164
230
 
165
231
  /** Derive field type and raw options from the unwrapped Zod def. */
166
- const deriveFieldType = (s: ZodInternals): DerivedFieldType => {
232
+ const deriveFieldType = (s: ZodInternals): DerivedFieldType | null => {
167
233
  const defType = s._zod.def['type'] as string;
168
234
  const derive = fieldTypeByDef[defType];
169
- return derive ? derive(s) : { options: undefined, type: 'string' };
235
+ return derive ? derive(s) : null;
170
236
  };
171
237
 
172
238
  /** Build options array, merging with overrides when present. */
@@ -189,29 +255,192 @@ const buildOptions = (
189
255
  });
190
256
  };
191
257
 
258
+ /**
259
+ * Derive the canonical ordered CLI path from a trail ID.
260
+ *
261
+ * @throws {ValidationError} if the trail ID contains empty segments (e.g. consecutive dots).
262
+ */
263
+ export const deriveCliPath = (trailId: string): string[] => {
264
+ const segments = trailId.split('.');
265
+ const emptyIndex = segments.findIndex((s) => s.length === 0);
266
+ if (emptyIndex !== -1) {
267
+ throw new ValidationError(
268
+ `Trail ID "${trailId}" contains an empty segment at position ${emptyIndex}`
269
+ );
270
+ }
271
+ return segments;
272
+ };
273
+
274
+ const hasWhitespace = (value: string): boolean => /\s/.test(value);
275
+
276
+ const validateCliSegment = (segment: string, context: string): string => {
277
+ const normalized = segment.trim();
278
+ if (normalized.length === 0) {
279
+ throw new ValidationError(`${context} cannot contain empty segments`);
280
+ }
281
+ if (hasWhitespace(normalized)) {
282
+ throw new ValidationError(
283
+ `${context} segment "${segment}" cannot contain whitespace`
284
+ );
285
+ }
286
+ return normalized;
287
+ };
288
+
289
+ const splitCliPathString = (value: string, context: string): string[] => {
290
+ const segments = value
291
+ .trim()
292
+ .split(/\s+/)
293
+ .filter((segment) => segment.length > 0);
294
+ if (segments.length === 0) {
295
+ throw new ValidationError(`${context} cannot be empty`);
296
+ }
297
+ return segments.map((segment) => validateCliSegment(segment, context));
298
+ };
299
+
300
+ /** Normalize an authored CLI command path. */
301
+ export const normalizeCliCommandPath = (
302
+ value: CliCommandPathInput,
303
+ context = 'CLI command path'
304
+ ): readonly string[] =>
305
+ typeof value === 'string'
306
+ ? splitCliPathString(value, context)
307
+ : value.map((segment) => validateCliSegment(segment, context));
308
+
309
+ const isTrailCliProjection = (
310
+ value: CliCommandPathInput | TrailCliProjection
311
+ ): value is TrailCliProjection =>
312
+ typeof value !== 'string' &&
313
+ !Array.isArray(value) &&
314
+ value !== null &&
315
+ typeof value === 'object';
316
+
317
+ const trailCliProjectionFor = (
318
+ trail: TrailCliProjectionInput
319
+ ): TrailCliProjection | undefined => {
320
+ if (trail.cli === undefined) {
321
+ return undefined;
322
+ }
323
+ return isTrailCliProjection(trail.cli) ? trail.cli : { path: trail.cli };
324
+ };
325
+
326
+ const deriveCanonicalCliRoute = (
327
+ trail: TrailCliProjectionInput
328
+ ): CliCommandRoute => {
329
+ const projection = trailCliProjectionFor(trail);
330
+ const path =
331
+ projection?.path === undefined
332
+ ? deriveCliPath(trail.id)
333
+ : normalizeCliCommandPath(
334
+ projection.path,
335
+ `CLI command path for trail "${trail.id}"`
336
+ );
337
+ return {
338
+ kind: 'canonical',
339
+ path,
340
+ source: projection?.path === undefined ? 'derived' : 'trail',
341
+ target: trail.id,
342
+ };
343
+ };
344
+
345
+ const normalizeCliAlias = ({
346
+ alias,
347
+ canonicalPath,
348
+ source,
349
+ target,
350
+ }: {
351
+ readonly alias: CliCommandAliasInput;
352
+ readonly canonicalPath: readonly string[];
353
+ readonly source: Extract<CliCommandRouteSource, 'surface' | 'trail'>;
354
+ readonly target: string;
355
+ }): CliCommandRoute => {
356
+ const context = `CLI command alias for trail "${target}"`;
357
+ if (typeof alias === 'string') {
358
+ const segment = alias.trim();
359
+ if (segment.length === 0) {
360
+ throw new ValidationError(`${context} cannot be empty`);
361
+ }
362
+ if (hasWhitespace(segment)) {
363
+ throw new ValidationError(
364
+ `${context} must be a single command segment; use a string array for absolute paths`
365
+ );
366
+ }
367
+ return {
368
+ kind: 'alias',
369
+ path: [
370
+ ...canonicalPath.slice(0, -1),
371
+ validateCliSegment(segment, context),
372
+ ],
373
+ source,
374
+ target,
375
+ };
376
+ }
377
+ return {
378
+ kind: 'alias',
379
+ path: normalizeCliCommandPath(alias, context),
380
+ source,
381
+ target,
382
+ };
383
+ };
384
+
385
+ /** Derive resolved CLI command routes for one trail. */
386
+ export const deriveTrailCliCommandProjection = (
387
+ trail: TrailCliProjectionInput,
388
+ options?: DeriveTrailCliCommandProjectionOptions
389
+ ): TrailCliCommandProjection => {
390
+ const canonical = deriveCanonicalCliRoute(trail);
391
+ const projection = trailCliProjectionFor(trail);
392
+ const trailAliases =
393
+ projection?.aliases?.map((alias) =>
394
+ normalizeCliAlias({
395
+ alias,
396
+ canonicalPath: canonical.path,
397
+ source: 'trail',
398
+ target: trail.id,
399
+ })
400
+ ) ?? [];
401
+ const surfaceAliases =
402
+ options?.aliases?.map((alias) =>
403
+ normalizeCliAlias({
404
+ alias,
405
+ canonicalPath: canonical.path,
406
+ source: options.aliasSource ?? 'surface',
407
+ target: trail.id,
408
+ })
409
+ ) ?? [];
410
+
411
+ return {
412
+ path: canonical.path,
413
+ routes: [canonical, ...trailAliases, ...surfaceAliases],
414
+ };
415
+ };
416
+
192
417
  // ---------------------------------------------------------------------------
193
418
  // Public API
194
419
  // ---------------------------------------------------------------------------
195
420
 
196
- /**
197
- * Derive a surface-agnostic Field[] from a Zod object schema.
198
- *
199
- * Uses Zod v4's `_zod.def` for introspection. Returns fields sorted by name.
200
- */
201
421
  /** Derive a single field from a shape entry. */
202
422
  const deriveField = (
203
423
  key: string,
204
424
  value: ZodInternals,
205
425
  overrides?: Record<string, FieldOverride>
206
- ): Field => {
426
+ ): Field | null => {
207
427
  const { inner, required, defaultValue, description } = unwrap(value);
208
- const { type, options: rawOptions } = deriveFieldType(inner);
428
+ const derived = deriveFieldType(inner);
429
+ if (!derived) {
430
+ return null;
431
+ }
432
+ const { type, options: rawOptions } = derived;
209
433
  const override = overrides?.[key];
210
434
  const label = override?.label ?? description ?? humanize(key);
211
435
  const options = buildOptions(rawOptions, override?.options);
212
436
  return { default: defaultValue, label, name: key, options, required, type };
213
437
  };
214
438
 
439
+ /**
440
+ * Derive a runtime-agnostic Field[] from a Zod object schema.
441
+ *
442
+ * Uses Zod v4's `_zod.def` for introspection. Returns fields sorted by name.
443
+ */
215
444
  export const deriveFields = (
216
445
  schema: z.ZodType,
217
446
  overrides?: Record<string, FieldOverride>
@@ -229,5 +458,7 @@ export const deriveFields = (
229
458
  const fields = Object.entries(shape).map(([key, value]) =>
230
459
  deriveField(key, value, overrides)
231
460
  );
232
- return fields.toSorted((a, b) => a.name.localeCompare(b.name));
461
+ return fields
462
+ .filter((field): field is Field => field !== null)
463
+ .toSorted((a, b) => a.name.localeCompare(b.name));
233
464
  };
package/src/detours.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Hard upper bound for detour recovery attempts.
3
+ *
4
+ * Execution and derived surface/topo projections both clamp declared detour
5
+ * attempts to this value so runtime behavior and inspectable contracts stay in
6
+ * lockstep.
7
+ */
8
+ export const DETOUR_MAX_ATTEMPTS_CAP = 5;