@ontrails/core 1.0.0-beta.13 → 1.0.0-beta.15

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 (287) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +19 -14
  4. package/dist/branded.d.ts +1 -1
  5. package/dist/branded.d.ts.map +1 -1
  6. package/dist/branded.js +1 -1
  7. package/dist/branded.js.map +1 -1
  8. package/dist/context.d.ts +13 -1
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +19 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/contour.d.ts +81 -0
  13. package/dist/contour.d.ts.map +1 -0
  14. package/dist/contour.js +176 -0
  15. package/dist/contour.js.map +1 -0
  16. package/dist/cross-schema.d.ts +18 -0
  17. package/dist/cross-schema.d.ts.map +1 -0
  18. package/dist/cross-schema.js +29 -0
  19. package/dist/cross-schema.js.map +1 -0
  20. package/dist/derive.d.ts +6 -0
  21. package/dist/derive.d.ts.map +1 -1
  22. package/dist/derive.js +29 -5
  23. package/dist/derive.js.map +1 -1
  24. package/dist/draft.d.ts +28 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +157 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/errors.d.ts +62 -4
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +44 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/execute.d.ts +26 -9
  33. package/dist/execute.d.ts.map +1 -1
  34. package/dist/execute.js +441 -20
  35. package/dist/execute.js.map +1 -1
  36. package/dist/fire.d.ts +43 -0
  37. package/dist/fire.d.ts.map +1 -0
  38. package/dist/fire.js +185 -0
  39. package/dist/fire.js.map +1 -0
  40. package/dist/index.d.ts +33 -17
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +26 -12
  43. package/dist/index.js.map +1 -1
  44. package/dist/internal/cross-batch.d.ts +44 -0
  45. package/dist/internal/cross-batch.d.ts.map +1 -0
  46. package/dist/internal/cross-batch.js +58 -0
  47. package/dist/internal/cross-batch.js.map +1 -0
  48. package/dist/internal/fork-ctx.d.ts +41 -0
  49. package/dist/internal/fork-ctx.d.ts.map +1 -0
  50. package/dist/internal/fork-ctx.js +47 -0
  51. package/dist/internal/fork-ctx.js.map +1 -0
  52. package/dist/internal/signal-ref.d.ts +15 -0
  53. package/dist/internal/signal-ref.d.ts.map +1 -0
  54. package/dist/internal/signal-ref.js +41 -0
  55. package/dist/internal/signal-ref.js.map +1 -0
  56. package/dist/internal/topo-saves.d.ts +53 -0
  57. package/dist/internal/topo-saves.d.ts.map +1 -0
  58. package/dist/internal/topo-saves.js +449 -0
  59. package/dist/internal/topo-saves.js.map +1 -0
  60. package/dist/internal/topo-snapshots.d.ts +57 -0
  61. package/dist/internal/topo-snapshots.d.ts.map +1 -0
  62. package/dist/internal/topo-snapshots.js +372 -0
  63. package/dist/internal/topo-snapshots.js.map +1 -0
  64. package/dist/internal/topo-store-read.d.ts +70 -0
  65. package/dist/internal/topo-store-read.d.ts.map +1 -0
  66. package/dist/internal/topo-store-read.js +221 -0
  67. package/dist/internal/topo-store-read.js.map +1 -0
  68. package/dist/internal/topo-store.d.ts +26 -0
  69. package/dist/internal/topo-store.d.ts.map +1 -0
  70. package/dist/internal/topo-store.js +621 -0
  71. package/dist/internal/topo-store.js.map +1 -0
  72. package/dist/internal/tracing.d.ts +98 -0
  73. package/dist/internal/tracing.d.ts.map +1 -0
  74. package/dist/internal/tracing.js +102 -0
  75. package/dist/internal/tracing.js.map +1 -0
  76. package/dist/internal/trails-db.d.ts +16 -0
  77. package/dist/internal/trails-db.d.ts.map +1 -0
  78. package/dist/internal/trails-db.js +118 -0
  79. package/dist/internal/trails-db.js.map +1 -0
  80. package/dist/internal/zod-wrappers.d.ts +40 -0
  81. package/dist/internal/zod-wrappers.d.ts.map +1 -0
  82. package/dist/internal/zod-wrappers.js +59 -0
  83. package/dist/internal/zod-wrappers.js.map +1 -0
  84. package/dist/layer.d.ts +22 -5
  85. package/dist/layer.d.ts.map +1 -1
  86. package/dist/layer.js +20 -3
  87. package/dist/layer.js.map +1 -1
  88. package/dist/path-security.d.ts +1 -1
  89. package/dist/path-security.d.ts.map +1 -1
  90. package/dist/path-security.js +1 -1
  91. package/dist/path-security.js.map +1 -1
  92. package/dist/resilience.d.ts +1 -1
  93. package/dist/resilience.d.ts.map +1 -1
  94. package/dist/resilience.js +3 -3
  95. package/dist/resilience.js.map +1 -1
  96. package/dist/resource-config.d.ts +22 -0
  97. package/dist/resource-config.d.ts.map +1 -0
  98. package/dist/resource-config.js +210 -0
  99. package/dist/resource-config.js.map +1 -0
  100. package/dist/resource.d.ts +74 -0
  101. package/dist/resource.d.ts.map +1 -0
  102. package/dist/resource.js +61 -0
  103. package/dist/resource.js.map +1 -0
  104. package/dist/run.d.ts +4 -3
  105. package/dist/run.d.ts.map +1 -1
  106. package/dist/run.js +5 -4
  107. package/dist/run.js.map +1 -1
  108. package/dist/serialization.d.ts.map +1 -1
  109. package/dist/serialization.js +2 -1
  110. package/dist/serialization.js.map +1 -1
  111. package/dist/store/accessor-protocol.d.ts +51 -0
  112. package/dist/store/accessor-protocol.d.ts.map +1 -0
  113. package/dist/store/accessor-protocol.js +2 -0
  114. package/dist/store/accessor-protocol.js.map +1 -0
  115. package/dist/store/index.d.ts +2 -0
  116. package/dist/store/index.d.ts.map +1 -0
  117. package/dist/store/index.js +2 -0
  118. package/dist/store/index.js.map +1 -0
  119. package/dist/surface-filter.d.ts +13 -0
  120. package/dist/surface-filter.d.ts.map +1 -0
  121. package/dist/surface-filter.js +76 -0
  122. package/dist/surface-filter.js.map +1 -0
  123. package/dist/topo-store.d.ts +62 -0
  124. package/dist/topo-store.d.ts.map +1 -0
  125. package/dist/topo-store.js +319 -0
  126. package/dist/topo-store.js.map +1 -0
  127. package/dist/topo.d.ts +23 -9
  128. package/dist/topo.d.ts.map +1 -1
  129. package/dist/topo.js +175 -46
  130. package/dist/topo.js.map +1 -1
  131. package/dist/trail.d.ts +86 -19
  132. package/dist/trail.d.ts.map +1 -1
  133. package/dist/trail.js +59 -4
  134. package/dist/trail.js.map +1 -1
  135. package/dist/trails/derive-trail.d.ts +66 -0
  136. package/dist/trails/derive-trail.d.ts.map +1 -0
  137. package/dist/trails/derive-trail.js +378 -0
  138. package/dist/trails/derive-trail.js.map +1 -0
  139. package/dist/trails/index.d.ts +5 -0
  140. package/dist/trails/index.d.ts.map +1 -0
  141. package/dist/trails/index.js +3 -0
  142. package/dist/trails/index.js.map +1 -0
  143. package/dist/trails/ingest.d.ts +24 -0
  144. package/dist/trails/ingest.d.ts.map +1 -0
  145. package/dist/trails/ingest.js +63 -0
  146. package/dist/trails/ingest.js.map +1 -0
  147. package/dist/transport-error-map.d.ts +106 -0
  148. package/dist/transport-error-map.d.ts.map +1 -0
  149. package/dist/transport-error-map.js +24 -0
  150. package/dist/transport-error-map.js.map +1 -0
  151. package/dist/type-checks.test-d.d.ts +71 -0
  152. package/dist/type-checks.test-d.d.ts.map +1 -0
  153. package/dist/type-checks.test-d.js +12 -0
  154. package/dist/type-checks.test-d.js.map +1 -0
  155. package/dist/type-utils.d.ts +11 -2
  156. package/dist/type-utils.d.ts.map +1 -1
  157. package/dist/type-utils.js.map +1 -1
  158. package/dist/types.d.ts +113 -7
  159. package/dist/types.d.ts.map +1 -1
  160. package/dist/types.js.map +1 -1
  161. package/dist/validate-established-topo.d.ts +76 -0
  162. package/dist/validate-established-topo.d.ts.map +1 -0
  163. package/dist/validate-established-topo.js +43 -0
  164. package/dist/validate-established-topo.js.map +1 -0
  165. package/dist/validate-topo.d.ts.map +1 -1
  166. package/dist/validate-topo.js +27 -8
  167. package/dist/validate-topo.js.map +1 -1
  168. package/dist/workspace.d.ts +1 -1
  169. package/dist/workspace.d.ts.map +1 -1
  170. package/dist/workspace.js +1 -1
  171. package/dist/workspace.js.map +1 -1
  172. package/package.json +10 -1
  173. package/src/__tests__/branded.test.ts +5 -5
  174. package/src/__tests__/context.test.ts +3 -3
  175. package/src/__tests__/contour.test.ts +263 -0
  176. package/src/__tests__/derive-trail.test.ts +668 -0
  177. package/src/__tests__/derive.test.ts +58 -1
  178. package/src/__tests__/errors.test.ts +118 -0
  179. package/src/__tests__/execute.test.ts +1179 -141
  180. package/src/__tests__/fire.test.ts +1056 -0
  181. package/src/__tests__/fork-ctx.test.ts +83 -0
  182. package/src/__tests__/ingest.test.ts +191 -0
  183. package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
  184. package/src/__tests__/path-security.test.ts +7 -7
  185. package/src/__tests__/resilience.test.ts +8 -8
  186. package/src/__tests__/resource.test.ts +204 -0
  187. package/src/__tests__/run.test.ts +13 -13
  188. package/src/__tests__/service-config.test.ts +28 -32
  189. package/src/__tests__/surface-filter.test.ts +196 -0
  190. package/src/__tests__/topo-store-read.test.ts +256 -0
  191. package/src/__tests__/topo-store.test.ts +869 -0
  192. package/src/__tests__/topo.test.ts +350 -54
  193. package/src/__tests__/trail.test.ts +189 -24
  194. package/src/__tests__/trails-db.test.ts +198 -0
  195. package/src/__tests__/transport-error-map.test.ts +82 -0
  196. package/src/__tests__/type-utils.test.ts +29 -1
  197. package/src/__tests__/validate-topo.test.ts +305 -16
  198. package/src/__tests__/workspace.test.ts +6 -6
  199. package/src/__tests__/zod-wrappers.test.ts +62 -0
  200. package/src/branded.ts +1 -1
  201. package/src/context.ts +24 -4
  202. package/src/contour.ts +344 -0
  203. package/src/cross-schema.ts +36 -0
  204. package/src/derive.ts +39 -8
  205. package/src/draft.ts +338 -0
  206. package/src/errors.ts +62 -18
  207. package/src/execute.ts +840 -29
  208. package/src/fire.ts +295 -0
  209. package/src/index.ts +142 -26
  210. package/src/internal/cross-batch.ts +77 -0
  211. package/src/internal/fork-ctx.ts +69 -0
  212. package/src/internal/signal-ref.ts +87 -0
  213. package/src/internal/topo-snapshots.ts +517 -0
  214. package/src/internal/topo-store-read.ts +481 -0
  215. package/src/internal/topo-store.ts +1192 -0
  216. package/src/internal/tracing.ts +187 -0
  217. package/src/internal/trails-db.ts +187 -0
  218. package/src/internal/zod-wrappers.ts +78 -0
  219. package/src/layer.ts +50 -0
  220. package/src/path-security.ts +1 -1
  221. package/src/resilience.ts +3 -3
  222. package/src/{provision-config.ts → resource-config.ts} +109 -123
  223. package/src/resource.ts +155 -0
  224. package/src/run.ts +5 -4
  225. package/src/serialization.ts +2 -0
  226. package/src/store/accessor-protocol.ts +56 -0
  227. package/src/store/index.ts +4 -0
  228. package/src/surface-filter.ts +182 -0
  229. package/src/topo-store.ts +510 -0
  230. package/src/topo.ts +328 -57
  231. package/src/trail.ts +199 -31
  232. package/src/trails/derive-trail.ts +842 -0
  233. package/src/trails/index.ts +9 -0
  234. package/src/trails/ingest.ts +146 -0
  235. package/src/transport-error-map.ts +56 -0
  236. package/src/type-checks.test-d.ts +106 -0
  237. package/src/type-utils.ts +17 -2
  238. package/src/types.ts +148 -11
  239. package/src/validate-established-topo.ts +63 -0
  240. package/src/validate-topo.ts +37 -9
  241. package/src/workspace.ts +1 -1
  242. package/tsconfig.tests.json +10 -0
  243. package/tsconfig.tsbuildinfo +1 -1
  244. package/dist/adapters.d.ts +0 -39
  245. package/dist/adapters.d.ts.map +0 -1
  246. package/dist/adapters.js +0 -2
  247. package/dist/adapters.js.map +0 -1
  248. package/dist/dispatch.d.ts +0 -27
  249. package/dist/dispatch.d.ts.map +0 -1
  250. package/dist/dispatch.js +0 -34
  251. package/dist/dispatch.js.map +0 -1
  252. package/dist/event.d.ts +0 -8
  253. package/dist/event.d.ts.map +0 -1
  254. package/dist/event.js +0 -7
  255. package/dist/event.js.map +0 -1
  256. package/dist/gate.d.ts +0 -17
  257. package/dist/gate.d.ts.map +0 -1
  258. package/dist/gate.js +0 -21
  259. package/dist/gate.js.map +0 -1
  260. package/dist/health.d.ts +0 -18
  261. package/dist/health.d.ts.map +0 -1
  262. package/dist/health.js +0 -5
  263. package/dist/health.js.map +0 -1
  264. package/dist/job.d.ts +0 -24
  265. package/dist/job.d.ts.map +0 -1
  266. package/dist/job.js +0 -17
  267. package/dist/job.js.map +0 -1
  268. package/dist/provision-config.d.ts +0 -22
  269. package/dist/provision-config.d.ts.map +0 -1
  270. package/dist/provision-config.js +0 -210
  271. package/dist/provision-config.js.map +0 -1
  272. package/dist/provision.d.ts +0 -71
  273. package/dist/provision.d.ts.map +0 -1
  274. package/dist/provision.js +0 -56
  275. package/dist/provision.js.map +0 -1
  276. package/dist/service-config.d.ts +0 -22
  277. package/dist/service-config.d.ts.map +0 -1
  278. package/dist/service-config.js +0 -210
  279. package/dist/service-config.js.map +0 -1
  280. package/dist/service.d.ts +0 -71
  281. package/dist/service.d.ts.map +0 -1
  282. package/dist/service.js +0 -56
  283. package/dist/service.js.map +0 -1
  284. package/src/__tests__/service.test.ts +0 -197
  285. package/src/event.ts +0 -15
  286. package/src/gate.ts +0 -44
  287. package/src/provision.ts +0 -148
