@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
@@ -0,0 +1,1056 @@
1
+ /* oxlint-disable require-await -- trail implementations satisfy async interface without awaiting */
2
+ import { describe, expect, test } from 'bun:test';
3
+ import { z } from 'zod';
4
+
5
+ import { NotFoundError, ValidationError } from '../errors';
6
+ import { clearTraceSink, registerTraceSink } from '../internal/tracing';
7
+ import type { TraceRecord, TraceSink } from '../internal/tracing';
8
+ import type { Layer } from '../layer';
9
+ import { Result } from '../result';
10
+ import { run } from '../run';
11
+ import { signal } from '../signal';
12
+ import { topo } from '../topo';
13
+ import { trail } from '../trail';
14
+ import type { Logger } from '../types';
15
+
16
+ const noopLogger: Logger = {
17
+ child() {
18
+ return noopLogger;
19
+ },
20
+ debug() {
21
+ // noop
22
+ },
23
+ error() {
24
+ // noop
25
+ },
26
+ fatal() {
27
+ // noop
28
+ },
29
+ info() {
30
+ // noop
31
+ },
32
+ trace() {
33
+ // noop
34
+ },
35
+ warn() {
36
+ // noop
37
+ },
38
+ };
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Fixtures
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const orderPlaced = signal('order.placed', {
45
+ payload: z.object({ orderId: z.string(), total: z.number() }),
46
+ });
47
+
48
+ // Mutable capture box for assertions. Each test resets it.
49
+ interface Capture {
50
+ invocations: { trailId: string; payload: unknown }[];
51
+ }
52
+ const createCapture = (): Capture => ({ invocations: [] });
53
+
54
+ const makeConsumer = (
55
+ id: string,
56
+ capture: Capture,
57
+ behavior: 'ok' | 'err' = 'ok'
58
+ ) =>
59
+ trail(id, {
60
+ blaze: (input) => {
61
+ capture.invocations.push({ payload: input, trailId: id });
62
+ if (behavior === 'err') {
63
+ return Result.err(new Error(`${id} failed`));
64
+ }
65
+ return Result.ok({ received: input });
66
+ },
67
+ input: z.object({ orderId: z.string(), total: z.number() }),
68
+ on: ['order.placed'],
69
+ });
70
+
71
+ const makeProducer = (fireResultKey: { result?: Result<void, Error> }) =>
72
+ trail('order.create', {
73
+ blaze: async (input, ctx) => {
74
+ const fired = await ctx.fire?.('order.placed', {
75
+ orderId: input.orderId,
76
+ total: input.total,
77
+ });
78
+ fireResultKey.result = fired;
79
+ return Result.ok({ ok: true });
80
+ },
81
+ fires: ['order.placed'],
82
+ input: z.object({ orderId: z.string(), total: z.number() }),
83
+ });
84
+
85
+ const READY = 'ready';
86
+
87
+ type Ready = typeof READY;
88
+ type ReadyGate = ReturnType<typeof Promise.withResolvers<Ready>>;
89
+
90
+ const createReadyGate = (): ReadyGate => Promise.withResolvers<Ready>();
91
+
92
+ const waitForReadyPair = async (
93
+ left: Promise<Ready>,
94
+ right: Promise<Ready>
95
+ ): Promise<'started'> => {
96
+ await left;
97
+ await right;
98
+ return 'started';
99
+ };
100
+
101
+ const observedConsumerIds = (capture: Capture): string[] =>
102
+ capture.invocations.map((entry) => entry.trailId).toSorted();
103
+
104
+ const cyclePayload = z.object({ id: z.string() });
105
+
106
+ const loopA = signal('loop.a', { payload: cyclePayload });
107
+ const loopB = signal('loop.b', { payload: cyclePayload });
108
+
109
+ interface CycleLogEvent {
110
+ fireStack?: unknown;
111
+ level: 'debug' | 'warn';
112
+ message: string;
113
+ signalId?: unknown;
114
+ }
115
+
116
+ const createCycleLogger = (events: CycleLogEvent[]): Logger => ({
117
+ child() {
118
+ return this;
119
+ },
120
+ debug(message, data) {
121
+ events.push({
122
+ fireStack: data?.fireStack,
123
+ level: 'debug',
124
+ message,
125
+ signalId: data?.signalId,
126
+ });
127
+ },
128
+ error() {
129
+ // noop
130
+ },
131
+ fatal() {
132
+ // noop
133
+ },
134
+ info() {
135
+ // noop
136
+ },
137
+ trace() {
138
+ // noop
139
+ },
140
+ warn(message, data) {
141
+ events.push({
142
+ fireStack: data?.fireStack,
143
+ level: 'warn',
144
+ message,
145
+ signalId: data?.signalId,
146
+ });
147
+ },
148
+ });
149
+
150
+ const cycleEventsAtLevel = (
151
+ events: readonly CycleLogEvent[],
152
+ level: CycleLogEvent['level']
153
+ ): CycleLogEvent[] => events.filter((event) => event.level === level);
154
+
155
+ const expectCycleSuppressionLogs = (
156
+ events: readonly CycleLogEvent[],
157
+ signalId: string,
158
+ fireStack: readonly string[]
159
+ ): void => {
160
+ expect(cycleEventsAtLevel(events, 'debug')).toEqual([
161
+ {
162
+ fireStack,
163
+ level: 'debug',
164
+ message: 'Signal fan-out suppressed due to cycle',
165
+ signalId,
166
+ },
167
+ ]);
168
+ expect(cycleEventsAtLevel(events, 'warn')).toEqual([
169
+ {
170
+ fireStack,
171
+ level: 'warn',
172
+ message: 'Signal cycle detected — skipping re-entrant fire',
173
+ signalId,
174
+ },
175
+ ]);
176
+ };
177
+
178
+ const expectNoCycleSuppressionDebugLogs = (
179
+ events: readonly CycleLogEvent[]
180
+ ): void => {
181
+ expect(cycleEventsAtLevel(events, 'debug')).toEqual([]);
182
+ };
183
+
184
+ const createWarningLogger = (
185
+ warnings: {
186
+ consumerId?: unknown;
187
+ message: string;
188
+ signalId?: unknown;
189
+ }[]
190
+ ): Logger => ({
191
+ child() {
192
+ return this;
193
+ },
194
+ debug() {
195
+ // noop
196
+ },
197
+ error() {
198
+ // noop
199
+ },
200
+ fatal() {
201
+ // noop
202
+ },
203
+ info() {
204
+ // noop
205
+ },
206
+ trace() {
207
+ // noop
208
+ },
209
+ warn(message, data) {
210
+ warnings.push({
211
+ consumerId: data?.consumerId,
212
+ message,
213
+ signalId: data?.signalId,
214
+ });
215
+ },
216
+ });
217
+
218
+ interface WarningEvent {
219
+ consumerId?: unknown;
220
+ message: string;
221
+ signalId?: unknown;
222
+ }
223
+
224
+ const createBlockingConsumer = (
225
+ id: string,
226
+ started: ReadyGate,
227
+ release: Promise<Ready>
228
+ ) =>
229
+ trail(id, {
230
+ blaze: async () => {
231
+ started.resolve(READY);
232
+ await release;
233
+ return Result.ok({ ok: true });
234
+ },
235
+ input: z.object({ orderId: z.string(), total: z.number() }),
236
+ on: ['order.placed'],
237
+ output: z.object({ ok: z.boolean() }),
238
+ });
239
+
240
+ const createIsolatedConsumer = (
241
+ id: string,
242
+ value: string,
243
+ started: ReadyGate,
244
+ release: Promise<Ready>,
245
+ seen: Map<string, string>
246
+ ) =>
247
+ trail(id, {
248
+ blaze: async (_input, ctx) => {
249
+ const extensions = ctx.extensions as Record<string, unknown>;
250
+ extensions.currentConsumer = value;
251
+ started.resolve(READY);
252
+ await release;
253
+ seen.set(id, extensions.currentConsumer as string);
254
+ return Result.ok({ consumer: extensions.currentConsumer });
255
+ },
256
+ input: z.object({ orderId: z.string(), total: z.number() }),
257
+ on: ['order.placed'],
258
+ output: z.object({ consumer: z.unknown() }),
259
+ });
260
+
261
+ const createCycleConsumer = (
262
+ id: string,
263
+ signalId: 'loop.a' | 'loop.b',
264
+ nextSignalId: 'loop.a' | 'loop.b',
265
+ invocations: string[]
266
+ ) =>
267
+ trail(id, {
268
+ blaze: async (input, ctx) => {
269
+ invocations.push(signalId === 'loop.a' ? 'a' : 'b');
270
+ const fired = await ctx.fire?.(nextSignalId, { id: input.id });
271
+ return fired as Result<unknown, Error>;
272
+ },
273
+ fires: [nextSignalId],
274
+ input: cyclePayload,
275
+ on: [signalId],
276
+ });
277
+
278
+ const createErrorIsolationScenario = () => {
279
+ const capture = createCapture();
280
+ const warnings: WarningEvent[] = [];
281
+ const fireBox: { result?: Result<void, Error> } = {};
282
+ const app = topo('fire-err', {
283
+ failingConsumer: makeConsumer('notify.broken', capture, 'err'),
284
+ healthyConsumer: makeConsumer('notify.email', capture),
285
+ orderPlaced,
286
+ producer: makeProducer(fireBox),
287
+ });
288
+
289
+ return {
290
+ app,
291
+ capture,
292
+ fireBox,
293
+ warnings,
294
+ };
295
+ };
296
+
297
+ const expectConsumerFailureWarning = (warnings: WarningEvent[]): void => {
298
+ expect(warnings).toEqual([
299
+ {
300
+ consumerId: 'notify.broken',
301
+ message: 'Signal consumer failed',
302
+ signalId: 'order.placed',
303
+ },
304
+ ]);
305
+ };
306
+
307
+ const createParallelStartScenario = () => {
308
+ const leftStarted = createReadyGate();
309
+ const rightStarted = createReadyGate();
310
+ const release = createReadyGate();
311
+ const app = topo('fire-parallel-start', {
312
+ consumerA: createBlockingConsumer(
313
+ 'notify.email',
314
+ leftStarted,
315
+ release.promise
316
+ ),
317
+ consumerB: createBlockingConsumer(
318
+ 'notify.slack',
319
+ rightStarted,
320
+ release.promise
321
+ ),
322
+ orderPlaced,
323
+ producer: makeProducer({}),
324
+ });
325
+
326
+ return {
327
+ app,
328
+ leftStarted,
329
+ release,
330
+ rightStarted,
331
+ };
332
+ };
333
+
334
+ const waitForConsumersToStart = async (
335
+ left: ReadyGate,
336
+ right: ReadyGate
337
+ ): Promise<'started'> =>
338
+ // Sequential fan-out would deadlock here: consumerA resolves `left` then
339
+ // blocks on `release`, which only resolves after both consumers have
340
+ // started. The test runner's own timeout catches the regression without
341
+ // needing a wall-clock timer that flakes under CI load.
342
+ await waitForReadyPair(left.promise, right.promise);
343
+
344
+ const createContextIsolationScenario = () => {
345
+ const leftStarted = createReadyGate();
346
+ const rightStarted = createReadyGate();
347
+ const release = createReadyGate();
348
+ const seen = new Map<string, string>();
349
+ const app = topo('fire-context-isolation', {
350
+ consumerA: createIsolatedConsumer(
351
+ 'notify.email',
352
+ 'email',
353
+ leftStarted,
354
+ release.promise,
355
+ seen
356
+ ),
357
+ consumerB: createIsolatedConsumer(
358
+ 'notify.slack',
359
+ 'slack',
360
+ rightStarted,
361
+ release.promise,
362
+ seen
363
+ ),
364
+ orderPlaced,
365
+ producer: makeProducer({}),
366
+ });
367
+
368
+ return {
369
+ app,
370
+ leftStarted,
371
+ release,
372
+ rightStarted,
373
+ seen,
374
+ };
375
+ };
376
+
377
+ const createCycleScenario = (invocations: string[]) =>
378
+ topo('fire-cycle', {
379
+ consumerA: createCycleConsumer(
380
+ 'loop.consumer.a',
381
+ 'loop.a',
382
+ 'loop.b',
383
+ invocations
384
+ ),
385
+ consumerB: createCycleConsumer(
386
+ 'loop.consumer.b',
387
+ 'loop.b',
388
+ 'loop.a',
389
+ invocations
390
+ ),
391
+ loopA,
392
+ loopB,
393
+ producer: trail('loop.producer', {
394
+ blaze: async (input, ctx) =>
395
+ (await ctx.fire?.('loop.a', { id: input.id })) as Result<
396
+ unknown,
397
+ Error
398
+ >,
399
+ fires: ['loop.a'],
400
+ input: cyclePayload,
401
+ }),
402
+ });
403
+
404
+ const createDepthChainScenario = (chainLength: number) => {
405
+ const chainPayload = z.object({ n: z.number() });
406
+ const signals = Array.from({ length: chainLength }, (_, i) =>
407
+ signal(`chain.${i}`, { payload: chainPayload })
408
+ );
409
+ const [firstSignal] = signals;
410
+ const consumers = Object.fromEntries(
411
+ signals.slice(0, -1).map((sig, i) => [
412
+ `consumer${i}`,
413
+ trail(`chain.consumer.${i}`, {
414
+ blaze: async (_input, ctx) => {
415
+ const next = signals[i + 1];
416
+ await ctx.fire?.(next?.id ?? '', { n: i + 1 });
417
+ return Result.ok({ step: i });
418
+ },
419
+ fires: [signals[i + 1]?.id ?? ''],
420
+ input: chainPayload,
421
+ on: [sig.id],
422
+ }),
423
+ ])
424
+ );
425
+ const signalEntries = Object.fromEntries(
426
+ signals.map((s) => [s.id.replace('.', '_'), s])
427
+ );
428
+ return {
429
+ app: topo('fire-depth', {
430
+ ...consumers,
431
+ ...signalEntries,
432
+ producer: trail('chain.start', {
433
+ blaze: async (input: { n: number }, ctx) => {
434
+ await ctx.fire?.(firstSignal?.id ?? '', input);
435
+ return Result.ok({ started: true });
436
+ },
437
+ fires: [firstSignal?.id ?? ''],
438
+ input: chainPayload,
439
+ }),
440
+ }),
441
+ };
442
+ };
443
+
444
+ const createCapturingSink = (records: TraceRecord[]): TraceSink => ({
445
+ write(record) {
446
+ records.push(record);
447
+ },
448
+ });
449
+
450
+ const createTraceShapeScenario = () => {
451
+ const leftStarted = createReadyGate();
452
+ const rightStarted = createReadyGate();
453
+ const release = createReadyGate();
454
+ const app = topo('fire-trace-shape', {
455
+ consumerA: createBlockingConsumer(
456
+ 'notify.email',
457
+ leftStarted,
458
+ release.promise
459
+ ),
460
+ consumerB: createBlockingConsumer(
461
+ 'notify.slack',
462
+ rightStarted,
463
+ release.promise
464
+ ),
465
+ orderPlaced,
466
+ producer: makeProducer({}),
467
+ });
468
+ return { app, leftStarted, release, rightStarted };
469
+ };
470
+
471
+ const runTraceShapeScenario = async (
472
+ scenario: ReturnType<typeof createTraceShapeScenario>
473
+ ) => {
474
+ const runPromise = run(scenario.app, 'order.create', {
475
+ orderId: 'o-trace',
476
+ total: 7,
477
+ });
478
+ // Both consumers must enter before either completes — this proves parallel
479
+ // fan-out. Sequential fan-out would deadlock here.
480
+ await waitForReadyPair(
481
+ scenario.leftStarted.promise,
482
+ scenario.rightStarted.promise
483
+ );
484
+ scenario.release.resolve(READY);
485
+ return await runPromise;
486
+ };
487
+
488
+ const findTrailRecord = (
489
+ records: readonly TraceRecord[],
490
+ trailId: string
491
+ ): TraceRecord => {
492
+ const record = records.find(
493
+ (entry) => entry.kind === 'trail' && entry.trailId === trailId
494
+ );
495
+ if (!record) {
496
+ throw new Error(`Expected trail trace record for "${trailId}"`);
497
+ }
498
+ return record;
499
+ };
500
+
501
+ const expectSiblingParentage = (
502
+ producer: TraceRecord,
503
+ left: TraceRecord,
504
+ right: TraceRecord
505
+ ): void => {
506
+ expect(left.parentId).toBe(producer.id);
507
+ expect(right.parentId).toBe(producer.id);
508
+ expect(left.traceId).toBe(producer.traceId);
509
+ expect(right.traceId).toBe(producer.traceId);
510
+ expect(left.rootId).toBe(producer.rootId);
511
+ expect(right.rootId).toBe(producer.rootId);
512
+ };
513
+
514
+ const expectSiblingIdentity = (
515
+ producer: TraceRecord,
516
+ left: TraceRecord,
517
+ right: TraceRecord
518
+ ): void => {
519
+ expect(left.id).not.toBe(right.id);
520
+ expect(left.id).not.toBe(producer.id);
521
+ expect(right.id).not.toBe(producer.id);
522
+ expect(left.status).toBe('ok');
523
+ expect(right.status).toBe('ok');
524
+ };
525
+
526
+ const expectSiblingOverlap = (left: TraceRecord, right: TraceRecord): void => {
527
+ const leftEnd = left.endedAt;
528
+ const rightEnd = right.endedAt;
529
+ if (leftEnd === undefined || rightEnd === undefined) {
530
+ throw new Error('Expected both sibling records to have completed timings');
531
+ }
532
+ // Overlap means each span ended after the other started — i.e. they were
533
+ // both in-flight at some point. Sequential execution would fail this.
534
+ expect(leftEnd).toBeGreaterThanOrEqual(right.startedAt);
535
+ expect(rightEnd).toBeGreaterThanOrEqual(left.startedAt);
536
+ };
537
+
538
+ const expectSiblingTraceShape = (
539
+ records: readonly TraceRecord[],
540
+ ids: {
541
+ producerTrailId: string;
542
+ leftTrailId: string;
543
+ rightTrailId: string;
544
+ }
545
+ ): void => {
546
+ const producer = findTrailRecord(records, ids.producerTrailId);
547
+ const left = findTrailRecord(records, ids.leftTrailId);
548
+ const right = findTrailRecord(records, ids.rightTrailId);
549
+ expectSiblingParentage(producer, left, right);
550
+ expectSiblingIdentity(producer, left, right);
551
+ expectSiblingOverlap(left, right);
552
+ };
553
+
554
+ const createConsumerCrossScenario = () => {
555
+ const crossTarget = trail('notify.audit', {
556
+ blaze: () => Result.ok({ audited: true }),
557
+ input: z.object({ orderId: z.string() }),
558
+ output: z.object({ audited: z.boolean() }),
559
+ });
560
+ const consumer = trail('notify.email', {
561
+ blaze: async (input, ctx) => {
562
+ const crossFn = ctx.cross as NonNullable<typeof ctx.cross>;
563
+ return (await crossFn(crossTarget, {
564
+ orderId: input.orderId,
565
+ })) as Result<unknown, Error>;
566
+ },
567
+ crosses: [crossTarget],
568
+ input: z.object({ orderId: z.string(), total: z.number() }),
569
+ on: ['order.placed'],
570
+ });
571
+ const fireBox: { result?: Result<void, Error> } = {};
572
+ return topo('fire-consumer-cross-attribution', {
573
+ consumer,
574
+ crossTarget,
575
+ orderPlaced,
576
+ producer: makeProducer(fireBox),
577
+ });
578
+ };
579
+
580
+ const expectConsumerCrossAttribution = (
581
+ records: readonly TraceRecord[]
582
+ ): void => {
583
+ const producerRecord = findTrailRecord(records, 'order.create');
584
+ const consumerRecord = findTrailRecord(records, 'notify.email');
585
+ const crossRecord = findTrailRecord(records, 'notify.audit');
586
+ // The consumer span parents the crossed call — NOT the producer.
587
+ // Before the forkCtx fix, the consumer inherited the producer's
588
+ // `cross` closure, which attributed the crossed span to the
589
+ // producer's scope.
590
+ expect(crossRecord.parentId).toBe(consumerRecord.id);
591
+ expect(crossRecord.parentId).not.toBe(producerRecord.id);
592
+ expect(crossRecord.traceId).toBe(producerRecord.traceId);
593
+ };
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Tests
597
+ // ---------------------------------------------------------------------------
598
+
599
+ describe('fire', () => {
600
+ describe('fan-out', () => {
601
+ test('invokes every consumer with validated payload', async () => {
602
+ const capture = createCapture();
603
+ const fireBox: { result?: Result<void, Error> } = {};
604
+ const app = topo('fire-test', {
605
+ consumerA: makeConsumer('notify.email', capture),
606
+ consumerB: makeConsumer('notify.slack', capture),
607
+ orderPlaced,
608
+ producer: makeProducer(fireBox),
609
+ });
610
+ const result = await run(app, 'order.create', {
611
+ orderId: 'o-1',
612
+ total: 42,
613
+ });
614
+ expect(result.isOk()).toBe(true);
615
+ expect(fireBox.result?.isOk()).toBe(true);
616
+ expect(capture.invocations).toHaveLength(2);
617
+ expect(capture.invocations.map((i) => i.trailId).toSorted()).toEqual([
618
+ 'notify.email',
619
+ 'notify.slack',
620
+ ]);
621
+ expect(capture.invocations[0]?.payload).toEqual({
622
+ orderId: 'o-1',
623
+ total: 42,
624
+ });
625
+ expect(capture.invocations[1]?.payload).toEqual({
626
+ orderId: 'o-1',
627
+ total: 42,
628
+ });
629
+ });
630
+
631
+ test('no-consumer fan-out returns Result.ok', async () => {
632
+ const fireBox: { result?: Result<void, Error> } = {};
633
+ const producer = makeProducer(fireBox);
634
+ const app = topo('fire-empty', { orderPlaced, producer });
635
+
636
+ const result = await run(app, 'order.create', {
637
+ orderId: 'o-2',
638
+ total: 0,
639
+ });
640
+
641
+ expect(result.isOk()).toBe(true);
642
+ expect(fireBox.result?.isOk()).toBe(true);
643
+ });
644
+ });
645
+
646
+ describe('validation', () => {
647
+ test('unknown signal id returns Result.err(NotFoundError)', async () => {
648
+ const badProducer = trail('bad.producer', {
649
+ blaze: async (_input, ctx) => {
650
+ const fired = await ctx.fire?.('ghost.signal', {});
651
+ return fired as Result<unknown, Error>;
652
+ },
653
+ input: z.object({}),
654
+ });
655
+
656
+ const app = topo('fire-unknown', { badProducer });
657
+ const result = await run(app, 'bad.producer', {});
658
+
659
+ expect(result.isErr()).toBe(true);
660
+ expect(result.error).toBeInstanceOf(NotFoundError);
661
+ });
662
+
663
+ test('bad payload returns Result.err(ValidationError) and skips consumers', async () => {
664
+ const capture = createCapture();
665
+ const consumer = makeConsumer('notify.email', capture);
666
+
667
+ const badProducer = trail('bad.payload', {
668
+ blaze: async (_input, ctx) => {
669
+ const fired = await ctx.fire?.('order.placed', { orderId: 123 });
670
+ return fired as Result<unknown, Error>;
671
+ },
672
+ input: z.object({}),
673
+ });
674
+
675
+ const app = topo('fire-bad-payload', {
676
+ badProducer,
677
+ consumer,
678
+ orderPlaced,
679
+ });
680
+ const result = await run(app, 'bad.payload', {});
681
+
682
+ expect(result.isErr()).toBe(true);
683
+ expect(result.error).toBeInstanceOf(ValidationError);
684
+ expect(capture.invocations).toHaveLength(0);
685
+ });
686
+ });
687
+
688
+ describe('error isolation', () => {
689
+ test('consumer error does not fail successful siblings or the producer', async () => {
690
+ const scenario = createErrorIsolationScenario();
691
+ const result = await run(
692
+ scenario.app,
693
+ 'order.create',
694
+ {
695
+ orderId: 'o-3',
696
+ total: 1,
697
+ },
698
+ {
699
+ ctx: { logger: createWarningLogger(scenario.warnings) },
700
+ }
701
+ );
702
+
703
+ expect(result.isOk()).toBe(true);
704
+ expect(scenario.fireBox.result?.isOk()).toBe(true);
705
+ expect(observedConsumerIds(scenario.capture)).toEqual([
706
+ 'notify.broken',
707
+ 'notify.email',
708
+ ]);
709
+ expectConsumerFailureWarning(scenario.warnings);
710
+ });
711
+
712
+ test('starts sibling consumers before the first one settles', async () => {
713
+ const scenario = createParallelStartScenario();
714
+ const runPromise = run(scenario.app, 'order.create', {
715
+ orderId: 'o-parallel',
716
+ total: 1,
717
+ });
718
+ const started = await waitForConsumersToStart(
719
+ scenario.leftStarted,
720
+ scenario.rightStarted
721
+ );
722
+
723
+ scenario.release.resolve(READY);
724
+ const result = await runPromise;
725
+
726
+ expect(result.isOk()).toBe(true);
727
+ expect(started).toBe('started');
728
+ });
729
+
730
+ test('gives each consumer its own derived top-level context', async () => {
731
+ const scenario = createContextIsolationScenario();
732
+ const runPromise = run(
733
+ scenario.app,
734
+ 'order.create',
735
+ { orderId: 'o-isolated', total: 1 },
736
+ { ctx: { extensions: { source: 'producer' } } }
737
+ );
738
+
739
+ await waitForReadyPair(
740
+ scenario.leftStarted.promise,
741
+ scenario.rightStarted.promise
742
+ );
743
+ scenario.release.resolve(READY);
744
+ const result = await runPromise;
745
+
746
+ expect(result.isOk()).toBe(true);
747
+ expect(scenario.seen.get('notify.email')).toBe('email');
748
+ expect(scenario.seen.get('notify.slack')).toBe('slack');
749
+ });
750
+ });
751
+
752
+ describe('context binding', () => {
753
+ test('ctx.fire is undefined when executeTrail is called without a topo', async () => {
754
+ const { executeTrail } = await import('../execute');
755
+ const standalone = trail('standalone', {
756
+ blaze: async (_input, ctx) =>
757
+ Result.ok({ hasFire: ctx.fire !== undefined }),
758
+ input: z.object({}),
759
+ });
760
+
761
+ const result = await executeTrail(standalone, {});
762
+ expect(result.isOk()).toBe(true);
763
+ expect((result.unwrap() as { hasFire: boolean }).hasFire).toBe(false);
764
+ });
765
+ });
766
+
767
+ describe('signal-value overload', () => {
768
+ test('ctx.fire(signal, payload) accepts a signal value and fans out', async () => {
769
+ const capture = createCapture();
770
+ const consumerA = makeConsumer('notify.email', capture);
771
+ const valueProducer = trail('order.create-by-value', {
772
+ blaze: async (input, ctx) => {
773
+ const fired = await ctx.fire?.(orderPlaced, {
774
+ orderId: input.orderId,
775
+ total: input.total,
776
+ });
777
+ return fired as Result<unknown, Error>;
778
+ },
779
+ fires: ['order.placed'],
780
+ input: z.object({ orderId: z.string(), total: z.number() }),
781
+ });
782
+ const app = topo('fire-signal-value', {
783
+ consumerA,
784
+ orderPlaced,
785
+ valueProducer,
786
+ });
787
+ const result = await run(app, 'order.create-by-value', {
788
+ orderId: 'o-value',
789
+ total: 99,
790
+ });
791
+ expect(result.isOk()).toBe(true);
792
+ expect(capture.invocations).toHaveLength(1);
793
+ expect(capture.invocations[0]?.payload).toEqual({
794
+ orderId: 'o-value',
795
+ total: 99,
796
+ });
797
+ });
798
+ });
799
+
800
+ describe('option forwarding', () => {
801
+ test('consumer inherits producer layers applied via options.layers', async () => {
802
+ const layerCalls: string[] = [];
803
+ const tagging: Layer = {
804
+ name: 'tag',
805
+ wrap: (_trail, implementation) => (input, ctx) => {
806
+ layerCalls.push('wrap');
807
+ return implementation(input, ctx);
808
+ },
809
+ };
810
+
811
+ const consumer = trail('layered.consumer', {
812
+ blaze: () => Result.ok({ ok: true }),
813
+ input: z.object({ orderId: z.string(), total: z.number() }),
814
+ on: ['order.placed'],
815
+ });
816
+ const fireBox: { result?: Result<void, Error> } = {};
817
+ const producer = makeProducer(fireBox);
818
+ const app = topo('fire-layer-forward', {
819
+ consumer,
820
+ orderPlaced,
821
+ producer,
822
+ });
823
+
824
+ const result = await run(
825
+ app,
826
+ 'order.create',
827
+ { orderId: 'o-layer', total: 1 },
828
+ { layers: [tagging] }
829
+ );
830
+
831
+ expect(result.isOk()).toBe(true);
832
+ // Layer runs once for producer and once for consumer when forwarded.
833
+ expect(layerCalls.length).toBe(2);
834
+ });
835
+ });
836
+
837
+ describe('cycle detection', () => {
838
+ test('skips re-entrant signal cycles and logs suppression details', async () => {
839
+ const events: CycleLogEvent[] = [];
840
+ const invocations: string[] = [];
841
+ const cycleLogger = createCycleLogger(events);
842
+ const app = createCycleScenario(invocations);
843
+
844
+ const result = await run(
845
+ app,
846
+ 'loop.producer',
847
+ { id: 'loop-1' },
848
+ { ctx: { logger: cycleLogger } }
849
+ );
850
+
851
+ expect(result.isOk()).toBe(true);
852
+ expect(invocations).toEqual(['a', 'b']);
853
+ expectCycleSuppressionLogs(events, 'loop.a', ['loop.a', 'loop.b']);
854
+ });
855
+
856
+ test('does not emit suppression debug logs for ordinary fan-out', async () => {
857
+ const events: CycleLogEvent[] = [];
858
+ const app = topo('fire-no-cycle-debug', {
859
+ consumer: makeConsumer('notify.email', createCapture()),
860
+ orderPlaced,
861
+ producer: makeProducer({}),
862
+ });
863
+
864
+ const result = await run(
865
+ app,
866
+ 'order.create',
867
+ { orderId: 'o-no-cycle', total: 1 },
868
+ { ctx: { logger: createCycleLogger(events) } }
869
+ );
870
+
871
+ expect(result.isOk()).toBe(true);
872
+ expectNoCycleSuppressionDebugLogs(events);
873
+ expect(cycleEventsAtLevel(events, 'warn')).toEqual([]);
874
+ });
875
+
876
+ test('stops at max depth for distinct-signal chains', async () => {
877
+ const events: CycleLogEvent[] = [];
878
+ const logger = createCycleLogger(events);
879
+ const { app } = createDepthChainScenario(20);
880
+
881
+ const result = await run(
882
+ app,
883
+ 'chain.start',
884
+ { n: 0 },
885
+ { ctx: { logger } }
886
+ );
887
+
888
+ expect(result.isOk()).toBe(true);
889
+ const depthWarning = cycleEventsAtLevel(events, 'warn').find((event) =>
890
+ event.message.includes('depth limit')
891
+ );
892
+ expect(depthWarning).toBeDefined();
893
+ });
894
+ });
895
+
896
+ describe('trace shape', () => {
897
+ test('sibling consumers emit overlapping trace records parented to the producer', async () => {
898
+ const records: TraceRecord[] = [];
899
+ registerTraceSink(createCapturingSink(records));
900
+
901
+ try {
902
+ const scenario = createTraceShapeScenario();
903
+ const result = await runTraceShapeScenario(scenario);
904
+ expect(result.isOk()).toBe(true);
905
+ expectSiblingTraceShape(records, {
906
+ leftTrailId: 'notify.email',
907
+ producerTrailId: 'order.create',
908
+ rightTrailId: 'notify.slack',
909
+ });
910
+ } finally {
911
+ clearTraceSink();
912
+ }
913
+ });
914
+ });
915
+
916
+ describe('per-consumer cross binding', () => {
917
+ test('cross calls from a consumer are attributed to the consumer span', async () => {
918
+ const records: TraceRecord[] = [];
919
+ registerTraceSink(createCapturingSink(records));
920
+
921
+ try {
922
+ const app = createConsumerCrossScenario();
923
+ const result = await run(app, 'order.create', {
924
+ orderId: 'o-cross-attr',
925
+ total: 4,
926
+ });
927
+ expect(result.isOk()).toBe(true);
928
+ expectConsumerCrossAttribution(records);
929
+ } finally {
930
+ clearTraceSink();
931
+ }
932
+ });
933
+ });
934
+
935
+ describe('producer context inheritance', () => {
936
+ test('consumer inherits producer logger and requestId', async () => {
937
+ const captured: { requestId: string; loggerExists: boolean }[] = [];
938
+ const consumer = trail('inherit.consumer', {
939
+ blaze: (_input, ctx) => {
940
+ captured.push({
941
+ loggerExists: ctx.logger !== undefined,
942
+ requestId: ctx.requestId,
943
+ });
944
+ return Result.ok({ ok: true });
945
+ },
946
+ input: z.object({ orderId: z.string(), total: z.number() }),
947
+ on: ['order.placed'],
948
+ });
949
+ const fireBox: { result?: Result<void, Error> } = {};
950
+ const app = topo('fire-inherit', {
951
+ consumer,
952
+ orderPlaced,
953
+ producer: makeProducer(fireBox),
954
+ });
955
+ const result = await run(
956
+ app,
957
+ 'order.create',
958
+ { orderId: 'o-inherit', total: 1 },
959
+ { ctx: { logger: noopLogger, requestId: 'producer-request-id' } }
960
+ );
961
+ expect(result.isOk()).toBe(true);
962
+ expect(captured).toHaveLength(1);
963
+ expect(captured[0]?.loggerExists).toBe(true);
964
+ expect(captured[0]?.requestId).toBe('producer-request-id');
965
+ });
966
+
967
+ test('consumer inherits layer-mutated producer requestId', async () => {
968
+ const captured: string[] = [];
969
+ const consumer = trail('inherit.layered.consumer', {
970
+ blaze: (_input, ctx) => {
971
+ captured.push(ctx.requestId);
972
+ return Result.ok({ ok: true });
973
+ },
974
+ input: z.object({ orderId: z.string(), total: z.number() }),
975
+ on: ['order.placed'],
976
+ });
977
+ const requestIdLayer: Layer = {
978
+ name: 'request-id-layer',
979
+ wrap: (_trail, implementation) => (input, ctx) =>
980
+ implementation(input, {
981
+ ...ctx,
982
+ requestId: 'layer-request-id',
983
+ }),
984
+ };
985
+ const fireBox: { result?: Result<void, Error> } = {};
986
+ const app = topo('fire-inherit-layered', {
987
+ consumer,
988
+ orderPlaced,
989
+ producer: makeProducer(fireBox),
990
+ });
991
+
992
+ const result = await run(
993
+ app,
994
+ 'order.create',
995
+ { orderId: 'o-layered-inherit', total: 1 },
996
+ {
997
+ ctx: { requestId: 'producer-request-id' },
998
+ layers: [requestIdLayer],
999
+ }
1000
+ );
1001
+
1002
+ expect(result.isOk()).toBe(true);
1003
+ expect(captured).toEqual(['layer-request-id']);
1004
+ });
1005
+
1006
+ test('extracted fire inherits layer-mutated producer requestId', async () => {
1007
+ const captured: string[] = [];
1008
+ const consumer = trail('inherit.extracted.consumer', {
1009
+ blaze: (_input, ctx) => {
1010
+ captured.push(ctx.requestId);
1011
+ return Result.ok({ ok: true });
1012
+ },
1013
+ input: z.object({ orderId: z.string(), total: z.number() }),
1014
+ on: ['order.placed'],
1015
+ });
1016
+ const producer = trail('inherit.extracted.producer', {
1017
+ blaze: async (input, ctx) => {
1018
+ const { fire } = ctx;
1019
+ const fired = await fire?.('order.placed', {
1020
+ orderId: input.orderId,
1021
+ total: input.total,
1022
+ });
1023
+ return fired as Result<unknown, Error>;
1024
+ },
1025
+ fires: ['order.placed'],
1026
+ input: z.object({ orderId: z.string(), total: z.number() }),
1027
+ });
1028
+ const requestIdLayer: Layer = {
1029
+ name: 'request-id-layer',
1030
+ wrap: (_trail, implementation) => (input, ctx) =>
1031
+ implementation(input, {
1032
+ ...ctx,
1033
+ requestId: 'layer-request-id',
1034
+ }),
1035
+ };
1036
+ const app = topo('fire-inherit-extracted', {
1037
+ consumer,
1038
+ orderPlaced,
1039
+ producer,
1040
+ });
1041
+
1042
+ const result = await run(
1043
+ app,
1044
+ 'inherit.extracted.producer',
1045
+ { orderId: 'o-layered-inherit', total: 1 },
1046
+ {
1047
+ ctx: { requestId: 'producer-request-id' },
1048
+ layers: [requestIdLayer],
1049
+ }
1050
+ );
1051
+
1052
+ expect(result.isOk()).toBe(true);
1053
+ expect(captured).toEqual(['layer-request-id']);
1054
+ });
1055
+ });
1056
+ });