@ontrails/http 1.0.0-beta.14 → 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.
@@ -5,15 +5,16 @@ import {
5
5
  NotFoundError,
6
6
  Result,
7
7
  TRAILHEAD_KEY,
8
- provision,
8
+ resource,
9
+ signal,
9
10
  ValidationError,
10
11
  trail,
11
12
  topo,
12
13
  } from '@ontrails/core';
13
- import type { Gate } from '@ontrails/core';
14
+ import type { Layer, TrailContext } from '@ontrails/core';
14
15
  import { z } from 'zod';
15
16
 
16
- import { buildHttpRoutes } from '../build.js';
17
+ import { deriveHttpRoutes } from '../build.js';
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Test trails
@@ -54,29 +55,40 @@ const internalTrail = trail('crash', {
54
55
  input: z.object({}),
55
56
  });
56
57
 
57
- const internalMetaTrail = trail('secret', {
58
+ const internalVisibilityTrail = trail('secret', {
58
59
  blaze: () => Result.ok({ ok: true }),
59
60
  description: 'Internal trail that should be skipped',
60
61
  input: z.object({}),
61
- meta: { internal: true },
62
+ visibility: 'internal',
62
63
  });
63
64
 
64
- const dbProvision = provision('db.main', {
65
+ const dbResource = resource('db.main', {
65
66
  create: () =>
66
67
  Result.ok({
67
68
  source: 'factory',
68
69
  }),
69
70
  });
70
71
 
72
+ const orderPlaced = signal('order.placed', {
73
+ payload: z.object({ orderId: z.string() }),
74
+ });
75
+
76
+ const requireFire = (fire: TrailContext['fire']) => {
77
+ if (!fire) {
78
+ throw new Error('Expected ctx.fire to be bound');
79
+ }
80
+ return fire;
81
+ };
82
+
71
83
  // ---------------------------------------------------------------------------
72
84
  // Tests
73
85
  // ---------------------------------------------------------------------------
74
86
 
75
- describe('buildHttpRoutes', () => {
87
+ describe('deriveHttpRoutes', () => {
76
88
  describe('method derivation', () => {
77
89
  test('intent: read maps to GET', () => {
78
90
  const app = topo('testapp', { echoTrail });
79
- const result = buildHttpRoutes(app);
91
+ const result = deriveHttpRoutes(app);
80
92
 
81
93
  expect(result.isOk()).toBe(true);
82
94
  const routes = result.value;
@@ -86,7 +98,7 @@ describe('buildHttpRoutes', () => {
86
98
 
87
99
  test('intent: destroy maps to DELETE', () => {
88
100
  const app = topo('testapp', { deleteTrail });
89
- const result = buildHttpRoutes(app);
101
+ const result = deriveHttpRoutes(app);
90
102
 
91
103
  expect(result.isOk()).toBe(true);
92
104
  const routes = result.value;
@@ -96,7 +108,7 @@ describe('buildHttpRoutes', () => {
96
108
 
97
109
  test('default intent (write) maps to POST', () => {
98
110
  const app = topo('testapp', { createTrail });
99
- const result = buildHttpRoutes(app);
111
+ const result = deriveHttpRoutes(app);
100
112
 
101
113
  expect(result.isOk()).toBe(true);
102
114
  const routes = result.value;
@@ -108,7 +120,7 @@ describe('buildHttpRoutes', () => {
108
120
  describe('path derivation', () => {
109
121
  test('dotted ID becomes slashed path', () => {
110
122
  const app = topo('testapp', { createTrail });
111
- const result = buildHttpRoutes(app);
123
+ const result = deriveHttpRoutes(app);
112
124
 
113
125
  expect(result.isOk()).toBe(true);
114
126
  expect(result.value[0]?.path).toBe('/item/create');
@@ -116,7 +128,7 @@ describe('buildHttpRoutes', () => {
116
128
 
117
129
  test('simple ID becomes /id', () => {
118
130
  const app = topo('testapp', { echoTrail });
119
- const result = buildHttpRoutes(app);
131
+ const result = deriveHttpRoutes(app);
120
132
 
121
133
  expect(result.isOk()).toBe(true);
122
134
  expect(result.value[0]?.path).toBe('/echo');
@@ -124,7 +136,7 @@ describe('buildHttpRoutes', () => {
124
136
 
125
137
  test('basePath is prepended', () => {
126
138
  const app = topo('testapp', { echoTrail });
127
- const result = buildHttpRoutes(app, { basePath: '/api/v1' });
139
+ const result = deriveHttpRoutes(app, { basePath: '/api/v1' });
128
140
 
129
141
  expect(result.isOk()).toBe(true);
130
142
  expect(result.value[0]?.path).toBe('/api/v1/echo');
@@ -132,7 +144,7 @@ describe('buildHttpRoutes', () => {
132
144
 
133
145
  test('basePath trailing slash is normalized', () => {
134
146
  const app = topo('testapp', { echoTrail });
135
- const result = buildHttpRoutes(app, { basePath: '/api/v1/' });
147
+ const result = deriveHttpRoutes(app, { basePath: '/api/v1/' });
136
148
 
137
149
  expect(result.isOk()).toBe(true);
138
150
  expect(result.value[0]?.path).toBe('/api/v1/echo');
@@ -142,7 +154,7 @@ describe('buildHttpRoutes', () => {
142
154
  describe('input source derivation', () => {
143
155
  test('GET routes use query input source', () => {
144
156
  const app = topo('testapp', { echoTrail });
145
- const result = buildHttpRoutes(app);
157
+ const result = deriveHttpRoutes(app);
146
158
 
147
159
  expect(result.isOk()).toBe(true);
148
160
  expect(result.value[0]?.inputSource).toBe('query');
@@ -150,7 +162,7 @@ describe('buildHttpRoutes', () => {
150
162
 
151
163
  test('POST routes use body input source', () => {
152
164
  const app = topo('testapp', { createTrail });
153
- const result = buildHttpRoutes(app);
165
+ const result = deriveHttpRoutes(app);
154
166
 
155
167
  expect(result.isOk()).toBe(true);
156
168
  expect(result.value[0]?.inputSource).toBe('body');
@@ -158,7 +170,7 @@ describe('buildHttpRoutes', () => {
158
170
 
159
171
  test('DELETE routes use body input source', () => {
160
172
  const app = topo('testapp', { deleteTrail });
161
- const result = buildHttpRoutes(app);
173
+ const result = deriveHttpRoutes(app);
162
174
 
163
175
  expect(result.isOk()).toBe(true);
164
176
  expect(result.value[0]?.inputSource).toBe('body');
@@ -167,8 +179,77 @@ describe('buildHttpRoutes', () => {
167
179
 
168
180
  describe('filtering', () => {
169
181
  test('internal trails are skipped', () => {
170
- const app = topo('testapp', { echoTrail, internalMetaTrail });
171
- const result = buildHttpRoutes(app);
182
+ const app = topo('testapp', { echoTrail, internalVisibilityTrail });
183
+ const result = deriveHttpRoutes(app);
184
+
185
+ expect(result.isOk()).toBe(true);
186
+ const routes = result.value;
187
+ expect(routes).toHaveLength(1);
188
+ expect(routes[0]?.trailId).toBe('echo');
189
+ });
190
+
191
+ test('exact include can expose an internal trail', () => {
192
+ const app = topo('testapp', { echoTrail, internalVisibilityTrail });
193
+ const result = deriveHttpRoutes(app, { include: ['secret'] });
194
+
195
+ expect(result.isOk()).toBe(true);
196
+ const routes = result.value;
197
+ expect(routes).toHaveLength(1);
198
+ expect(routes[0]?.trailId).toBe('secret');
199
+ });
200
+
201
+ test('wildcard include does not expose internal trails', () => {
202
+ const app = topo('testapp', { echoTrail, internalVisibilityTrail });
203
+ const result = deriveHttpRoutes(app, { include: ['**'] });
204
+
205
+ expect(result.isOk()).toBe(true);
206
+ const routes = result.value;
207
+ expect(routes).toHaveLength(1);
208
+ expect(routes[0]?.trailId).toBe('echo');
209
+ });
210
+
211
+ test('exclude patterns win before include narrowing', () => {
212
+ const app = topo('testapp', { deleteTrail, echoTrail });
213
+ const result = deriveHttpRoutes(app, {
214
+ exclude: ['item.**'],
215
+ include: ['echo', 'item.delete'],
216
+ });
217
+
218
+ expect(result.isOk()).toBe(true);
219
+ expect(result.value.map((route) => route.trailId)).toEqual(['echo']);
220
+ });
221
+
222
+ test('intent filters narrow the route table', () => {
223
+ const app = topo('testapp', { deleteTrail, echoTrail });
224
+ const result = deriveHttpRoutes(app, { intent: ['read'] });
225
+
226
+ expect(result.isOk()).toBe(true);
227
+ expect(result.value.map((route) => route.trailId)).toEqual(['echo']);
228
+ });
229
+
230
+ test('intent filters compose with include patterns using AND logic', () => {
231
+ const app = topo('testapp', { deleteTrail, echoTrail });
232
+ const result = deriveHttpRoutes(app, {
233
+ include: ['item.*', 'echo'],
234
+ intent: ['destroy'],
235
+ });
236
+
237
+ expect(result.isOk()).toBe(true);
238
+ expect(result.value.map((route) => route.trailId)).toEqual([
239
+ 'item.delete',
240
+ ]);
241
+ });
242
+
243
+ test('consumer trails (on: [...]) are skipped', () => {
244
+ const consumerTrail = trail('notify.email', {
245
+ blaze: (input: { orderId: string }) =>
246
+ Result.ok({ delivered: true, orderId: input.orderId }),
247
+ description: 'Send email on order placed',
248
+ input: z.object({ orderId: z.string() }),
249
+ on: ['order.placed'],
250
+ });
251
+ const app = topo('testapp', { consumerTrail, echoTrail, orderPlaced });
252
+ const result = deriveHttpRoutes(app);
172
253
 
173
254
  expect(result.isOk()).toBe(true);
174
255
  const routes = result.value;
@@ -180,7 +261,7 @@ describe('buildHttpRoutes', () => {
180
261
  describe('route definition shape', () => {
181
262
  test('includes trail reference', () => {
182
263
  const app = topo('testapp', { echoTrail });
183
- const result = buildHttpRoutes(app);
264
+ const result = deriveHttpRoutes(app);
184
265
 
185
266
  expect(result.isOk()).toBe(true);
186
267
  expect(result.value[0]?.trail).toBe(echoTrail);
@@ -188,7 +269,7 @@ describe('buildHttpRoutes', () => {
188
269
 
189
270
  test('execute is a function', () => {
190
271
  const app = topo('testapp', { echoTrail });
191
- const result = buildHttpRoutes(app);
272
+ const result = deriveHttpRoutes(app);
192
273
 
193
274
  expect(result.isOk()).toBe(true);
194
275
  expect(typeof result.value[0]?.execute).toBe('function');
@@ -198,7 +279,7 @@ describe('buildHttpRoutes', () => {
198
279
  describe('execute', () => {
199
280
  test('returns ok Result on valid input', async () => {
200
281
  const app = topo('testapp', { echoTrail });
201
- const buildResult = buildHttpRoutes(app);
282
+ const buildResult = deriveHttpRoutes(app);
202
283
 
203
284
  expect(buildResult.isOk()).toBe(true);
204
285
  const [route] = buildResult.value;
@@ -210,7 +291,7 @@ describe('buildHttpRoutes', () => {
210
291
 
211
292
  test('returns err Result on invalid input', async () => {
212
293
  const app = topo('testapp', { echoTrail });
213
- const buildResult = buildHttpRoutes(app);
294
+ const buildResult = deriveHttpRoutes(app);
214
295
 
215
296
  expect(buildResult.isOk()).toBe(true);
216
297
  const [route] = buildResult.value;
@@ -221,7 +302,7 @@ describe('buildHttpRoutes', () => {
221
302
 
222
303
  test('returns err Result from trail error', async () => {
223
304
  const app = topo('testapp', { notFoundTrail });
224
- const buildResult = buildHttpRoutes(app);
305
+ const buildResult = deriveHttpRoutes(app);
225
306
 
226
307
  expect(buildResult.isOk()).toBe(true);
227
308
  const [route] = buildResult.value;
@@ -233,7 +314,7 @@ describe('buildHttpRoutes', () => {
233
314
 
234
315
  test('returns err Result from internal error', async () => {
235
316
  const app = topo('testapp', { internalTrail });
236
- const buildResult = buildHttpRoutes(app);
317
+ const buildResult = deriveHttpRoutes(app);
237
318
 
238
319
  expect(buildResult.isOk()).toBe(true);
239
320
  const [route] = buildResult.value;
@@ -251,7 +332,7 @@ describe('buildHttpRoutes', () => {
251
332
  input: z.object({}),
252
333
  });
253
334
  const app = topo('testapp', { throwingTrail });
254
- const buildResult = buildHttpRoutes(app);
335
+ const buildResult = deriveHttpRoutes(app);
255
336
 
256
337
  expect(buildResult.isOk()).toBe(true);
257
338
  const [route] = buildResult.value;
@@ -264,7 +345,7 @@ describe('buildHttpRoutes', () => {
264
345
 
265
346
  test('returns err Result when createContext throws', async () => {
266
347
  const app = topo('testapp', { echoTrail });
267
- const buildResult = buildHttpRoutes(app, {
348
+ const buildResult = deriveHttpRoutes(app, {
268
349
  createContext: () => {
269
350
  throw new Error('context creation failed');
270
351
  },
@@ -279,6 +360,41 @@ describe('buildHttpRoutes', () => {
279
360
  expect(result?.error?.message).toBe('context creation failed');
280
361
  });
281
362
 
363
+ test('passes topo to executeTrail so HTTP-invoked producers can fan out', async () => {
364
+ const captured: string[] = [];
365
+ const consumer = trail('notify.email', {
366
+ blaze: (input: { orderId: string }) => {
367
+ captured.push(input.orderId);
368
+ return Result.ok({ delivered: true });
369
+ },
370
+ input: z.object({ orderId: z.string() }),
371
+ on: ['order.placed'],
372
+ });
373
+ const producer = trail('order.create', {
374
+ blaze: async (input: { orderId: string }, ctx) => {
375
+ const fired = await requireFire(ctx.fire)('order.placed', {
376
+ orderId: input.orderId,
377
+ });
378
+ return fired.match({
379
+ err: (error) => Result.err(error),
380
+ ok: () => Result.ok({ ok: true }),
381
+ });
382
+ },
383
+ fires: ['order.placed'],
384
+ input: z.object({ orderId: z.string() }),
385
+ });
386
+ const app = topo('signal-http', { consumer, orderPlaced, producer });
387
+ const buildResult = deriveHttpRoutes(app);
388
+
389
+ expect(buildResult.isOk()).toBe(true);
390
+ const [route] = buildResult.value;
391
+
392
+ const result = await route?.execute({ orderId: 'o-http' });
393
+
394
+ expect(result?.isOk()).toBe(true);
395
+ expect(captured).toEqual(['o-http']);
396
+ });
397
+
282
398
  test('passes requestId to context', async () => {
283
399
  let capturedRequestId: string | undefined;
284
400
 
@@ -292,7 +408,7 @@ describe('buildHttpRoutes', () => {
292
408
  });
293
409
 
294
410
  const app = topo('testapp', { ctxTrail });
295
- const buildResult = buildHttpRoutes(app);
411
+ const buildResult = deriveHttpRoutes(app);
296
412
 
297
413
  expect(buildResult.isOk()).toBe(true);
298
414
  const [route] = buildResult.value;
@@ -314,7 +430,7 @@ describe('buildHttpRoutes', () => {
314
430
  });
315
431
 
316
432
  const app = topo('testapp', { ctxTrail });
317
- const buildResult = buildHttpRoutes(app);
433
+ const buildResult = deriveHttpRoutes(app);
318
434
 
319
435
  expect(buildResult.isOk()).toBe(true);
320
436
  const [route] = buildResult.value;
@@ -324,18 +440,18 @@ describe('buildHttpRoutes', () => {
324
440
  expect(capturedRequestId).not.toBe('');
325
441
  });
326
442
 
327
- test('forwards provision overrides into executeTrail', async () => {
328
- const provisionTrail = trail('provision.check', {
443
+ test('forwards resource overrides into executeTrail', async () => {
444
+ const resourceTrail = trail('resource.check', {
329
445
  blaze: (_input, ctx) =>
330
- Result.ok({ source: dbProvision.from(ctx).source as string }),
446
+ Result.ok({ source: dbResource.from(ctx).source as string }),
331
447
  input: z.object({}),
332
448
  output: z.object({ source: z.string() }),
333
- provisions: [dbProvision],
449
+ resources: [dbResource],
334
450
  });
335
451
 
336
- const app = topo('testapp', { dbProvision, provisionTrail });
337
- const buildResult = buildHttpRoutes(app, {
338
- provisions: { 'db.main': { source: 'override' } },
452
+ const app = topo('testapp', { dbResource, resourceTrail });
453
+ const buildResult = deriveHttpRoutes(app, {
454
+ resources: { 'db.main': { source: 'override' } },
339
455
  });
340
456
 
341
457
  expect(buildResult.isOk()).toBe(true);
@@ -347,12 +463,12 @@ describe('buildHttpRoutes', () => {
347
463
  });
348
464
  });
349
465
 
350
- describe('gates', () => {
351
- test('gates compose around trail execution', async () => {
466
+ describe('layers', () => {
467
+ test('layers compose around trail execution', async () => {
352
468
  const calls: string[] = [];
353
469
 
354
- const testGate: Gate = {
355
- name: 'test-gate',
470
+ const testGate: Layer = {
471
+ name: 'test-layer',
356
472
  wrap(_trail, impl) {
357
473
  return async (input, ctx) => {
358
474
  calls.push('before');
@@ -364,7 +480,7 @@ describe('buildHttpRoutes', () => {
364
480
  };
365
481
 
366
482
  const app = topo('testapp', { echoTrail });
367
- const buildResult = buildHttpRoutes(app, { gates: [testGate] });
483
+ const buildResult = deriveHttpRoutes(app, { layers: [testGate] });
368
484
 
369
485
  expect(buildResult.isOk()).toBe(true);
370
486
  const [route] = buildResult.value;
@@ -391,7 +507,7 @@ describe('buildHttpRoutes', () => {
391
507
  });
392
508
 
393
509
  const app = topo('testapp', { ctxTrail });
394
- const buildResult = buildHttpRoutes(app, {
510
+ const buildResult = deriveHttpRoutes(app, {
395
511
  createContext: () => ({
396
512
  abortSignal: new AbortController().signal,
397
513
  extensions: { custom: true },
@@ -427,7 +543,7 @@ describe('buildHttpRoutes', () => {
427
543
  intent: 'read',
428
544
  });
429
545
  const app = topo('testapp', { dotTrail, slashTrail });
430
- const result = buildHttpRoutes(app);
546
+ const result = deriveHttpRoutes(app);
431
547
 
432
548
  expect(result.isErr()).toBe(true);
433
549
  expect(result.error).toBeInstanceOf(ValidationError);
@@ -450,7 +566,7 @@ describe('buildHttpRoutes', () => {
450
566
  input: z.object({ name: z.string() }),
451
567
  });
452
568
  const app = topo('testapp', { createItem, getItem });
453
- const result = buildHttpRoutes(app);
569
+ const result = deriveHttpRoutes(app);
454
570
 
455
571
  expect(result.isOk()).toBe(true);
456
572
  expect(result.value).toHaveLength(2);
@@ -470,7 +586,7 @@ describe('buildHttpRoutes', () => {
470
586
  intent: 'read',
471
587
  });
472
588
  const app = topo('testapp', { dotTrail, slashTrail });
473
- const result = buildHttpRoutes(app);
589
+ const result = deriveHttpRoutes(app);
474
590
 
475
591
  expect(result.isErr()).toBe(true);
476
592
  expect(result.error?.message).toContain('entity');
@@ -485,7 +601,7 @@ describe('buildHttpRoutes', () => {
485
601
  input: z.object({}),
486
602
  });
487
603
 
488
- const result = buildHttpRoutes(topo('testapp', { draftTrail }));
604
+ const result = deriveHttpRoutes(topo('testapp', { draftTrail }));
489
605
 
490
606
  expect(result.isErr()).toBe(true);
491
607
  expect(result.error?.message).toMatch(/draft/i);
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { Result, trail, topo } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { deriveHttpRoutes } from '../build.js';
7
+
8
+ const unwrapOk = <T>(result: Result<T, Error>): T =>
9
+ result.match({
10
+ err: (error) => {
11
+ throw error;
12
+ },
13
+ ok: (value) => value,
14
+ });
15
+
16
+ describe('deriveHttpRoutes', () => {
17
+ test('aliases the HTTP projection API', () => {
18
+ const graph = topo('testapp', {
19
+ echo: trail('echo', {
20
+ blaze: (input: { message: string }) =>
21
+ Result.ok({ reply: input.message }),
22
+ input: z.object({ message: z.string() }),
23
+ intent: 'read',
24
+ output: z.object({ reply: z.string() }),
25
+ }),
26
+ });
27
+
28
+ const routes = unwrapOk(deriveHttpRoutes(graph));
29
+ expect(routes[0]?.path).toBe('/echo');
30
+ });
31
+ });
package/src/build.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Build framework-agnostic HTTP route definitions from a Trails topo.
3
3
  *
4
4
  * Each route definition describes the path, method, input source, and an
5
- * `execute` function that validates input, composes gates, and runs the
5
+ * `execute` function that validates input, composes layers, and runs the
6
6
  * implementation -- all without referencing any HTTP framework types.
7
7
  */
8
8
 
@@ -11,11 +11,13 @@ import {
11
11
  TRAILHEAD_KEY,
12
12
  ValidationError,
13
13
  executeTrail,
14
+ filterSurfaceTrails,
14
15
  validateEstablishedTopo,
15
16
  } from '@ontrails/core';
16
17
  import type {
17
- Gate,
18
- ProvisionOverrideMap,
18
+ Intent,
19
+ Layer,
20
+ ResourceOverrideMap,
19
21
  Topo,
20
22
  Trail,
21
23
  TrailContextInit,
@@ -25,17 +27,20 @@ import type {
25
27
  // Public types
26
28
  // ---------------------------------------------------------------------------
27
29
 
28
- export interface BuildHttpRoutesOptions {
30
+ export interface DeriveHttpRoutesOptions {
29
31
  readonly basePath?: string | undefined;
30
- /** Config values for provisions that declare a `config` schema, keyed by provision ID. */
32
+ /** Config values for resources that declare a `config` schema, keyed by resource ID. */
31
33
  readonly configValues?:
32
34
  | Readonly<Record<string, Record<string, unknown>>>
33
35
  | undefined;
34
36
  readonly createContext?:
35
37
  | (() => TrailContextInit | Promise<TrailContextInit>)
36
38
  | undefined;
37
- readonly gates?: readonly Gate[] | undefined;
38
- readonly provisions?: ProvisionOverrideMap | undefined;
39
+ readonly exclude?: readonly string[] | undefined;
40
+ readonly include?: readonly string[] | undefined;
41
+ readonly intent?: readonly Intent[] | undefined;
42
+ readonly layers?: readonly Layer[] | undefined;
43
+ readonly resources?: ResourceOverrideMap | undefined;
39
44
  /** Set to `false` to skip topo validation while building routes. */
40
45
  readonly validate?: boolean | undefined;
41
46
  }
@@ -50,9 +55,9 @@ export interface HttpRouteDefinition {
50
55
  readonly path: string;
51
56
  readonly trailId: string;
52
57
  readonly inputSource: InputSource;
53
- readonly trail: Trail<unknown, unknown>;
58
+ readonly trail: Trail<unknown, unknown, unknown>;
54
59
  /**
55
- * Validate input, compose gates, and execute the trail implementation.
60
+ * Validate input, compose layers, and execute the trail implementation.
56
61
  *
57
62
  * The caller is responsible for parsing raw input from the request and
58
63
  * mapping the Result to an HTTP response. This function is framework-agnostic.
@@ -80,7 +85,7 @@ const intentToMethod: Record<string, HttpMethod> = {
80
85
  };
81
86
 
82
87
  /** Derive HTTP method from trail intent. */
83
- const deriveMethod = (trail: Trail<unknown, unknown>): HttpMethod =>
88
+ const deriveMethod = (trail: Trail<unknown, unknown, unknown>): HttpMethod =>
84
89
  intentToMethod[trail.intent] ?? 'POST';
85
90
 
86
91
  /** Derive HTTP path from trail ID: `entity.show` -> `/entity/show`. */
@@ -94,10 +99,6 @@ const derivePath = (basePath: string, trailId: string): string => {
94
99
  const deriveInputSource = (method: HttpMethod): InputSource =>
95
100
  method === 'GET' ? 'query' : 'body';
96
101
 
97
- /** Check if a trail should be included (skip internal trails). */
98
- const shouldInclude = (trail: Trail<unknown, unknown>): boolean =>
99
- trail.meta?.['internal'] !== true;
100
-
101
102
  /** Build per-request context overrides with the HTTP trailhead marker. */
102
103
  const withHttpTrailhead = (
103
104
  requestId: string | undefined
@@ -120,9 +121,10 @@ const withHttpTrailhead = (
120
121
  */
121
122
  const createExecute =
122
123
  (
123
- t: Trail<unknown, unknown>,
124
- gates: readonly Gate[],
125
- options: BuildHttpRoutesOptions
124
+ graph: Topo,
125
+ t: Trail<unknown, unknown, unknown>,
126
+ layers: readonly Layer[],
127
+ options: DeriveHttpRoutesOptions
126
128
  ): HttpRouteDefinition['execute'] =>
127
129
  (input, requestId, abortSignal) =>
128
130
  executeTrail(t, input, {
@@ -130,8 +132,9 @@ const createExecute =
130
132
  configValues: options.configValues,
131
133
  createContext: options.createContext,
132
134
  ctx: withHttpTrailhead(requestId),
133
- gates,
134
- provisions: options.provisions,
135
+ layers,
136
+ resources: options.resources,
137
+ topo: graph,
135
138
  });
136
139
 
137
140
  // ---------------------------------------------------------------------------
@@ -139,20 +142,28 @@ const createExecute =
139
142
  // ---------------------------------------------------------------------------
140
143
 
141
144
  /** Filter topo items to eligible trails. */
142
- const eligibleTrails = (app: Topo): Trail<unknown, unknown>[] =>
143
- app.list().filter((trail) => shouldInclude(trail));
145
+ const eligibleTrails = (
146
+ graph: Topo,
147
+ options: DeriveHttpRoutesOptions
148
+ ): Trail<unknown, unknown, unknown>[] =>
149
+ filterSurfaceTrails(graph.list(), {
150
+ exclude: options.exclude,
151
+ include: options.include,
152
+ intent: options.intent,
153
+ });
144
154
 
145
155
  /** Build a single route definition from a trail. */
146
156
  const buildRoute = (
147
- trail: Trail<unknown, unknown>,
157
+ graph: Topo,
158
+ trail: Trail<unknown, unknown, unknown>,
148
159
  basePath: string,
149
- gates: readonly Gate[],
150
- options: BuildHttpRoutesOptions
160
+ layers: readonly Layer[],
161
+ options: DeriveHttpRoutesOptions
151
162
  ): HttpRouteDefinition => {
152
163
  const method = deriveMethod(trail);
153
164
  const path = derivePath(basePath, trail.id);
154
165
  return {
155
- execute: createExecute(trail, gates, options),
166
+ execute: createExecute(graph, trail, layers, options),
156
167
  inputSource: deriveInputSource(method),
157
168
  method,
158
169
  path,
@@ -191,16 +202,17 @@ const registerRoute = (
191
202
 
192
203
  /** Accumulate route definitions, returning early on the first collision. */
193
204
  const accumulateRoutes = (
194
- trails: Trail<unknown, unknown>[],
205
+ graph: Topo,
206
+ trails: Trail<unknown, unknown, unknown>[],
195
207
  basePath: string,
196
- gates: readonly Gate[],
197
- options: BuildHttpRoutesOptions
208
+ layers: readonly Layer[],
209
+ options: DeriveHttpRoutesOptions
198
210
  ): Result<HttpRouteDefinition[], Error> => {
199
211
  const routes: HttpRouteDefinition[] = [];
200
212
  const seenRoutes = new Map<string, string>();
201
213
 
202
214
  for (const trail of trails) {
203
- const route = buildRoute(trail, basePath, gates, options);
215
+ const route = buildRoute(graph, trail, basePath, layers, options);
204
216
  const registered = registerRoute(route, seenRoutes, routes);
205
217
  if (registered.isErr()) {
206
218
  return registered;
@@ -221,23 +233,29 @@ const accumulateRoutes = (
221
233
  * - An HTTP method derived from intent (read -> GET, write -> POST, destroy -> DELETE)
222
234
  * - A path derived from the trail ID (dots become slashes)
223
235
  * - An input source derived from the method (GET -> query, others -> body)
224
- * - An `execute` function that validates, gates, and runs the implementation
236
+ * - An `execute` function that validates, layers, and runs the implementation
225
237
  *
226
238
  * Returns `Result.err(ValidationError)` if two trails derive the same
227
239
  * (method, path) pair. Returns `Result.ok(routes)` on success.
228
240
  */
229
- export const buildHttpRoutes = (
230
- app: Topo,
231
- options: BuildHttpRoutesOptions = {}
241
+ export const deriveHttpRoutes = (
242
+ graph: Topo,
243
+ options: DeriveHttpRoutesOptions = {}
232
244
  ): Result<HttpRouteDefinition[], Error> => {
233
245
  if (options.validate !== false) {
234
- const validated = validateEstablishedTopo(app);
246
+ const validated = validateEstablishedTopo(graph);
235
247
  if (validated.isErr()) {
236
248
  return Result.err(validated.error);
237
249
  }
238
250
  }
239
251
 
240
252
  const basePath = (options.basePath ?? '').replace(/\/+$/, '');
241
- const gates = options.gates ?? [];
242
- return accumulateRoutes(eligibleTrails(app), basePath, gates, options);
253
+ const layers = options.layers ?? [];
254
+ return accumulateRoutes(
255
+ graph,
256
+ eligibleTrails(graph, options),
257
+ basePath,
258
+ layers,
259
+ options
260
+ );
243
261
  };
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Build (framework-agnostic)
2
2
  export {
3
- buildHttpRoutes,
4
- type BuildHttpRoutesOptions,
3
+ deriveHttpRoutes,
4
+ type DeriveHttpRoutesOptions,
5
5
  type HttpMethod,
6
6
  type HttpRouteDefinition,
7
7
  type InputSource,