@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
@@ -3,12 +3,23 @@ import { describe, test, expect } from 'bun:test';
3
3
 
4
4
  import { z } from 'zod';
5
5
 
6
- import { InternalError, ValidationError } from '../errors';
6
+ import {
7
+ CancelledError,
8
+ ConflictError,
9
+ InternalError,
10
+ NetworkError,
11
+ NotFoundError,
12
+ RetryExhaustedError,
13
+ TrailsError,
14
+ ValidationError,
15
+ } from '../errors';
7
16
  import { executeTrail } from '../execute';
17
+ import { TRACE_CONTEXT_KEY, clearTraceSink } from '../internal/tracing';
8
18
  import { createTrailContext } from '../context';
9
- import type { Gate } from '../gate';
19
+ import type { Layer } from '../layer';
10
20
  import { Result } from '../result';
11
- import { provision } from '../provision';
21
+ import { resource } from '../resource';
22
+ import { topo } from '../topo';
12
23
  import { trail } from '../trail';
13
24
  import type { TrailContext, TrailContextInit } from '../types';
14
25
 
@@ -34,67 +45,67 @@ const throwingTrail = trail('throws', {
34
45
  input: z.object({}),
35
46
  });
36
47
 
37
- const nextProvisionId = (name: string): string =>
38
- `test.provision.${name}.${Bun.randomUUIDv7()}`;
48
+ const nextResourceId = (name: string): string =>
49
+ `test.resource.${name}.${Bun.randomUUIDv7()}`;
39
50
 