package/src/contour.ts ADDED
@@ -0,0 +1,344 @@
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
+ }
17
+
18
+ /** Type-level brand name applied to a contour's identity schema. */
19
+ export type ContourIdBrand<TName extends string> = `${Capitalize<TName>}Id`;
20
+
21
+ type BrandedSchema<
22
+ TSchema extends z.core.$ZodType,
23
+ TBrand extends string,
24
+ > = TSchema & z.ZodType<Branded<z.output<TSchema>, TBrand>>;
25
+
26
+ type BrandableSchema<TSchema extends z.core.$ZodType> = TSchema & {
27
+ brand<TBrand extends string>(): BrandedSchema<TSchema, TBrand>;
28
+ };
29
+
30
+ /** Output value of a branded contour identity schema. */
31
+ export type ContourIdValue<
32
+ TSchema extends z.core.$ZodType,
33
+ TName extends string,
34
+ > = Branded<z.output<TSchema>, ContourIdBrand<TName>>;
35
+
36
+ /** Runtime metadata attached to schemas returned from `contour.id()`. */
37
+ export interface ContourIdMetadata<
38
+ TName extends string = string,
39
+ TIdentity extends string = string,
40
+ > {
41
+ readonly contour: TName;
42
+ readonly identity: TIdentity;
43
+ }
44
+
45
+ /** A structural contour reference declared by another contour field schema. */
46
+ export interface ContourReference<
47
+ TName extends string = string,
48
+ TIdentity extends string = string,
49
+ > extends ContourIdMetadata<TName, TIdentity> {
50
+ readonly field: string;
51
+ }
52
+
53
+ /** Symbol used to tag branded contour reference schemas at runtime. */
54
+ export const CONTOUR_ID_METADATA = Symbol.for('@ontrails/core/contour-id');
55
+
56
+ /**
57
+ * Module-level WeakMap storing contour identity metadata keyed by schema object.
58
+ *
59
+ * First-write-wins: when multiple contours share the same underlying schema
60
+ * (e.g. `contour('admin', { id: user.shape.id }, ...)`), the first contour to
61
+ * brand the schema claims it. Subsequent calls skip the write to prevent
62
+ * silent metadata corruption.
63
+ */
64
+ const contourIdMetadata = new WeakMap<object, ContourIdMetadata>();
65
+
66
+ /**
67
+ * A contour identity schema branded for one contour and tagged with runtime
68
+ * metadata so the topo layer can recognize declared references later on.
69
+ */
70
+ export type ContourIdSchema<
71
+ TSchema extends z.core.$ZodType = z.core.$ZodType,
72
+ TName extends string = string,
73
+ TIdentity extends string = string,
74
+ > = BrandedSchema<TSchema, ContourIdBrand<TName>> & {
75
+ /** @deprecated Use `getContourIdMetadata()` — metadata lives in a WeakMap, not on the schema. */
76
+ readonly [CONTOUR_ID_METADATA]?: ContourIdMetadata<TName, TIdentity>;
77
+ };
78
+
79
+ /**
80
+ * A first-class domain object with schema, identity metadata, and examples.
81
+ *
82
+ * A contour behaves like the `ZodObject` it wraps, so standard Zod composition
83
+ * helpers such as `.pick()`, `.extend()`, and `.array()` continue to work.
84
+ */
85
+ export type Contour<
86
+ TName extends string = string,
87
+ TShape extends z.ZodRawShape = z.ZodRawShape,
88
+ TIdentity extends keyof TShape & string = keyof TShape & string,
89
+ > = z.ZodObject<TShape> & {
90
+ readonly kind: 'contour';
91
+ readonly name: TName;
92
+ readonly identity: TIdentity;
93
+ readonly identitySchema: TShape[TIdentity];
94
+ readonly id: () => ContourIdSchema<TShape[TIdentity], TName, TIdentity>;
95
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
96
+ };
97
+
98
+ const formatExampleIssues = (issues: readonly z.core.$ZodIssue[]): string =>
99
+ issues
100
+ .map((issue) => {
101
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
102
+ return `${path}: ${issue.message}`;
103
+ })
104
+ .join('; ');
105
+
106
+ const assertIdentityField = <
107
+ TShape extends z.ZodRawShape,
108
+ TIdentity extends keyof TShape & string,
109
+ >(
110
+ name: string,
111
+ shape: TShape,
112
+ identity: TIdentity
113
+ ): void => {
114
+ if (!Object.hasOwn(shape, identity)) {
115
+ throw new TypeError(
116
+ `contour("${name}") identity "${identity}" must match a declared field`
117
+ );
118
+ }
119
+ };
120
+
121
+ const assertExamples = <TShape extends z.ZodRawShape>(
122
+ name: string,
123
+ schema: z.ZodObject<TShape>,
124
+ examples: readonly z.output<z.ZodObject<TShape>>[]
125
+ ): void => {
126
+ for (const [index, example] of examples.entries()) {
127
+ const parsed = schema.safeParse(example);
128
+ if (!parsed.success) {
129
+ throw new TypeError(
130
+ `contour("${name}") example ${index} is invalid: ${formatExampleIssues(parsed.error.issues)}`
131
+ );
132
+ }
133
+ }
134
+ };
135
+
136
+ const validateExamples = <TShape extends z.ZodRawShape>(
137
+ name: string,
138
+ schema: z.ZodObject<TShape>,
139
+ examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined
140
+ ): void => {
141
+ if (examples) {
142
+ assertExamples(name, schema, examples);
143
+ }
144
+ };
145
+
146
+ const brandIdentitySchema = <
147
+ TSchema extends z.core.$ZodType,
148
+ TName extends string,
149
+ TIdentity extends string,
150
+ >(
151
+ contour: TName,
152
+ identity: TIdentity,
153
+ schema: TSchema
154
+ ): ContourIdSchema<TSchema, TName, TIdentity> => {
155
+ const branded = (schema as BrandableSchema<TSchema>).brand<
156
+ ContourIdBrand<TName>
157
+ >();
158
+
159
+ // First-write-wins: if another contour already claimed this schema object
160
+ // (possible when Zod v4 brand() returns `this`), preserve the original
161
+ // metadata rather than silently overwriting it.
162
+ if (!contourIdMetadata.has(branded)) {
163
+ contourIdMetadata.set(branded, {
164
+ contour,
165
+ identity,
166
+ } satisfies ContourIdMetadata<TName, TIdentity>);
167
+ }
168
+
169
+ return branded as ContourIdSchema<TSchema, TName, TIdentity>;
170
+ };
171
+
172
+ const attachContourMetadata = <
173
+ TName extends string,
174
+ TShape extends z.ZodRawShape,
175
+ TIdentity extends keyof TShape & string,
176
+ >(
177
+ schema: z.ZodObject<TShape>,
178
+ metadata: {
179
+ readonly examples?: readonly z.output<z.ZodObject<TShape>>[] | undefined;
180
+ readonly idSchema: ContourIdSchema<TShape[TIdentity], TName, TIdentity>;
181
+ readonly identity: TIdentity;
182
+ readonly identitySchema: TShape[TIdentity];
183
+ readonly name: TName;
184
+ }
185
+ ): void => {
186
+ Object.defineProperties(schema, {
187
+ examples: {
188
+ enumerable: true,
189
+ value: metadata.examples,
190
+ writable: false,
191
+ },
192
+ id: {
193
+ enumerable: true,
194
+ value: () => metadata.idSchema,
195
+ writable: false,
196
+ },
197
+ identity: {
198
+ enumerable: true,
199
+ value: metadata.identity,
200
+ writable: false,
201
+ },
202
+ identitySchema: {
203
+ enumerable: true,
204
+ value: metadata.identitySchema,
205
+ writable: false,
206
+ },
207
+ kind: {
208
+ enumerable: true,
209
+ value: 'contour',
210
+ writable: false,
211
+ },
212
+ name: {
213
+ enumerable: true,
214
+ value: metadata.name,
215
+ writable: false,
216
+ },
217
+ });
218
+ };
219
+
220
+ /** Read contour identity metadata from the module-level WeakMap, if present. */
221
+ const readMetadata = (schema: unknown): ContourIdMetadata | undefined =>
222
+ typeof schema === 'object' && schema !== null
223
+ ? contourIdMetadata.get(schema)
224
+ : undefined;
225
+
226
+ /** Resolve the inner schema from a Zod wrapper (ZodOptional, ZodNullable, etc.). */
227
+ const unwrapInner = (schema: unknown): unknown => {
228
+ const def = (schema as { _def?: Record<string, unknown> })._def;
229
+ return (def?.['innerType'] ?? def?.['schema']) as unknown;
230
+ };
231
+
232
+ /**
233
+ * Walk through Zod wrapper layers searching for `CONTOUR_ID_METADATA`.
234
+ *
235
+ * `.nullish()` produces `ZodOptional<ZodNullable<T>>` — two wrapper levels —
236
+ * so a single-step unwrap is insufficient. This iterates until it finds the
237
+ * metadata or exhausts all wrapper layers.
238
+ */
239
+ const unwrapToMetadata = (schema: unknown): ContourIdMetadata | undefined => {
240
+ let current: unknown = schema;
241
+ while (typeof current === 'object' && current !== null) {
242
+ const inner = unwrapInner(current);
243
+ if (typeof inner !== 'object' || inner === null) {
244
+ return undefined;
245
+ }
246
+ const metadata = readMetadata(inner);
247
+ if (metadata !== undefined) {
248
+ return metadata;
249
+ }
250
+ current = inner;
251
+ }
252
+ return undefined;
253
+ };
254
+
255
+ /**
256
+ * Read contour-reference metadata from a schema returned by `contour.id()`.
257
+ *
258
+ * When the schema is wrapped by Zod combinators (`.optional()`, `.nullable()`,
259
+ * `.default()`, `.nullish()`, etc.) the `CONTOUR_ID_METADATA` symbol lives on
260
+ * the inner schema, not on the wrapper. The unwrap handles arbitrarily nested
261
+ * wrapper levels.
262
+ */
263
+ export const getContourIdMetadata = (
264
+ schema: unknown
265
+ ): ContourIdMetadata | undefined =>
266
+ readMetadata(schema) ?? unwrapToMetadata(schema);
267
+
268
+ /** Inspect a contour schema for fields that reference other contours via `.id()`. */
269
+ export const getContourReferences = (
270
+ contour: AnyContour
271
+ ): readonly ContourReference[] =>
272
+ Object.entries(contour.shape)
273
+ .flatMap(([field, schema]) => {
274
+ if (field === contour.identity) {
275
+ return [];
276
+ }
277
+ const metadata = getContourIdMetadata(schema);
278
+ if (metadata === undefined) {
279
+ return [];
280
+ }
281
+
282
+ return [{ field, ...metadata }];
283
+ })
284
+ .toSorted((left, right) =>
285
+ left.field === right.field
286
+ ? left.contour.localeCompare(right.contour)
287
+ : left.field.localeCompare(right.field)
288
+ );
289
+
290
+ /**
291
+ * Create a contour definition from a raw Zod object shape.
292
+ *
293
+ * @example
294
+ * ```typescript
295
+ * const user = contour(
296
+ * 'user',
297
+ * {
298
+ * id: z.string().uuid(),
299
+ * email: z.string().email(),
300
+ * name: z.string(),
301
+ * },
302
+ * { identity: 'id' }
303
+ * );
304
+ * ```
305
+ */
306
+ export const contour = <
307
+ TName extends string,
308
+ TShape extends z.ZodRawShape,
309
+ TIdentity extends keyof TShape & string,
310
+ >(
311
+ name: TName,
312
+ shape: TShape,
313
+ options: ContourOptions<TShape, TIdentity>
314
+ ): Contour<TName, TShape, TIdentity> => {
315
+ assertIdentityField(name, shape, options.identity);
316
+
317
+ const schema = z.object(shape);
318
+ validateExamples(name, schema, options.examples);
319
+
320
+ const identitySchema = shape[options.identity];
321
+ if (!identitySchema) {
322
+ throw new TypeError(
323
+ `contour("${name}") identity "${options.identity}" must resolve to a schema`
324
+ );
325
+ }
326
+
327
+ const idSchema = brandIdentitySchema(name, options.identity, identitySchema);
328
+ const examples = options.examples
329
+ ? Object.freeze([...options.examples])
330
+ : undefined;
331
+
332
+ attachContourMetadata(schema, {
333
+ examples,
334
+ idSchema,
335
+ identity: options.identity,
336
+ identitySchema,
337
+ name,
338
+ });
339
+
340
+ return schema as Contour<TName, TShape, TIdentity>;
341
+ };
342
+
343
+ /** Existential type for heterogeneous contour collections. */
344
+ export type AnyContour = Contour<string, z.ZodRawShape, string>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Cross-invocation schema merging for trails with `crossInput`.
3
+ *
4
+ * When a trail declares `crossInput`, callers via `ctx.cross()` pass both
5
+ * public input and composition-only fields. The merged schema validates the
6
+ * combined shape so `executeTrail` doesn't reject the extra fields.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+
11
+ import type { AnyTrail } from './trail.js';
12
+
13
+ /**
14
+ * Build the validation schema for a cross-invoked trail.
15
+ *
16
+ * When the target trail declares `crossInput`, returns the intersection of
17
+ * `trail.input` and `trail.crossInput`. Returns `undefined` when no
18
+ * `crossInput` is declared, signaling that normal input validation suffices.
19
+ */
20
+ export const buildCrossValidationSchema = (
21
+ trailDef: AnyTrail
22
+ ): z.ZodType | undefined => {
23
+ if (!trailDef.crossInput) {
24
+ return undefined;
25
+ }
26
+ // Prefer .merge() for ZodObject pairs — produces a proper merged object
27
+ // schema that strips unknown keys and exposes .shape. Fall back to
28
+ // z.intersection for non-object schemas.
29
+ if (
30
+ trailDef.input instanceof z.ZodObject &&
31
+ trailDef.crossInput instanceof z.ZodObject
32
+ ) {
33
+ return trailDef.input.merge(trailDef.crossInput);
34
+ }
35
+ return z.intersection(trailDef.input, trailDef.crossInput);
36
+ };
package/src/derive.ts CHANGED
@@ -7,6 +7,8 @@
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
  // ---------------------------------------------------------------------------