40
- const createResolvedValueProvision = (
51
+ const createResolvedValueResource = (
41
52
  id: string,
42
53
  onCreate: () => void,
43
54
  value: number
44
55
  ) =>
45
- provision(id, {
56
+ resource(id, {
46
57
  create: () => {
47
58
  onCreate();
48
59
  return Result.ok({ value });
49
60
  },
50
61
  });
51
62
 
52
- const createEagerProvisionTrail = (
63
+ const createEagerResourceTrail = (
53
64
  id: string,
54
- counter: ReturnType<typeof createResolvedValueProvision>,
65
+ counter: ReturnType<typeof createResolvedValueResource>,
55
66
  onRun: (value: number) => void
56
67
  ) =>
57
- trail('provision.eager', {
68
+ trail('resource.eager', {
58
69
  blaze: (_input, ctx) => {
59
- const fromAccessor = ctx.provision<{ value: number }>(id);
70
+ const fromAccessor = ctx.resource<{ value: number }>(id);
60
71
  const fromDefinition = counter.from(ctx);
61
72
  onRun(fromDefinition.value);
62
73
  return Result.ok({ total: fromAccessor.value + 1 });
63
74
  },
64
75
  input: z.object({}),
65
76
  output: z.object({ total: z.number() }),
66
- provisions: [counter],
77
+ resources: [counter],
67
78
  });
68
79
 
69
- const createProvisionProbeGate = (
70
- counter: ReturnType<typeof createResolvedValueProvision>,
80
+ const createResourceProbeGate = (
81
+ counter: ReturnType<typeof createResolvedValueResource>,
71
82
  onResolve: (value: number) => void
72
- ): Gate => ({
73
- name: 'uses-provision',
83
+ ): Layer => ({
84
+ name: 'uses-resource',
74
85
  wrap(_trail, impl) {
75
86
  return async (input, ctx) => {
76
- onResolve(ctx.provision<{ value: number }>(counter).value);
87
+ onResolve(ctx.resource<{ value: number }>(counter).value);
77
88
  return await impl(input, ctx);
78
89
  };
79
90
  },
80
91
  });
81
92
 
82
- const createSingletonProvision = (id: string, onCreate: () => number) =>
83
- provision(id, {
93
+ const createSingletonResource = (id: string, onCreate: () => number) =>
94
+ resource(id, {
84
95
  create: () => Result.ok({ createdAtCall: onCreate() }),
85
96
  });
86
97
 
87
98
  const createSingletonTrail = (
88
- singleton: ReturnType<typeof createSingletonProvision>
99
+ singleton: ReturnType<typeof createSingletonResource>
89
100
  ) =>
90
- trail('provision.singleton', {
101
+ trail('resource.singleton', {
91
102
  blaze: (_input, ctx) =>
92
103
  Result.ok({
93
104
  createdAtCall: singleton.from(ctx).createdAtCall,
94
105
  }),
95
106
  input: z.object({}),
96
107
  output: z.object({ createdAtCall: z.number() }),
97
- provisions: [singleton],
108
+ resources: [singleton],
98
109
  });
99
110
 
100
111
  const unwrapExecution = async (
@@ -104,123 +115,865 @@ const unwrapExecution = async (
104
115
  return result.unwrap();
105
116
  };
106
117
 
118
+ const requireCross = (
119
+ ctx: TrailContext
120
+ ): NonNullable<TrailContext['cross']> => {
121
+ expect(ctx.cross).toBeDefined();
122
+ return ctx.cross as NonNullable<TrailContext['cross']>;
123
+ };
124
+
125
+ const waitForAbort = async (signal: AbortSignal): Promise<void> => {
126
+ if (signal.aborted) {
127
+ return;
128
+ }
129
+
130
+ const { promise, resolve } = Promise.withResolvers<undefined>();
131
+ signal.addEventListener('abort', () => resolve(), { once: true });
132
+ await promise;
133
+ };
134
+
135
+ const createConcurrentBranchResourceScopeScenario = () => {
136
+ const id = nextResourceId('branch-scope');
137
+ const captures = { createCalls: 0 };
138
+ const scopedResource = resource(id, {
139
+ create: () => {
140
+ captures.createCalls += 1;
141
+ return Result.ok({ source: `branch-scope-${captures.createCalls}` });
142
+ },
143
+ });
144
+ const createReader = (trailId: string) =>
145
+ trail(trailId, {
146
+ blaze: (_input, ctx) =>
147
+ Result.ok({ source: scopedResource.from(ctx).source }),
148
+ input: z.object({}),
149
+ output: z.object({ source: z.string() }),
150
+ resources: [scopedResource],
151
+ visibility: 'internal',
152
+ });
153
+ const left = createReader('entity.branch.left');
154
+ const right = createReader('entity.branch.right');
155
+ const entry = trail('entity.branch.resource-scope', {
156
+ blaze: async (_input, ctx) => {
157
+ const crossed = await requireCross(ctx)([
158
+ [left, {}],
159
+ [right, {}],
160
+ ] as const);
161
+ return Result.ok({
162
+ sources: crossed.map((result) =>
163
+ result.match({
164
+ err: () => 'err',
165
+ ok: (value) => value.source,
166
+ })
167
+ ),
168
+ });
169
+ },
170
+ crosses: [left, right],
171
+ input: z.object({}),
172
+ output: z.object({ sources: z.array(z.string()) }),
173
+ });
174
+
175
+ return {
176
+ app: topo('cross-branch-resource-scope-topo', {
177
+ entry,
178
+ left,
179
+ right,
180
+ scopedResource,
181
+ }),
182
+ captures,
183
+ entry,
184
+ id,
185
+ };
186
+ };
187
+
188
+ const createAbortSignalScenario = (seenSignals: AbortSignal[]) => {
189
+ const createCancellable = (id: string) =>
190
+ trail(id, {
191
+ blaze: async (_input, ctx) => {
192
+ seenSignals.push(ctx.abortSignal);
193
+ await waitForAbort(ctx.abortSignal);
194
+ return Result.err(
195
+ new CancelledError(`${id} cancelled by shared abort signal`)
196
+ );
197
+ },
198
+ input: z.object({}),
199
+ output: z.object({ ok: z.boolean() }),
200
+ visibility: 'internal',
201
+ });
202
+ const left = createCancellable('entity.cancel.left');
203
+ const right = createCancellable('entity.cancel.right');
204
+ const entry = trail('entity.branch.abort-signal', {
205
+ blaze: async (_input, ctx) => {
206
+ const crossed = await requireCross(ctx)([
207
+ [left, {}],
208
+ [right, {}],
209
+ ] as const);
210
+ return Result.ok({
211
+ statuses: crossed.map((result) =>
212
+ result.match({
213
+ err: (error) =>
214
+ error instanceof CancelledError ? error.category : 'err',
215
+ ok: () => 'ok',
216
+ })
217
+ ),
218
+ });
219
+ },
220
+ crosses: [left, right],
221
+ input: z.object({}),
222
+ output: z.object({ statuses: z.array(z.string()) }),
223
+ });
224
+
225
+ return {
226
+ app: topo('cross-branch-abort-signal-topo', {
227
+ entry,
228
+ left,
229
+ right,
230
+ }),
231
+ entry,
232
+ };
233
+ };
234
+
235
+ interface PermitCapture {
236
+ readonly permitId: string;
237
+ readonly sameReference: boolean;
238
+ }
239
+
240
+ const createPermitScenario = (
241
+ permit: { readonly id: string; readonly scopes: readonly string[] },
242
+ captures: PermitCapture[]
243
+ ) => {
244
+ const createChild = (id: string) =>
245
+ trail(id, {
246
+ blaze: (_input, ctx) => {
247
+ const branchPermit = ctx.permit as typeof permit;
248
+ const permitCapture = {
249
+ permitId: branchPermit.id,
250
+ sameReference: branchPermit === permit,
251
+ };
252
+ captures.push(permitCapture);
253
+ return Result.ok(permitCapture);
254
+ },
255
+ input: z.object({}),
256
+ output: z.object({
257
+ permitId: z.string(),
258
+ sameReference: z.boolean(),
259
+ }),
260
+ visibility: 'internal',
261
+ });
262
+ const left = createChild('entity.permit.left');
263
+ const right = createChild('entity.permit.right');
264
+ const entry = trail('entity.branch.permit', {
265
+ blaze: async (_input, ctx) => {
266
+ const crossed = await requireCross(ctx)([
267
+ [left, {}],
268
+ [right, {}],
269
+ ] as const);
270
+ return Result.ok({
271
+ permits: crossed.map((result) =>
272
+ result.match({
273
+ err: () => ({ permitId: 'err', sameReference: false }),
274
+ ok: (value) => value,
275
+ })
276
+ ),
277
+ });
278
+ },
279
+ crosses: [left, right],
280
+ input: z.object({}),
281
+ output: z.object({
282
+ permits: z.array(
283
+ z.object({
284
+ permitId: z.string(),
285
+ sameReference: z.boolean(),
286
+ })
287
+ ),
288
+ }),
289
+ });
290
+
291
+ return {
292
+ app: topo('cross-branch-permit-topo', {
293
+ entry,
294
+ left,
295
+ right,
296
+ }),
297
+ entry,
298
+ };
299
+ };
300
+
301
+ const createSiblingFailureScopeScenario = () => {
302
+ const failingId = nextResourceId('branch-fail');
303
+ const succeedingId = nextResourceId('branch-success');
304
+ const failingResource = resource(failingId, {
305
+ create: () =>
306
+ Result.err(new ValidationError('failing branch scope exploded')),
307
+ });
308
+ const succeedingResource = resource(succeedingId, {
309
+ create: () => Result.ok({ source: 'sibling-branch-scope' }),
310
+ });
311
+ const failing = trail('entity.scope.fail', {
312
+ blaze: () => Result.ok({ ok: true }),
313
+ input: z.object({}),
314
+ output: z.object({ ok: z.boolean() }),
315
+ resources: [failingResource],
316
+ visibility: 'internal',
317
+ });
318
+ const succeeding = trail('entity.scope.ok', {
319
+ blaze: (_input, ctx) =>
320
+ Result.ok({ source: succeedingResource.from(ctx).source }),
321
+ input: z.object({}),
322
+ output: z.object({ source: z.string() }),
323
+ resources: [succeedingResource],
324
+ visibility: 'internal',
325
+ });
326
+ const entry = trail('entity.branch.failure-scope', {
327
+ blaze: async (_input, ctx) => {
328
+ const [failed, succeeded] = await requireCross(ctx)([
329
+ [failing, {}],
330
+ [succeeding, {}],
331
+ ] as const);
332
+ return Result.ok({
333
+ failed: failed.match({
334
+ err: (error) => error.message,
335
+ ok: () => 'ok',
336
+ }),
337
+ succeeded: succeeded.match({
338
+ err: () => 'err',
339
+ ok: (value) => value.source,
340
+ }),
341
+ });
342
+ },
343
+ crosses: [failing, succeeding],
344
+ input: z.object({}),
345
+ output: z.object({
346
+ failed: z.string(),
347
+ succeeded: z.string(),
348
+ }),
349
+ });
350
+
351
+ return {
352
+ app: topo('cross-branch-failure-scope-topo', {
353
+ entry,
354
+ failing,
355
+ failingResource,
356
+ succeeding,
357
+ succeedingResource,
358
+ }),
359
+ entry,
360
+ failingId,
361
+ succeedingId,
362
+ };
363
+ };
364
+
365
+ const createConcurrencyWorkerScenario = () => {
366
+ const captures = {
367
+ active: 0,
368
+ completionOrder: [] as string[],
369
+ maxActive: 0,
370
+ };
371
+ const worker = trail('entity.concurrent.worker', {
372
+ blaze: async (input: { delayMs: number; label: string }) => {
373
+ captures.active += 1;
374
+ captures.maxActive = Math.max(captures.maxActive, captures.active);
375
+ await Bun.sleep(input.delayMs);
376
+ captures.completionOrder.push(input.label);
377
+ captures.active -= 1;
378
+ return Result.ok({ label: input.label });
379
+ },
380
+ input: z.object({
381
+ delayMs: z.number(),
382
+ label: z.string(),
383
+ }),
384
+ output: z.object({ label: z.string() }),
385
+ visibility: 'internal',
386
+ });
387
+
388
+ return { captures, worker };
389
+ };
390
+
107
391
  // ---------------------------------------------------------------------------
108
392
  // Tests
109
393
  // ---------------------------------------------------------------------------
110
394
 
111
395
  describe('executeTrail', () => {
112
396
  describe('happy path', () => {
113
- test('validates input and executes trail', async () => {
114
- const result = await executeTrail(echoTrail, { value: 'hello' });
397
+ describe('execution and resources', () => {
398
+ test('validates input and executes trail', async () => {
399
+ const result = await executeTrail(echoTrail, { value: 'hello' });
115
400
 
116
- expect(result.isOk()).toBe(true);
117
- expect(result.unwrap()).toEqual({ value: 'hello' });
118
- });
401
+ expect(result.isOk()).toBe(true);
402
+ expect(result.unwrap()).toEqual({ value: 'hello' });
403
+ });
119
404
 
120
- test('eagerly resolves declared provisions before gates and implementation run', async () => {
121
- const id = nextProvisionId('eager');
122
- const captures = {
123
- createCalls: 0,
124
- gateResolved: undefined as number | undefined,
125
- runResolved: undefined as number | undefined,
126
- };
127
- const counter = createResolvedValueProvision(
128
- id,
129
- () => {
130
- captures.createCalls += 1;
131
- },
132
- 41
133
- );
134
- const layeredTrail = createEagerProvisionTrail(id, counter, (value) => {
135
- captures.runResolved = value;
405
+ test('eagerly resolves declared resources before layers and implementation run', async () => {
406
+ const id = nextResourceId('eager');
407
+ const captures = {
408
+ createCalls: 0,
409
+ gateResolved: undefined as number | undefined,
410
+ runResolved: undefined as number | undefined,
411
+ };
412
+ const counter = createResolvedValueResource(
413
+ id,
414
+ () => {
415
+ captures.createCalls += 1;
416
+ },
417
+ 41
418
+ );
419
+ const layeredTrail = createEagerResourceTrail(id, counter, (value) => {
420
+ captures.runResolved = value;
421
+ });
422
+ const layer = createResourceProbeGate(counter, (value) => {
423
+ captures.gateResolved = value;
424
+ });
425
+
426
+ const result = await executeTrail(
427
+ layeredTrail,
428
+ {},
429
+ { layers: [layer] }
430
+ );
431
+
432
+ expect(result.unwrap()).toEqual({ total: 42 });
433
+ expect(captures.createCalls).toBe(1);
434
+ expect(captures.gateResolved).toBe(41);
435
+ expect(captures.runResolved).toBe(41);
136
436
  });
137
- const gate = createProvisionProbeGate(counter, (value) => {
138
- captures.gateResolved = value;
437
+
438
+ test('awaits async resource factories before running the trail', async () => {
439
+ const db = resource(nextResourceId('async-factory'), {
440
+ create: async () => {
441
+ await Bun.sleep(0);
442
+ return Result.ok({ source: 'async-factory' });
443
+ },
444
+ });
445
+ const resourceTrail = trail('resource.async-factory', {
446
+ blaze: (_input, ctx) =>
447
+ Result.ok({ source: db.from(ctx).source as string }),
448
+ input: z.object({}),
449
+ output: z.object({ source: z.string() }),
450
+ resources: [db],
451
+ });
452
+
453
+ const result = await executeTrail(resourceTrail, {});
454
+
455
+ expect(result.isOk()).toBe(true);
456
+ expect(result.unwrap()).toEqual({ source: 'async-factory' });
139
457
  });
140
458
 
141
- const result = await executeTrail(layeredTrail, {}, { gates: [gate] });
459
+ test('reuses cached singleton resources across executions', async () => {
460
+ const id = nextResourceId('singleton');
461
+ const captures = { createCalls: 0 };
462
+ const singleton = createSingletonResource(id, () => {
463
+ captures.createCalls += 1;
464
+ return captures.createCalls;
465
+ });
466
+ const singletonTrail = createSingletonTrail(singleton);
467
+
468
+ const outputs = [
469
+ await unwrapExecution(singletonTrail),
470
+ await unwrapExecution(singletonTrail),
471
+ ];
142
472
 
143
- expect(result.unwrap()).toEqual({ total: 42 });
144
- expect(captures.createCalls).toBe(1);
145
- expect(captures.gateResolved).toBe(41);
146
- expect(captures.runResolved).toBe(41);
473
+ expect(outputs).toEqual([{ createdAtCall: 1 }, { createdAtCall: 1 }]);
474
+ expect(captures.createCalls).toBe(1);
475
+ });
476
+
477
+ test('scopes cached singleton resources to compatible resource contexts', async () => {
478
+ const id = nextResourceId('singleton-context');
479
+ const envAwareResource = resource(id, {
480
+ create: (ctx) => Result.ok({ value: String(ctx.env?.VAL) }),
481
+ });
482
+ const envAwareTrail = trail('resource.singleton-context', {
483
+ blaze: (_input, ctx) =>
484
+ Result.ok({ value: envAwareResource.from(ctx).value }),
485
+ input: z.object({}),
486
+ output: z.object({ value: z.string() }),
487
+ resources: [envAwareResource],
488
+ });
489
+
490
+ const first = await executeTrail(
491
+ envAwareTrail,
492
+ {},
493
+ {
494
+ createContext: () =>
495
+ createTrailContext({
496
+ env: { VAL: 'first' },
497
+ }),
498
+ }
499
+ );
500
+ const second = await executeTrail(
501
+ envAwareTrail,
502
+ {},
503
+ {
504
+ createContext: () =>
505
+ createTrailContext({
506
+ env: { VAL: 'second' },
507
+ }),
508
+ }
509
+ );
510
+
511
+ expect(first.unwrap()).toEqual({ value: 'first' });
512
+ expect(second.unwrap()).toEqual({ value: 'second' });
513
+ });
147
514
  });
148
515
 
149
- test('awaits async provision factories before running the trail', async () => {
150
- const db = provision(nextProvisionId('async-factory'), {
151
- create: async () => {
152
- await Bun.sleep(0);
153
- return Result.ok({ source: 'async-factory' });
154
- },
516
+ describe('crossing execution', () => {
517
+ test('binds ctx.cross when topo access is available', async () => {
518
+ const helper = trail('entity.secret.rotate', {
519
+ blaze: (input: { id: string }) => Result.ok({ rotated: input.id }),
520
+ input: z.object({ id: z.string() }),
521
+ output: z.object({ rotated: z.string() }),
522
+ visibility: 'internal',
523
+ });
524
+ const entry = trail('entity.rotate', {
525
+ blaze: async (input: { id: string }, ctx) => {
526
+ const crossed = await requireCross(ctx)(
527
+ 'entity.secret.rotate',
528
+ input
529
+ );
530
+ return crossed.match({
531
+ err: (error) => Result.err(error),
532
+ ok: (value) => Result.ok(value),
533
+ });
534
+ },
535
+ crosses: ['entity.secret.rotate'],
536
+ input: z.object({ id: z.string() }),
537
+ output: z.object({ rotated: z.string() }),
538
+ });
539
+ const app = topo('cross-topo', { entry, helper });
540
+
541
+ const result = await executeTrail(
542
+ entry,
543
+ { id: 'abc123' },
544
+ { topo: app }
545
+ );
546
+
547
+ expect(result.isOk()).toBe(true);
548
+ expect(result.unwrap()).toEqual({ rotated: 'abc123' });
155
549
  });
156
- const provisionTrail = trail('provision.async-factory', {
157
- blaze: (_input, ctx) =>
158
- Result.ok({ source: db.from(ctx).source as string }),
159
- input: z.object({}),
160
- output: z.object({ source: z.string() }),
161
- provisions: [db],
550
+
551
+ test('validates merged crossInput fields when invoking through ctx.cross', async () => {
552
+ const helper = trail('entity.prepare', {
553
+ blaze: (input: { forkedFrom: string; id: string }) =>
554
+ Result.ok({ summary: `${input.id}:${input.forkedFrom}` }),
555
+ crossInput: z.object({ forkedFrom: z.string() }),
556
+ input: z.object({ id: z.string() }),
557
+ output: z.object({ summary: z.string() }),
558
+ visibility: 'internal',
559
+ });
560
+ const entry = trail('entity.run', {
561
+ blaze: async (input: { id: string }, ctx) => {
562
+ const crossed = await requireCross(ctx)(helper, {
563
+ forkedFrom: 'entity.run',
564
+ id: input.id,
565
+ });
566
+ return crossed.match({
567
+ err: (error) => Result.err(error),
568
+ ok: (value) => Result.ok(value),
569
+ });
570
+ },
571
+ crosses: [helper],
572
+ input: z.object({ id: z.string() }),
573
+ output: z.object({ summary: z.string() }),
574
+ });
575
+ const app = topo('cross-input-topo', { entry, helper });
576
+
577
+ const result = await executeTrail(
578
+ entry,
579
+ { id: 'abc123' },
580
+ { topo: app }
581
+ );
582
+
583
+ expect(result.isOk()).toBe(true);
584
+ expect(result.unwrap()).toEqual({ summary: 'abc123:entity.run' });
162
585
  });
163
586
 
164
- const result = await executeTrail(provisionTrail, {});
587
+ test('executes batch ctx.cross() calls concurrently and preserves tuple order', async () => {
588
+ const completionOrder: string[] = [];
589
+ const slow = trail('entity.slow', {
590
+ blaze: async () => {
591
+ await Bun.sleep(10);
592
+ completionOrder.push('slow');
593
+ return Result.ok({ id: 'slow' });
594
+ },
595
+ input: z.object({}),
596
+ output: z.object({ id: z.string() }),
597
+ visibility: 'internal',
598
+ });
599
+ const fast = trail('entity.fast', {
600
+ blaze: async () => {
601
+ await Bun.sleep(0);
602
+ completionOrder.push('fast');
603
+ return Result.ok({ id: 'fast' });
604
+ },
605
+ input: z.object({}),
606
+ output: z.object({ id: z.string() }),
607
+ visibility: 'internal',
608
+ });
609
+ const entry = trail('entity.batch', {
610
+ blaze: async (_input, ctx) => {
611
+ const crossed = await requireCross(ctx)([
612
+ ['entity.slow', {}],
613
+ ['entity.fast', {}],
614
+ ]);
615
+ return Result.ok({
616
+ completionOrder,
617
+ resultOrder: crossed.map((result) =>
618
+ result.match({
619
+ err: () => 'err',
620
+ ok: (value) => (value as { id: string }).id,
621
+ })
622
+ ),
623
+ });
624
+ },
625
+ crosses: ['entity.fast', 'entity.slow'],
626
+ input: z.object({}),
627
+ output: z.object({
628
+ completionOrder: z.array(z.string()),
629
+ resultOrder: z.array(z.string()),
630
+ }),
631
+ });
632
+ const app = topo('cross-batch-topo', { entry, fast, slow });
165
633
 
166
- expect(result.isOk()).toBe(true);
167
- expect(result.unwrap()).toEqual({ source: 'async-factory' });
168
- });
634
+ const result = await executeTrail(entry, {}, { topo: app });
169
635
 
170
- test('reuses cached singleton provisions across executions', async () => {
171
- const id = nextProvisionId('singleton');
172
- const captures = { createCalls: 0 };
173
- const singleton = createSingletonProvision(id, () => {
174
- captures.createCalls += 1;
175
- return captures.createCalls;
636
+ expect(result.isOk()).toBe(true);
637
+ expect(result.unwrap()).toEqual({
638
+ completionOrder: ['fast', 'slow'],
639
+ resultOrder: ['slow', 'fast'],
640
+ });
176
641
  });
177
- const singletonTrail = createSingletonTrail(singleton);
178
642
 
179
- const outputs = [
180
- await unwrapExecution(singletonTrail),
181
- await unwrapExecution(singletonTrail),
182
- ];
643
+ test('returns every batch cross result without short-circuiting on errors', async () => {
644
+ const completions: string[] = [];
645
+ const failing = trail('entity.fail', {
646
+ blaze: async () => {
647
+ await Bun.sleep(0);
648
+ completions.push('fail');
649
+ return Result.err(new ValidationError('nope'));
650
+ },
651
+ input: z.object({}),
652
+ output: z.object({ ok: z.boolean() }),
653
+ visibility: 'internal',
654
+ });
655
+ const succeeding = trail('entity.ok', {
656
+ blaze: async () => {
657
+ await Bun.sleep(5);
658
+ completions.push('ok');
659
+ return Result.ok({ ok: true });
660
+ },
661
+ input: z.object({}),
662
+ output: z.object({ ok: z.boolean() }),
663
+ visibility: 'internal',
664
+ });
665
+ const entry = trail('entity.batch.errors', {
666
+ blaze: async (_input, ctx) => {
667
+ const crossed = await requireCross(ctx)([
668
+ [failing, {}],
669
+ [succeeding, {}],
670
+ ] as const);
671
+ return Result.ok({
672
+ completions,
673
+ statuses: crossed.map((result) =>
674
+ result.match({
675
+ err: () => 'err',
676
+ ok: () => 'ok',
677
+ })
678
+ ),
679
+ });
680
+ },
681
+ crosses: [failing, succeeding],
682
+ input: z.object({}),
683
+ output: z.object({
684
+ completions: z.array(z.string()),
685
+ statuses: z.array(z.string()),
686
+ }),
687
+ });
688
+ const app = topo('cross-batch-errors-topo', {
689
+ entry,
690
+ failing,
691
+ succeeding,
692
+ });
693
+
694
+ const result = await executeTrail(entry, {}, { topo: app });
695
+
696
+ expect(result.isOk()).toBe(true);
697
+ expect(result.unwrap()).toEqual({
698
+ completions: ['fail', 'ok'],
699
+ statuses: ['err', 'ok'],
700
+ });
701
+ });
183
702
 
184
- expect(outputs).toEqual([{ createdAtCall: 1 }, { createdAtCall: 1 }]);
185
- expect(captures.createCalls).toBe(1);
186
- });
703
+ test('returns an empty array for empty batch ctx.cross() calls', async () => {
704
+ const entry = trail('entity.batch.empty', {
705
+ blaze: async (_input, ctx) => {
706
+ const crossed = await requireCross(ctx)([]);
707
+ return Result.ok({ count: crossed.length });
708
+ },
709
+ input: z.object({}),
710
+ output: z.object({ count: z.number() }),
711
+ });
712
+ const app = topo('cross-batch-empty-topo', { entry });
713
+
714
+ const result = await executeTrail(entry, {}, { topo: app });
187
715
 
188
- test('scopes cached singleton provisions to compatible provision contexts', async () => {
189
- const id = nextProvisionId('singleton-context');
190
- const envAwareProvision = provision(id, {
191
- create: (ctx) => Result.ok({ value: String(ctx.env?.VAL) }),
716
+ expect(result.isOk()).toBe(true);
717
+ expect(result.unwrap()).toEqual({ count: 0 });
192
718
  });
193
- const envAwareTrail = trail('provision.singleton-context', {
194
- blaze: (_input, ctx) =>
195
- Result.ok({ value: envAwareProvision.from(ctx).value }),
196
- input: z.object({}),
197
- output: z.object({ value: z.string() }),
198
- provisions: [envAwareProvision],
719
+
720
+ test('limits batch ctx.cross() execution to sequential flow when concurrency is 1', async () => {
721
+ const { captures, worker } = createConcurrencyWorkerScenario();
722
+ const entry = trail('entity.batch.sequential-limit', {
723
+ blaze: async (_input, ctx) => {
724
+ const crossed = await requireCross(ctx)(
725
+ [
726
+ [worker, { delayMs: 5, label: 'first' }],
727
+ [worker, { delayMs: 0, label: 'second' }],
728
+ [worker, { delayMs: 0, label: 'third' }],
729
+ ] as const,
730
+ { concurrency: 1 }
731
+ );
732
+ return Result.ok({
733
+ completionOrder: captures.completionOrder,
734
+ maxActive: captures.maxActive,
735
+ resultOrder: crossed.map((result) =>
736
+ result.match({
737
+ err: () => 'err',
738
+ ok: (value) => value.label,
739
+ })
740
+ ),
741
+ });
742
+ },
743
+ crosses: [worker],
744
+ input: z.object({}),
745
+ output: z.object({
746
+ completionOrder: z.array(z.string()),
747
+ maxActive: z.number(),
748
+ resultOrder: z.array(z.string()),
749
+ }),
750
+ });
751
+ const app = topo('cross-batch-sequential-limit-topo', {
752
+ entry,
753
+ worker,
754
+ });
755
+
756
+ const result = await executeTrail(entry, {}, { topo: app });
757
+
758
+ expect(result.isOk()).toBe(true);
759
+ expect(result.unwrap()).toEqual({
760
+ completionOrder: ['first', 'second', 'third'],
761
+ maxActive: 1,
762
+ resultOrder: ['first', 'second', 'third'],
763
+ });
199
764
  });
200
765
 
201
- const first = await executeTrail(
202
- envAwareTrail,
766
+ test('caps concurrent batch ctx.cross() execution and preserves input order', async () => {
767
+ const { captures, worker } = createConcurrencyWorkerScenario();
768
+ const labels = Array.from(
769
+ { length: 10 },
770
+ (_, index) => `task-${index}`
771
+ );
772
+ const entry = trail('entity.batch.concurrent-limit', {
773
+ blaze: async (_input, ctx) => {
774
+ const crossed = await requireCross(ctx)(
775
+ labels.map((label) => [worker, { delayMs: 5, label }] as const),
776
+ { concurrency: 3 }
777
+ );
778
+ return Result.ok({
779
+ completionCount: captures.completionOrder.length,
780
+ maxActive: captures.maxActive,
781
+ resultOrder: crossed.map((result) =>
782
+ result.match({
783
+ err: () => 'err',
784
+ ok: (value) => value.label,
785
+ })
786
+ ),
787
+ });
788
+ },
789
+ crosses: [worker],
790
+ input: z.object({}),
791
+ output: z.object({
792
+ completionCount: z.number(),
793
+ maxActive: z.number(),
794
+ resultOrder: z.array(z.string()),
795
+ }),
796
+ });
797
+ const app = topo('cross-batch-concurrent-limit-topo', {
798
+ entry,
799
+ worker,
800
+ });
801
+
802
+ const result = await executeTrail(entry, {}, { topo: app });
803
+
804
+ expect(result.isOk()).toBe(true);
805
+ expect(result.unwrap()).toEqual({
806
+ completionCount: 10,
807
+ maxActive: 3,
808
+ resultOrder: labels,
809
+ });
810
+ });
811
+
812
+ describe.each([
813
+ { label: '0', value: 0 },
814
+ { label: '-1', value: -1 },
815
+ { label: '1.5', value: 1.5 },
816
+ { label: 'NaN', value: Number.NaN },
817
+ ])(
818
+ 'rejects batch ctx.cross() calls with invalid concurrency $label',
819
+ ({ value }) => {
820
+ test('produces a ValidationError for every branch without running any', async () => {
821
+ const branchRuns: string[] = [];
822
+ const child = trail('entity.batch.invalid-concurrency.child', {
823
+ blaze: async () => {
824
+ branchRuns.push('ran');
825
+ return Result.ok({ id: 'child' });
826
+ },
827
+ input: z.object({}),
828
+ output: z.object({ id: z.string() }),
829
+ visibility: 'internal',
830
+ });
831
+ const entry = trail('entity.batch.invalid-concurrency.entry', {
832
+ blaze: async (_input, ctx) => {
833
+ const crossed = await requireCross(ctx)(
834
+ [
835
+ [child, {}],
836
+ [child, {}],
837
+ ] as const,
838
+ { concurrency: value }
839
+ );
840
+ return Result.ok({
841
+ statuses: crossed.map((result) =>
842
+ result.match({
843
+ err: (error) => ({
844
+ isValidation: error instanceof ValidationError,
845
+ message: error.message,
846
+ }),
847
+ ok: () => ({ isValidation: false, message: 'ok' }),
848
+ })
849
+ ),
850
+ });
851
+ },
852
+ crosses: [child],
853
+ input: z.object({}),
854
+ output: z.object({
855
+ statuses: z.array(
856
+ z.object({
857
+ isValidation: z.boolean(),
858
+ message: z.string(),
859
+ })
860
+ ),
861
+ }),
862
+ });
863
+ const app = topo(
864
+ `cross-batch-invalid-concurrency-${String(value)}-topo`,
865
+ { child, entry }
866
+ );
867
+
868
+ const result = await executeTrail(entry, {}, { topo: app });
869
+
870
+ expect(result.isOk()).toBe(true);
871
+ expect(result.unwrap()).toEqual({
872
+ statuses: [
873
+ {
874
+ isValidation: true,
875
+ message:
876
+ 'ctx.cross() batch concurrency must be a positive integer',
877
+ },
878
+ {
879
+ isValidation: true,
880
+ message:
881
+ 'ctx.cross() batch concurrency must be a positive integer',
882
+ },
883
+ ],
884
+ });
885
+ expect(branchRuns).toEqual([]);
886
+ });
887
+ }
888
+ );
889
+ });
890
+ });
891
+
892
+ describe('concurrent crossings', () => {
893
+ test('resolves concurrent branch resources from branch scope instead of inheriting parent resources', async () => {
894
+ const { app, captures, entry, id } =
895
+ createConcurrentBranchResourceScopeScenario();
896
+
897
+ const result = await executeTrail(
898
+ entry,
203
899
  {},
204
900
  {
205
- createContext: () =>
206
- createTrailContext({
207
- env: { VAL: 'first' },
208
- }),
901
+ ctx: { extensions: { [id]: { source: 'parent-scope' } } },
902
+ topo: app,
209
903
  }
210
904
  );
211
- const second = await executeTrail(
212
- envAwareTrail,
905
+
906
+ expect(result.isOk()).toBe(true);
907
+ expect(result.unwrap()).toEqual({
908
+ sources: ['branch-scope-1', 'branch-scope-1'],
909
+ });
910
+ expect(captures.createCalls).toBe(1);
911
+ });
912
+
913
+ test('propagates the same AbortSignal to every concurrent branch', async () => {
914
+ const seenSignals: AbortSignal[] = [];
915
+ const { app, entry } = createAbortSignalScenario(seenSignals);
916
+ const abortSignal = AbortSignal.timeout(5);
917
+
918
+ const result = await executeTrail(entry, {}, { abortSignal, topo: app });
919
+
920
+ expect(result.isOk()).toBe(true);
921
+ expect(result.unwrap()).toEqual({
922
+ statuses: ['cancelled', 'cancelled'],
923
+ });
924
+ expect(seenSignals).toEqual([abortSignal, abortSignal]);
925
+ });
926
+
927
+ test('propagates the parent permit to every concurrent branch', async () => {
928
+ const permit = Object.freeze({
929
+ id: 'permit-orders',
930
+ scopes: ['orders:read'] as const,
931
+ });
932
+ const captures: PermitCapture[] = [];
933
+ const { app, entry } = createPermitScenario(permit, captures);
934
+
935
+ const result = await executeTrail(
936
+ entry,
937
+ {},
938
+ { ctx: { permit }, topo: app }
939
+ );
940
+
941
+ expect(result.isOk()).toBe(true);
942
+ expect(result.unwrap()).toEqual({
943
+ permits: [
944
+ { permitId: 'permit-orders', sameReference: true },
945
+ { permitId: 'permit-orders', sameReference: true },
946
+ ],
947
+ });
948
+ expect(captures).toEqual([
949
+ { permitId: 'permit-orders', sameReference: true },
950
+ { permitId: 'permit-orders', sameReference: true },
951
+ ]);
952
+ });
953
+
954
+ test("keeps one branch's failure from poisoning sibling branch scopes", async () => {
955
+ const { app, entry, failingId, succeedingId } =
956
+ createSiblingFailureScopeScenario();
957
+
958
+ const result = await executeTrail(
959
+ entry,
213
960
  {},
214
961
  {
215
- createContext: () =>
216
- createTrailContext({
217
- env: { VAL: 'second' },
218
- }),
962
+ ctx: {
963
+ extensions: {
964
+ [failingId]: { source: 'parent-failing-scope' },
965
+ [succeedingId]: { source: 'parent-success-scope' },
966
+ },
967
+ },
968
+ topo: app,
219
969
  }
220
970
  );
221
971
 
222
- expect(first.unwrap()).toEqual({ value: 'first' });
223
- expect(second.unwrap()).toEqual({ value: 'second' });
972
+ expect(result.isOk()).toBe(true);
973
+ expect(result.unwrap()).toEqual({
974
+ failed: 'failing branch scope exploded',
975
+ succeeded: 'sibling-branch-scope',
976
+ });
224
977
  });
225
978
  });
226
979
 
@@ -233,11 +986,11 @@ describe('executeTrail', () => {
233
986
  });
234
987
  });
235
988
 
236
- describe('gates', () => {
237
- test('composes gates around execution', async () => {
989
+ describe('layers', () => {
990
+ test('composes layers around execution', async () => {
238
991
  const log: string[] = [];
239
- const gate: Gate = {
240
- name: 'test-gate',
992
+ const layer: Layer = {
993
+ name: 'test-layer',
241
994
  wrap(_trail, impl) {
242
995
  return async (input, ctx) => {
243
996
  log.push('before');
@@ -251,7 +1004,7 @@ describe('executeTrail', () => {
251
1004
  const result = await executeTrail(
252
1005
  echoTrail,
253
1006
  { value: 'x' },
254
- { gates: [gate] }
1007
+ { layers: [layer] }
255
1008
  );
256
1009
 
257
1010
  expect(result.isOk()).toBe(true);
@@ -361,26 +1114,49 @@ describe('executeTrail', () => {
361
1114
  ctx: { extensions: { userId: '123' } },
362
1115
  }
363
1116
  );
364
- expect(captured?.extensions).toEqual({ store: 'db', userId: '123' });
1117
+ // User-authored extension keys must stay intact regardless of whether
1118
+ // tracing later adds its own internal bookkeeping.
1119
+ expect(captured?.extensions?.store).toBe('db');
1120
+ expect(captured?.extensions?.userId).toBe('123');
365
1121
  });
366
1122
 
367
- test('rebinds ctx.provision after merging extension overrides from createContext', async () => {
1123
+ test('keeps ctx.trace in passthrough mode when NOOP_SINK is installed', async () => {
1124
+ clearTraceSink();
1125
+
1126
+ let sawTraceContext = true;
1127
+ const probe = trail('ctx.trace.noop-sink', {
1128
+ blaze: async (_input, ctx) => {
1129
+ const value = await ctx.trace('inner', () => Promise.resolve(42));
1130
+ sawTraceContext = ctx.extensions?.[TRACE_CONTEXT_KEY] !== undefined;
1131
+ return Result.ok({ value });
1132
+ },
1133
+ input: z.object({}),
1134
+ output: z.object({ value: z.number() }),
1135
+ });
1136
+
1137
+ const result = await executeTrail(probe, {});
1138
+
1139
+ expect(result).toEqual(Result.ok({ value: 42 }));
1140
+ expect(sawTraceContext).toBe(false);
1141
+ });
1142
+
1143
+ test('rebinds ctx.resource after merging extension overrides from createContext', async () => {
368
1144
  let resolvedSource: string | undefined;
369
- const db = provision(nextProvisionId('context-override'), {
1145
+ const db = resource(nextResourceId('context-override'), {
370
1146
  create: () => Result.ok({ source: 'factory' }),
371
1147
  });
372
- const provisionTrail = trail('ctx.provision.override', {
1148
+ const resourceTrail = trail('ctx.resource.override', {
373
1149
  blaze: (_input, ctx) => {
374
- resolvedSource = ctx.provision<{ source: string }>(db).source;
1150
+ resolvedSource = ctx.resource<{ source: string }>(db).source;
375
1151
  return Result.ok({ source: resolvedSource });
376
1152
  },
377
1153
  input: z.object({}),
378
1154
  output: z.object({ source: z.string() }),
379
- provisions: [db],
1155
+ resources: [db],
380
1156
  });
381
1157
 
382
1158
  const result = await executeTrail(
383
- provisionTrail,
1159
+ resourceTrail,
384
1160
  {},
385
1161
  {
386
1162
  createContext: () =>
@@ -401,17 +1177,17 @@ describe('executeTrail', () => {
401
1177
  expect(resolvedSource).toBe('override-context');
402
1178
  });
403
1179
 
404
- test('context factory seeds the provision accessor when omitted', async () => {
1180
+ test('context factory seeds the resource accessor when omitted', async () => {
405
1181
  let capturedCtx: TrailContext | undefined;
406
- const id = nextProvisionId('factory-seed');
1182
+ const id = nextResourceId('factory-seed');
407
1183
  const widget = { id: 'widget-1' };
408
- const widgetTrail = trail('provision.factory-seed', {
1184
+ const widgetTrail = trail('resource.factory-seed', {
409
1185
  blaze: (_input, ctx) => {
410
1186
  capturedCtx = ctx;
411
1187
  return Result.ok(null);
412
1188
  },
413
1189
  input: z.object({}),
414
- provisions: [],
1190
+ resources: [],
415
1191
  });
416
1192
 
417
1193
  await executeTrail(
@@ -421,12 +1197,12 @@ describe('executeTrail', () => {
421
1197
  createContext: () => ({
422
1198
  abortSignal: new AbortController().signal,
423
1199
  extensions: { [id]: widget },
424
- requestId: 'seeded-provision',
1200
+ requestId: 'seeded-resource',
425
1201
  }),
426
1202
  }
427
1203
  );
428
1204
 
429
- expect(capturedCtx?.provision(id)).toBe(widget);
1205
+ expect(capturedCtx?.resource(id)).toBe(widget);
430
1206
  });
431
1207
  });
432
1208
 
@@ -447,22 +1223,22 @@ describe('executeTrail', () => {
447
1223
  expect(result.error.message).toBe('kaboom');
448
1224
  });
449
1225
 
450
- test('short-circuits when a provision factory returns Result.err', async () => {
451
- const failingProvision = provision(nextProvisionId('factory-error'), {
1226
+ test('short-circuits when a resource factory returns Result.err', async () => {
1227
+ const failingResource = resource(nextResourceId('factory-error'), {
452
1228
  create: () =>
453
1229
  Result.err(new ValidationError('DATABASE_URL is required')),
454
1230
  });
455
1231
  let ran = false;
456
- const provisionTrail = trail('provision.factory-error', {
1232
+ const resourceTrail = trail('resource.factory-error', {
457
1233
  blaze: () => {
458
1234
  ran = true;
459
1235
  return Result.ok(null);
460
1236
  },
461
1237
  input: z.object({}),
462
- provisions: [failingProvision],
1238
+ resources: [failingResource],
463
1239
  });
464
1240
 
465
- const result = await executeTrail(provisionTrail, {});
1241
+ const result = await executeTrail(resourceTrail, {});
466
1242
 
467
1243
  expect(result.isErr()).toBe(true);
468
1244
  expect(result.error).toBeInstanceOf(ValidationError);
@@ -470,50 +1246,50 @@ describe('executeTrail', () => {
470
1246
  expect(ran).toBe(false);
471
1247
  });
472
1248
 
473
- test('wraps thrown provision factory exceptions with the provision ID in context', async () => {
474
- const explodingProvision = provision(nextProvisionId('factory-throw'), {
1249
+ test('wraps thrown resource factory exceptions with the resource ID in context', async () => {
1250
+ const explodingResource = resource(nextResourceId('factory-throw'), {
475
1251
  create: () => {
476
1252
  throw new Error('boom');
477
1253
  },
478
1254
  });
479
- const provisionTrail = trail('provision.factory-throw', {
1255
+ const resourceTrail = trail('resource.factory-throw', {
480
1256
  blaze: () => Result.ok(null),
481
1257
  input: z.object({}),
482
- provisions: [explodingProvision],
1258
+ resources: [explodingResource],
483
1259
  });
484
1260
 
485
- const result = await executeTrail(provisionTrail, {});
1261
+ const result = await executeTrail(resourceTrail, {});
486
1262
 
487
1263
  expect(result.isErr()).toBe(true);
488
1264
  expect(result.error).toBeInstanceOf(InternalError);
489
- expect(result.error.message).toContain(explodingProvision.id);
1265
+ expect(result.error.message).toContain(explodingResource.id);
490
1266
  expect(result.error.context).toEqual({
491
- provisionId: explodingProvision.id,
1267
+ resourceId: explodingResource.id,
492
1268
  });
493
1269
  });
494
1270
 
495
- test('prefers explicit provision overrides over cached or created instances', async () => {
496
- const id = nextProvisionId('override');
1271
+ test('prefers explicit resource overrides over cached or created instances', async () => {
1272
+ const id = nextResourceId('override');
497
1273
  let createCalls = 0;
498
- const db = provision(id, {
1274
+ const db = resource(id, {
499
1275
  create: () => {
500
1276
  createCalls += 1;
501
1277
  return Result.ok({ source: 'factory' });
502
1278
  },
503
1279
  });
504
- const provisionTrail = trail('provision.override', {
1280
+ const resourceTrail = trail('resource.override', {
505
1281
  blaze: (_input, ctx) =>
506
1282
  Result.ok({ source: db.from(ctx).source as string }),
507
1283
  input: z.object({}),
508
1284
  output: z.object({ source: z.string() }),
509
- provisions: [db],
1285
+ resources: [db],
510
1286
  });
511
1287
 
512
1288
  const result = await executeTrail(
513
- provisionTrail,
1289
+ resourceTrail,
514
1290
  {},
515
1291
  {
516
- provisions: { [id]: { source: 'override' } },
1292
+ resources: { [id]: { source: 'override' } },
517
1293
  }
518
1294
  );
519
1295
 
@@ -522,4 +1298,266 @@ describe('executeTrail', () => {
522
1298
  expect(createCalls).toBe(0);
523
1299
  });
524
1300
  });
1301
+
1302
+ // ---------------------------------------------------------------------------
1303
+ // Detour execution
1304
+ // ---------------------------------------------------------------------------
1305
+
1306
+ describe('detour execution', () => {
1307
+ test('detour recovers successfully from matching error', async () => {
1308
+ const detourTrail = trail('detour.recover', {
1309
+ blaze: () => Result.err(new ConflictError('version mismatch')),
1310
+ detours: [
1311
+ {
1312
+ on: ConflictError,
1313
+ recover: async () => Result.ok({ recovered: true }),
1314
+ },
1315
+ ],
1316
+ input: z.object({}),
1317
+ output: z.object({ recovered: z.boolean() }),
1318
+ });
1319
+
1320
+ const result = await executeTrail(detourTrail, {});
1321
+
1322
+ expect(result.isOk()).toBe(true);
1323
+ expect(result.unwrap()).toEqual({ recovered: true });
1324
+ });
1325
+
1326
+ test('exhaustion produces RetryExhaustedError with category inheritance', async () => {
1327
+ const detourTrail = trail('detour.exhaust', {
1328
+ blaze: () => Result.err(new ConflictError('version mismatch')),
1329
+ detours: [
1330
+ {
1331
+ maxAttempts: 2,
1332
+ on: ConflictError,
1333
+ recover: async () =>
1334
+ Result.err(new ConflictError('still conflicting')),
1335
+ },
1336
+ ],
1337
+ input: z.object({}),
1338
+ });
1339
+
1340
+ const result = await executeTrail(detourTrail, {});
1341
+
1342
+ expect(result.isErr()).toBe(true);
1343
+ const { error } = result;
1344
+ expect(error).toBeInstanceOf(RetryExhaustedError);
1345
+ expect(error).toBeInstanceOf(InternalError);
1346
+ const exhausted = error as RetryExhaustedError;
1347
+ expect(exhausted.category).toBe('conflict');
1348
+ expect(exhausted.retryable).toBe(false);
1349
+ expect(exhausted.cause).toBeInstanceOf(ConflictError);
1350
+ });
1351
+
1352
+ test('non-matching error passes through unchanged', async () => {
1353
+ const detourTrail = trail('detour.nomatch', {
1354
+ blaze: () => Result.err(new NotFoundError('missing')),
1355
+ detours: [
1356
+ {
1357
+ on: ConflictError,
1358
+ recover: async () => Result.ok({ recovered: true }),
1359
+ },
1360
+ ],
1361
+ input: z.object({}),
1362
+ });
1363
+
1364
+ const result = await executeTrail(detourTrail, {});
1365
+
1366
+ expect(result.isErr()).toBe(true);
1367
+ expect(result.error).toBeInstanceOf(NotFoundError);
1368
+ });
1369
+
1370
+ test('declaration-order matching — first matching detour wins', async () => {
1371
+ const calls: string[] = [];
1372
+ const detourTrail = trail('detour.order', {
1373
+ blaze: () => Result.err(new ConflictError('conflict')),
1374
+ detours: [
1375
+ {
1376
+ on: TrailsError,
1377
+ recover: async () => {
1378
+ calls.push('TrailsError');
1379
+ return Result.ok({ winner: 'superclass' });
1380
+ },
1381
+ },
1382
+ {
1383
+ on: ConflictError,
1384
+ recover: async () => {
1385
+ calls.push('ConflictError');
1386
+ return Result.ok({ winner: 'subclass' });
1387
+ },
1388
+ },
1389
+ ],
1390
+ input: z.object({}),
1391
+ output: z.object({ winner: z.string() }),
1392
+ });
1393
+
1394
+ const result = await executeTrail(detourTrail, {});
1395
+
1396
+ expect(result.isOk()).toBe(true);
1397
+ expect(result.unwrap()).toEqual({ winner: 'superclass' });
1398
+ expect(calls).toEqual(['TrailsError']);
1399
+ });
1400
+
1401
+ test('multi-attempt success on 2nd try', async () => {
1402
+ let recoverCalls = 0;
1403
+ const responses: Result<{ attempt: number }, TrailsError>[] = [
1404
+ Result.err(new ConflictError('still conflicting')),
1405
+ Result.ok({ attempt: 2 }),
1406
+ ];
1407
+ const detourTrail = trail('detour.multi', {
1408
+ blaze: () => Result.err(new ConflictError('conflict')),
1409
+ detours: [
1410
+ {
1411
+ maxAttempts: 3,
1412
+ on: ConflictError,
1413
+ recover: async () => {
1414
+ recoverCalls += 1;
1415
+ return responses.shift() as Result<never, TrailsError>;
1416
+ },
1417
+ },
1418
+ ],
1419
+ input: z.object({}),
1420
+ output: z.object({ attempt: z.number() }),
1421
+ });
1422
+
1423
+ const result = await executeTrail(detourTrail, {});
1424
+
1425
+ expect(result.isOk()).toBe(true);
1426
+ expect(result.unwrap()).toEqual({ attempt: 2 });
1427
+ expect(recoverCalls).toBe(2);
1428
+ });
1429
+
1430
+ test('non-TrailsError throw inside recover becomes InternalError', async () => {
1431
+ const detourTrail = trail('detour.throw', {
1432
+ blaze: () => Result.err(new ConflictError('conflict')),
1433
+ detours: [
1434
+ {
1435
+ on: ConflictError,
1436
+ recover: async () => {
1437
+ throw new Error('plain error in recover');
1438
+ },
1439
+ },
1440
+ ],
1441
+ input: z.object({}),
1442
+ });
1443
+
1444
+ const result = await executeTrail(detourTrail, {});
1445
+
1446
+ expect(result.isErr()).toBe(true);
1447
+ expect(result.error).toBeInstanceOf(InternalError);
1448
+ });
1449
+
1450
+ test('maxAttempts hard cap at 5', async () => {
1451
+ let recoverCalls = 0;
1452
+ const detourTrail = trail('detour.cap', {
1453
+ blaze: () => Result.err(new ConflictError('conflict')),
1454
+ detours: [
1455
+ {
1456
+ maxAttempts: 100,
1457
+ on: ConflictError,
1458
+ recover: async () => {
1459
+ recoverCalls += 1;
1460
+ return Result.err(new ConflictError('still conflicting'));
1461
+ },
1462
+ },
1463
+ ],
1464
+ input: z.object({}),
1465
+ });
1466
+
1467
+ const result = await executeTrail(detourTrail, {});
1468
+
1469
+ expect(result.isErr()).toBe(true);
1470
+ expect(result.error).toBeInstanceOf(RetryExhaustedError);
1471
+ expect(recoverCalls).toBe(5);
1472
+ });
1473
+
1474
+ test('no detours declared — baseline unchanged', async () => {
1475
+ const noDetourTrail = trail('detour.none', {
1476
+ blaze: () => Result.err(new ConflictError('conflict')),
1477
+ input: z.object({}),
1478
+ });
1479
+
1480
+ const result = await executeTrail(noDetourTrail, {});
1481
+
1482
+ expect(result.isErr()).toBe(true);
1483
+ expect(result.error).toBeInstanceOf(ConflictError);
1484
+ });
1485
+
1486
+ test('cross-detour termination — different error type stops recovery', async () => {
1487
+ const detourTrail = trail('detour.cross-terminate', {
1488
+ blaze: () => Result.err(new ConflictError('conflict')),
1489
+ detours: [
1490
+ {
1491
+ maxAttempts: 3,
1492
+ on: ConflictError,
1493
+ recover: async () => Result.err(new NetworkError('disconnected')),
1494
+ },
1495
+ ],
1496
+ input: z.object({}),
1497
+ });
1498
+
1499
+ const result = await executeTrail(detourTrail, {});
1500
+
1501
+ expect(result.isErr()).toBe(true);
1502
+ expect(result.error).toBeInstanceOf(NetworkError);
1503
+ });
1504
+
1505
+ describe('layer interaction', () => {
1506
+ const createCountingLayer = () => {
1507
+ let wrapCalls = 0;
1508
+ let execCalls = 0;
1509
+ const layer: Layer = {
1510
+ name: 'counting',
1511
+ wrap(_trail, impl) {
1512
+ wrapCalls += 1;
1513
+ return async (input, ctx) => {
1514
+ execCalls += 1;
1515
+ return await impl(input, ctx);
1516
+ };
1517
+ },
1518
+ };
1519
+ return {
1520
+ execCalls: () => execCalls,
1521
+ layer,
1522
+ wrapCalls: () => wrapCalls,
1523
+ };
1524
+ };
1525
+
1526
+ test('layer wraps once per logical execution, not per attempt', async () => {
1527
+ const counting = createCountingLayer();
1528
+ let recoverCalls = 0;
1529
+ const responses: Result<{ done: boolean }, TrailsError>[] = [
1530
+ Result.err(new ConflictError('still conflicting')),
1531
+ Result.err(new ConflictError('still conflicting')),
1532
+ Result.ok({ done: true }),
1533
+ ];
1534
+ const detourTrail = trail('detour.layer-count', {
1535
+ blaze: () => Result.err(new ConflictError('conflict')),
1536
+ detours: [
1537
+ {
1538
+ maxAttempts: 3,
1539
+ on: ConflictError,
1540
+ recover: async () => {
1541
+ recoverCalls += 1;
1542
+ return responses.shift() as Result<never, TrailsError>;
1543
+ },
1544
+ },
1545
+ ],
1546
+ input: z.object({}),
1547
+ output: z.object({ done: z.boolean() }),
1548
+ });
1549
+
1550
+ const result = await executeTrail(
1551
+ detourTrail,
1552
+ {},
1553
+ { layers: [counting.layer] }
1554
+ );
1555
+
1556
+ expect(result.isOk()).toBe(true);
1557
+ expect(counting.wrapCalls()).toBe(1);
1558
+ expect(counting.execCalls()).toBe(1);
1559
+ expect(recoverCalls).toBe(3);
1560
+ });
1561
+ });
1562
+ });
525
1563
  });