@@ -140,14 +142,21 @@ interface DerivedFieldType {
140
142
  type: Field['type'];
141
143
  }
142
144
 
143
- const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
145
+ const fieldTypeByDef: Record<
146
+ string,
147
+ (s: ZodInternals) => DerivedFieldType | null
148
+ > = {
144
149
  array: (s) => {
145
150
  const element = s._zod.def['element'] as unknown as ZodInternals;
146
- const elementType = element._zod.def['type'] as string;
151
+ const { inner } = unwrap(element);
152
+ const elementType = inner._zod.def['type'] as string;
147
153
  if (elementType === 'enum') {
148
- const entries = element._zod.def['entries'] as Record<string, string>;
154
+ const entries = inner._zod.def['entries'] as Record<string, string>;
149
155
  return { options: Object.values(entries), type: 'multiselect' };
150
156
  }
157
+ if (elementType !== 'number' && elementType !== 'string') {
158
+ return null;
159
+ }
151
160
  return {
152
161
  options: undefined,
153
162
  type: elementType === 'number' ? 'number[]' : 'string[]',
@@ -163,10 +172,10 @@ const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
163
172
  };
164
173
 
165
174
  /** Derive field type and raw options from the unwrapped Zod def. */
166
- const deriveFieldType = (s: ZodInternals): DerivedFieldType => {
175
+ const deriveFieldType = (s: ZodInternals): DerivedFieldType | null => {
167
176
  const defType = s._zod.def['type'] as string;
168
177
  const derive = fieldTypeByDef[defType];
169
- return derive ? derive(s) : { options: undefined, type: 'string' };
178
+ return derive ? derive(s) : null;
170
179
  };
171
180
 
172
181
  /** Build options array, merging with overrides when present. */
@@ -189,6 +198,22 @@ const buildOptions = (
189
198
  });
190
199
  };
191
200
 
201
+ /**
202
+ * Derive the canonical ordered CLI path from a trail ID.
203
+ *
204
+ * @throws {ValidationError} if the trail ID contains empty segments (e.g. consecutive dots).
205
+ */
206
+ export const deriveCliPath = (trailId: string): string[] => {
207
+ const segments = trailId.split('.');
208
+ const emptyIndex = segments.findIndex((s) => s.length === 0);
209
+ if (emptyIndex !== -1) {
210
+ throw new ValidationError(
211
+ `Trail ID "${trailId}" contains an empty segment at position ${emptyIndex}`
212
+ );
213
+ }
214
+ return segments;
215
+ };
216
+
192
217
  // ---------------------------------------------------------------------------
193
218
  // Public API
194
219
  // ---------------------------------------------------------------------------
@@ -198,9 +223,13 @@ const deriveField = (
198
223
  key: string,
199
224
  value: ZodInternals,
200
225
  overrides?: Record<string, FieldOverride>
201
- ): Field => {
226
+ ): Field | null => {
202
227
  const { inner, required, defaultValue, description } = unwrap(value);
203
- const { type, options: rawOptions } = deriveFieldType(inner);
228
+ const derived = deriveFieldType(inner);
229
+ if (!derived) {
230
+ return null;
231
+ }
232
+ const { type, options: rawOptions } = derived;
204
233
  const override = overrides?.[key];
205
234
  const label = override?.label ?? description ?? humanize(key);
206
235
  const options = buildOptions(rawOptions, override?.options);
@@ -229,5 +258,7 @@ export const deriveFields = (
229
258
  const fields = Object.entries(shape).map(([key, value]) =>
230
259
  deriveField(key, value, overrides)
231
260
  );
232
- return fields.toSorted((a, b) => a.name.localeCompare(b.name));
261
+ return fields
262
+ .filter((field): field is Field => field !== null)
263
+ .toSorted((a, b) => a.name.localeCompare(b.name));
233
264
  };