create-substrat 0.9.5 → 0.9.7

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.
package/index.js CHANGED
@@ -36,11 +36,11 @@ const TEMPLATE = join(HERE, 'template');
36
36
  // The runtime packages release together off one version line (the changesets `fixed`
37
37
  // group), so one constant is right for all of them. Engines do NOT share a line —
38
38
  // each versions on its own, so one pin per engine, deliberately.
39
- const SUBSTRAT = '^0.110.0';
40
- const ENGINE_WORKORDER = '^0.11.9';
41
- const ENGINE_INVOICING = '^0.10.0';
39
+ const SUBSTRAT = '^0.112.0';
40
+ const ENGINE_WORKORDER = '^0.11.11';
41
+ const ENGINE_INVOICING = '^0.10.2';
42
42
  const BOUNDARY_LINT = '^0.4.2';
43
- const DEV_ISSUER = '^0.1.25';
43
+ const DEV_ISSUER = '^0.1.27';
44
44
 
45
45
  const DOCS = 'https://substrat.net';
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -20,7 +20,7 @@
20
20
  * not in `.claude/`. `.claude/settings.json` is a three-line adapter that runs it,
21
21
  * and any other client that grows a session hook binds the same way. It also
22
22
  * means the plugin distribution (#753) ships this script unchanged rather than
23
- * forking it: `plugin/substrat/scripts/session-start.mjs` is emitted from this
23
+ * forking it: `plugin/substrat/scripts/session-start.generated.mjs` is emitted from this
24
24
  * file byte-for-byte, and `pnpm lint:plugin --check` fails if the two diverge.
25
25
  *
26
26
  * The plugin copy is what reaches a project scaffolded before this hook existed.
@@ -324,7 +324,7 @@ to make the build pass.
324
324
  ## Step 6 — Reshape the reference
325
325
 
326
326
  The design is approved. The scaffold already contains a working vertical in `src/` + `test/` —
327
- the bike-repair shop. **Read it first** (it's your Callout: the real, green implementation of
327
+ the bike-repair shop. **Read it first** (it is your reference: the real, green implementation of
328
328
  every pattern this step describes), then reshape it into the user's domain from the approved
329
329
  `spec/concept.md`:
330
330
 
@@ -46,20 +46,29 @@ src/migrations.ts the SqlMigration[] ← module cod
46
46
  src/module.ts the handlers, bound to the declaration ← module code
47
47
  src/provision.ts MODULES, ROLES, grant shapes — node-free ← module code
48
48
  src/seed.ts host, tenants, demo cast, seed world ← harness
49
- src/routes.ts the HTTP route table — BOTH hosts mount it ← harness
49
+ src/personas.ts the dev cast, read by the issuer and the seed ← harness
50
+ src/routes.ts the routes, DERIVED from the operations ← harness
50
51
  src/server.ts the dev entrypoint (node + persona picker) ← harness
51
52
  src/worker.ts the deployable Cloudflare worker ← harness
52
53
  src/config-do.ts per-instance config store (Cloudflare only) ← harness
53
54
  test/scenario.test.ts the scenario — including the denials
55
+ test/entities.test.ts the registry, held to the tables it migrates
54
56
  ```
55
57
 
56
- **A new route goes in `src/routes.ts`, never in an entrypoint.** Both `server.ts`
57
- and `worker.ts` mount that one table, so a route added there is live on both — and
58
- a route added to only one is a surface that works in dev and 404s in production
58
+ **A new route is an `http` declaration on its operation, never a handler in an
59
+ entrypoint.** `src/routes.ts` holds no table: `mountOperations` (from
60
+ `@substrat-run/vertical-host`) derives one from the `http` each operation in
61
+ `src/operations.ts` declares — method, path, and which input fields the path
62
+ carries, compile-checked there — and both `server.ts` and `worker.ts` mount that
63
+ one derivation, so a route is live on both the moment it is declared. A route
64
+ added to only one entrypoint is a surface that works in dev and 404s in production
59
65
  (or the reverse), which nothing catches until you deploy: the scenario tests call
60
- operations directly and never boot either host. What an entrypoint may still own
61
- is only what is genuinely its own — building a host, resolving a caller, and its
62
- own auth-shaped route (`/api/cast` in dev, `/api/me` in the worker).
66
+ operations directly and never boot either host. A composed engine's operations
67
+ carry no `http` of their own (an engine does not own a URL shape), so the vertical
68
+ binds them with `defineEngineRoutes` beside its own declarations. What an
69
+ entrypoint may still own is only what is genuinely its own — building a host,
70
+ resolving a caller, and its own auth-shaped routes (`/api/auth/*` and `/api/me`
71
+ in dev, `/api/me` in the worker).
63
72
 
64
73
  `provision.ts` is deliberately node-free: both hosts register from it (the dev
65
74
  server's SQLite host and the worker's `ScopeDO`), and `substrat push` reads the
@@ -149,6 +158,12 @@ same object feeds `operationInputs: operationInputsOf(bikeShopOperations)`, so t
149
158
  schemas the host parses with are the ones the declaration states — they cannot drift,
150
159
  because there is only one of them.
151
160
 
161
+ The permission list works the same way. `SHOP_PERMISSIONS`, the array handed to
162
+ `defineOperations`, is also the `keys` that `src/provision.ts` hands
163
+ `definePermissions({ modules, roles, entityGrants, keys })` — and `definePermissions`
164
+ throws at module load if those keys and `MODULES` disagree in either direction. Hand
165
+ both readers the same array; a second copy is a list that drifts.
166
+
152
167
  **A list read must declare `paged`.** `defineOperations` refuses a bare-array `output`
153
168
  that does not, and it refuses it at module load — so it fires in every build, every
154
169
  test and every dev server rather than in a lint tool that has to find you. A list
@@ -161,15 +176,17 @@ walk, a table the registry does not carry — the handler composes its own and n
161
176
  field the cursor walks (`paged: { sortKey: 'article' }`). Keyset, never offset: on
162
177
  live data an offset shifts between requests, so pages drop and duplicate rows.
163
178
 
164
- **A paged read has an HTTP half, and this route table is hand-written.** The
179
+ **A paged read has an HTTP half, and the derived route does it for you.** The
165
180
  operation answers with a `Page<T>` — it is transport-agnostic, and a test or a seed
166
- must be able to walk a list with no response to read headers off — so `src/routes.ts`
167
- does the projection at the edge: it forwards the page trio in (`pageInput`) and hands
168
- the entries back as the body with the walk in a `Link` header (`pageJson`). Forget the
169
- first and the endpoint is pinned to page one no matter what the operation supports;
170
- forget the second and it answers with an envelope where it used to answer with an
171
- array. Both helpers are already in `src/routes.ts` — use them for every route that
172
- invokes a paged operation, the engines' list reads included.
181
+ must be able to walk a list with no response to read headers off — so the projection
182
+ happens at the edge: `mountOperations` forwards the page trio in (`limit`, `cursor`,
183
+ `order`, parsed with the platform's own defaults and ceiling) and hands the entries
184
+ back as the body with the walk in a `Link` header. Declare `http` on the paged
185
+ operation and both halves are there; hand-mount a paged read yourself and you have
186
+ to do both by hand — forget the first and the endpoint is pinned to page one no
187
+ matter what the operation supports, forget the second and it answers with an
188
+ envelope where it used to answer with an array. That is the case for not
189
+ hand-mounting anything an operation can declare.
173
190
 
174
191
  ## The rules (non-negotiable)
175
192
 
@@ -1,10 +1,17 @@
1
- import { defineOperations, money, timelineEntry, z } from '@substrat-run/contracts';
2
- import { billableLine, workOrder, workorderEntities } from '@substrat-run/engine-workorder';
1
+ import { defineEngineRoutes, defineOperations, money, timelineEntry, z } from '@substrat-run/contracts';
2
+ import { invoicingOperations } from '@substrat-run/engine-invoicing';
3
+ import {
4
+ billableLine,
5
+ workOrder,
6
+ workorderEntities,
7
+ workorderOperations,
8
+ } from '@substrat-run/engine-workorder';
3
9
  import { bikeShopEntities } from './entities.js';
4
10
 
5
11
  // ============================================================================
6
12
  // The bike shop's DECLARED OPERATION SURFACE — what each operation accepts,
7
- // what it answers with, and which permission gates it.
13
+ // what it answers with, which permission gates it, and where it lives in the
14
+ // HTTP API.
8
15
  //
9
16
  // This is one declaration, not documentation of another one. `src/module.ts`
10
17
  // binds its handlers to this object with `satisfies OperationImpl<…>`, so four
@@ -12,7 +19,10 @@ import { bikeShopEntities } from './entities.js';
12
19
  // disagrees with `input`, one whose return disagrees with `output`, an
13
20
  // operation declared and not implemented, and one implemented and not declared.
14
21
  // `operationInputsOf(bikeShopOperations)` hands the host the same schemas, so
15
- // every invocation is parsed before the guards and the handler.
22
+ // every invocation is parsed before the guards and the handler — and
23
+ // `mountOperations` (src/routes.ts) derives the route table from the `http`
24
+ // each operation declares, so a `{var}` in a path that names no input field is
25
+ // a compile error too, and there is no second list of routes to drift.
16
26
  // ============================================================================
17
27
 
18
28
  /**
@@ -100,10 +110,12 @@ export const bikeShopOperations = defineOperations(
100
110
  }),
101
111
  // The row shape comes from the registry — not restated here.
102
112
  output: bikeShopEntities.customer.fields,
113
+ http: { method: 'POST', path: '/customers' },
103
114
  },
104
115
  'shop/list-customers': {
105
116
  summary: 'List customers with the bikes they have registered',
106
117
  permission: 'customer:manage',
118
+ http: { method: 'GET', path: '/customers' },
107
119
  // The ENTRY, not the envelope — `paged` wraps it. The page also BOUNDS the
108
120
  // hydration: one bikes query per customer ON THE PAGE, where an unpaged read
109
121
  // ran one per customer in the scope.
@@ -124,6 +136,8 @@ export const bikeShopOperations = defineOperations(
124
136
  frameNo: z.string().min(1).optional(),
125
137
  }),
126
138
  output: bikeShopEntities.bike.fields,
139
+ // `{customerId}` must name an input field, and the compiler checks that it does.
140
+ http: { method: 'POST', path: '/customers/{customerId}/bikes' },
127
141
  },
128
142
  'shop/upsert-price': {
129
143
  summary: 'Create or update a price-list article',
@@ -138,11 +152,13 @@ export const bikeShopOperations = defineOperations(
138
152
  internal: z.boolean().optional(),
139
153
  }),
140
154
  output: priceRow,
155
+ http: { method: 'POST', path: '/prices' },
141
156
  },
142
157
  'shop/price-list': {
143
158
  summary: 'The workshop price list',
144
159
  permission: 'customer:manage',
145
160
  output: priceRow,
161
+ http: { method: 'GET', path: '/prices' },
146
162
  // Handler-composed rather than `over`: `shop_price_list` is value-keyed and
147
163
  // deliberately not a declared entity, so the registry has no table for the
148
164
  // kernel to index. It still pages, and still carries a cursor.
@@ -163,18 +179,21 @@ export const bikeShopOperations = defineOperations(
163
179
  // and NOT the row: the engine stores `facility_type`/`facility_id` as two
164
180
  // snake_case columns and publishes one `EntityRef` in camelCase.
165
181
  output: workOrder,
182
+ http: { method: 'POST', path: '/repairs' },
166
183
  },
167
184
  'shop/complete-repair': {
168
185
  summary: 'Complete a repair and price its billable lines',
169
186
  permission: 'workorder:complete',
170
187
  input: z.object({ orderId: z.string().min(1) }),
171
188
  output: z.object({ order: workOrder, billable: z.array(billableLine), total: money }),
189
+ http: { method: 'POST', path: '/repairs/{orderId}/complete' },
172
190
  },
173
191
  'shop/close-repair': {
174
192
  summary: 'Hand the bike back — completed to closed',
175
193
  permission: 'workorder:close',
176
194
  input: z.object({ orderId: z.string().min(1) }),
177
195
  output: workOrder,
196
+ http: { method: 'POST', path: '/repairs/{orderId}/close' },
178
197
  },
179
198
  'shop/portal-repairs': {
180
199
  summary: 'The repairs visible to the calling portal customer',
@@ -195,6 +214,7 @@ export const bikeShopOperations = defineOperations(
195
214
  // compose. It pages by OVER-fetching, so a SHORT page does not end the walk
196
215
  // — only an absent cursor does.
197
216
  paged: { sortKey: 'id' },
217
+ http: { method: 'GET', path: '/portal/repairs' },
198
218
  },
199
219
  'shop/timeline': {
200
220
  summary: 'The event timeline for one repair',
@@ -213,5 +233,46 @@ export const bikeShopOperations = defineOperations(
213
233
  // The cursor is `id` — the event's ULID, which IS this entity's version at
214
234
  // that point.
215
235
  paged: { sortKey: 'id' },
236
+ // The path carries `entityId` alone. `entityType` is the literal above, and
237
+ // `mountOperations` PINS a literal — it goes into the payload before anything
238
+ // the caller sent, so a caller cannot talk this route into another entity type.
239
+ http: { method: 'GET', path: '/repairs/{entityId}/timeline' },
216
240
  },
217
241
  });
242
+
243
+ /**
244
+ * Where the composed engines' operations live in this vertical's API.
245
+ *
246
+ * An engine declares no `http`, and should not: it is entity-agnostic and does
247
+ * not own a URL shape — this shop calls a work order a repair, and the path is
248
+ * the shop's decision. A binding is a name and a path; the summary, the input
249
+ * schema and the return shape all come from the engine, so nothing here
250
+ * restates anything. Bind a `{var}` the engine's input does not accept and it
251
+ * does not compile; bind a name the engine does not have and it throws when the
252
+ * module loads.
253
+ *
254
+ * `workorder/complete` and `workorder/close` are deliberately NOT bound: the
255
+ * shop wraps them (`shop/complete-repair` owns the pricing moment,
256
+ * `shop/close-repair` the handback) and a route straight to the engine would
257
+ * skip that. Which operations are the vertical's and which are the engine's,
258
+ * invoked directly, is the composition boundary — visible right here.
259
+ */
260
+ export const bikeShopEngineRoutes = defineEngineRoutes(workorderOperations)({
261
+ 'workorder/list': { method: 'GET', path: '/repairs' },
262
+ 'workorder/get': { method: 'GET', path: '/repairs/{orderId}' },
263
+ 'workorder/assign': { method: 'POST', path: '/repairs/{orderId}/assign' },
264
+ 'workorder/start': { method: 'POST', path: '/repairs/{orderId}/start' },
265
+ 'workorder/report-time': { method: 'POST', path: '/repairs/{orderId}/time' },
266
+ 'workorder/report-material': { method: 'POST', path: '/repairs/{orderId}/material' },
267
+ });
268
+
269
+ /**
270
+ * The invoicing engine's operations (the sibling engine, fed by event). All
271
+ * three, because this engine's callable surface is reads and one export — there
272
+ * is nothing to create, so there is no constant for the shop to pin.
273
+ */
274
+ export const bikeShopInvoicingRoutes = defineEngineRoutes(invoicingOperations)({
275
+ 'invoicing/list': { method: 'GET', path: '/invoicing' },
276
+ 'invoicing/get': { method: 'GET', path: '/invoicing/{underlagId}' },
277
+ 'invoicing/export': { method: 'POST', path: '/invoicing/{underlagId}/export' },
278
+ });
@@ -1,92 +1,53 @@
1
1
  import type { Context, Hono } from 'hono';
2
- import { problemResponse } from '@substrat-run/vertical-host';
3
- import {
4
- isPage,
5
- listPageQuery,
6
- LIST_SORT_PARAM,
7
- nextPageLink,
8
- PAGE_LINK_HEADER,
9
- PAGE_TOTAL_HEADER,
10
- } from '@substrat-run/contracts';
11
- import type { ScopeStub } from '@substrat-run/kernel';
2
+ import { mountOperations, problemResponse, type ResolveStub } from '@substrat-run/vertical-host';
3
+ import { bikeShopEngineRoutes, bikeShopInvoicingRoutes, bikeShopOperations } from './operations.js';
12
4
 
13
5
  /**
14
- * The bike shop's HTTP API — ONE route table, adapter- and auth-agnostic.
6
+ * The bike shop's HTTP API — derived from the declared operations, adapter- and
7
+ * auth-agnostic.
15
8
  *
16
9
  * Both entrypoints mount this: `server.ts` (node, pure-SQLite adapter, OIDC against the
17
10
  * local dev issuer) and `worker.ts` (Cloudflare, Durable-Object adapter, the auth seam). Each
18
11
  * supplies a `resolveStub` that authenticates the caller its own way and returns a
19
- * capability `ScopeStub`; every route here is a thin wrapper over ONE operation, with
20
- * no business logic — the rules live in an operation or an engine.
12
+ * capability `ScopeStub`; every route is a thin wrapper over ONE operation, with no
13
+ * business logic — the rules live in an operation or an engine.
21
14
  *
22
15
  * Sharing the table is the point. A route added to only one entrypoint is a surface
23
16
  * that exists in dev and 404s in production (or the reverse), and nothing catches it
24
17
  * until deploy: the scenario tests call operations directly and never boot either host.
25
- * Add a route HERE and it is live on both.
26
- *
27
18
  * What each entrypoint still owns is only what is genuinely its own: how it builds a
28
- * host, how it resolves a caller, and its own auth-shaped routes (`/api/cast` in dev,
29
- * `/api/me` in the worker) — those answer "who am I on THIS host" and cannot be shared.
30
- */
31
- export type ResolveStub = (c: Context) => Promise<ScopeStub>;
32
-
33
- /**
34
- * The page trio, off the query string — what a route hands a PAGED operation.
19
+ * host, how it resolves a caller, and its own auth-shaped routes (`/api/auth/*` and
20
+ * `/api/me` in dev, `/api/me` in the worker) — those answer "who am I on THIS host"
21
+ * and cannot be shared.
35
22
  *
36
- * A paged read takes `limit`/`cursor` (and, where its declaration offers them,
37
- * `order`/`sort`) as ordinary input, so a route that forwards nothing pins its
38
- * endpoint to page one forever: the operation still pages, the caller just has
39
- * no way to say which page it wants. Parsed with the platform's own
40
- * `listPageQuery`, so this endpoint's default page size and its ceiling are the
41
- * same numbers every other list read on the platform uses — a hand-written route
42
- * table is not a licence to invent a second convention.
23
+ * ## Why there is no table here
43
24
  *
44
- * `order` and `sort` travel only when asked for, so the DECLARATION's own
45
- * defaults stay the answer when a caller says nothing.
25
+ * There was one, and every line of it restated something the operations already
26
+ * declare: the method, the path, which input fields the path carries, and — for a
27
+ * paged read — that the page trio must be forwarded in and the entries handed back
28
+ * as the body with the walk in a `Link` header. Two helpers held that last part
29
+ * together by hand, and every route that invoked a paged operation had to remember
30
+ * to call both. `mountOperations` derives all of it from the `http` each operation
31
+ * declares in `src/operations.ts`: it orders static path segments ahead of their
32
+ * parameter siblings, coerces query values per the declared shape, pins a
33
+ * `z.literal` input the caller must not choose, projects a `Page<T>` onto the wire,
34
+ * and turns the kernel's own refusals into their status. A new operation is on
35
+ * both hosts the moment it declares `http`, and there is no second list to drift.
46
36
  */
47
- function pageInput(c: Context): Record<string, unknown> {
48
- const q = c.req.query();
49
- const page = listPageQuery.parse({ limit: q['limit'], cursor: q['cursor'], order: q['order'] });
50
- return {
51
- limit: page.limit,
52
- ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
53
- ...(page.order === undefined ? {} : { order: page.order }),
54
- ...(q[LIST_SORT_PARAM] === undefined ? {} : { sort: q[LIST_SORT_PARAM] }),
55
- };
56
- }
37
+ export type { ResolveStub };
57
38
 
58
- /**
59
- * A page's answer on the WIRE: the entries are the body, the walk rides in
60
- * headers (`Link: <…?cursor=…>; rel="next"`, RFC 8288).
61
- *
62
- * The operation's own shape stays `Page<T>` — a test, a seed or another
63
- * operation must be able to walk a list with no HTTP response to read headers
64
- * off — so this is a projection at the edge, not a change to what the operation
65
- * returns. Adopting paging then costs a client nothing: a list endpoint returns
66
- * the array it always returned and gains a walk it did not have.
67
- *
68
- * `isPage` is CHECKED rather than assumed, so an operation that has not adopted
69
- * `pageOf` yet reaches the client unchanged instead of being emptied into a body
70
- * of `undefined`.
71
- *
72
- * This is the same projection `mountOperations` (@substrat-run/vertical-host)
73
- * performs for a declared surface. This route table is hand-written, so it does
74
- * it here — one helper, used by every paged read below.
75
- */
76
- function pageJson(c: Context, result: unknown): Response {
77
- if (!isPage(result)) return c.json(result as never);
78
- const link = nextPageLink(c.req.url, result.nextCursor);
79
- if (link) c.header(PAGE_LINK_HEADER, link);
80
- const total = (result as { total?: unknown }).total;
81
- if (typeof total === 'number') c.header(PAGE_TOTAL_HEADER, String(total));
82
- return c.json(result.entries as never);
83
- }
84
-
85
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
- export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): void {
87
- const S = resolveStub;
88
- const body = (c: Context) => c.req.json<Record<string, unknown>>();
39
+ /** Every operation that carries a URL: this vertical's own, and the two engines it composes. */
40
+ const ROUTED = {
41
+ ...bikeShopOperations,
42
+ ...bikeShopEngineRoutes,
43
+ ...bikeShopInvoicingRoutes,
44
+ };
89
45
 
46
+ export function mountApi(
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ app: Hono<any, any, any>,
49
+ resolveStub: ResolveStub,
50
+ ): { operation: string; method: string; path: string }[] {
90
51
  /**
91
52
  * One error vocabulary, shared with the platform surface: `problemResponse`
92
53
  * (@substrat-run/vertical-host) is built on the same `classifyError` that
@@ -105,115 +66,21 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
105
66
  * it here is what gives `server.ts`, which mounts no platform surface, the same
106
67
  * behaviour.
107
68
  */
108
- app.onError((err, c) => problemResponse(c, err));
69
+ app.onError((err, c: Context) => problemResponse(c, err));
109
70
 
110
- // -- generic invoke ---------------------------------------------------------
111
- // The kernel checks a permission inside EVERY operation, so a generic route is
112
- // exactly as safe as one route per operation. It is the escape hatch that keeps a
113
- // new operation reachable before it has a named route — on BOTH hosts, deliberately.
71
+ // The one hand-written route, registered BEFORE the derived table so the
72
+ // exception always wins. The kernel checks a permission inside EVERY operation,
73
+ // so a generic route is exactly as safe as one route per operation — and it is
74
+ // what keeps an operation reachable that has no URL of its own: an engine
75
+ // operation this vertical deliberately did not bind (`workorder/complete`, which
76
+ // `shop/complete-repair` wraps) is still callable here, on BOTH hosts.
114
77
  app.post('/api/invoke', async (c) => {
115
78
  const { op, input } = await c.req.json<{ op: string; input?: unknown }>();
116
- return c.json((await (await S(c)).invoke(op, input)) ?? null);
79
+ return c.json((await (await resolveStub(c)).invoke(op, input)) ?? null);
117
80
  });
118
81
 
119
- // -- customers, bikes, price list (the vertical's own tables) ---------------
120
- app.get('/api/customers', async (c) =>
121
- pageJson(c, await (await S(c)).invoke('shop/list-customers', pageInput(c))),
122
- );
123
- app.post('/api/customers', async (c) =>
124
- c.json(await (await S(c)).invoke('shop/create-customer', await c.req.json())),
125
- );
126
- app.post('/api/customers/:id/bikes', async (c) =>
127
- c.json(
128
- await (await S(c)).invoke('shop/register-bike', {
129
- customerId: c.req.param('id'),
130
- ...(await body(c)),
131
- }),
132
- ),
133
- );
134
- app.get('/api/prices', async (c) =>
135
- pageJson(c, await (await S(c)).invoke('shop/price-list', pageInput(c))),
136
- );
137
- app.post('/api/prices', async (c) =>
138
- c.json(await (await S(c)).invoke('shop/upsert-price', await c.req.json())),
139
- );
140
-
141
- // -- repairs ---------------------------------------------------------------
142
- // create/complete/close are the VERTICAL's operations (they wrap the engine and own
143
- // the pricing moment); assign/start/report/get/list are the ENGINE's own, invoked
144
- // directly. Which is which is the composition boundary, visible right here.
145
- app.get('/api/repairs', async (c) =>
146
- pageJson(
147
- c,
148
- await (await S(c)).invoke('workorder/list', {
149
- status: c.req.query('status'),
150
- ...pageInput(c),
151
- }),
152
- ),
153
- );
154
- app.post('/api/repairs', async (c) =>
155
- c.json(await (await S(c)).invoke('shop/create-repair', await c.req.json())),
156
- );
157
- app.get('/api/repairs/:id', async (c) =>
158
- c.json(await (await S(c)).invoke('workorder/get', { orderId: c.req.param('id') })),
159
- );
160
- app.get('/api/repairs/:id/timeline', async (c) =>
161
- pageJson(
162
- c,
163
- await (await S(c)).invoke('shop/timeline', {
164
- entityType: 'workorder',
165
- entityId: c.req.param('id'),
166
- ...pageInput(c),
167
- }),
168
- ),
169
- );
170
- app.post('/api/repairs/:id/assign', async (c) =>
171
- c.json(
172
- await (await S(c)).invoke('workorder/assign', {
173
- orderId: c.req.param('id'),
174
- ...(await body(c)),
175
- }),
176
- ),
177
- );
178
- app.post('/api/repairs/:id/start', async (c) =>
179
- c.json(await (await S(c)).invoke('workorder/start', { orderId: c.req.param('id') })),
180
- );
181
- app.post('/api/repairs/:id/time', async (c) =>
182
- c.json(
183
- await (await S(c)).invoke('workorder/report-time', {
184
- orderId: c.req.param('id'),
185
- ...(await body(c)),
186
- }),
187
- ),
188
- );
189
- app.post('/api/repairs/:id/material', async (c) =>
190
- c.json(
191
- await (await S(c)).invoke('workorder/report-material', {
192
- orderId: c.req.param('id'),
193
- ...(await body(c)),
194
- }),
195
- ),
196
- );
197
- app.post('/api/repairs/:id/complete', async (c) =>
198
- c.json(await (await S(c)).invoke('shop/complete-repair', { orderId: c.req.param('id') })),
199
- );
200
- app.post('/api/repairs/:id/close', async (c) =>
201
- c.json(await (await S(c)).invoke('shop/close-repair', { orderId: c.req.param('id') })),
202
- );
203
-
204
- // -- the customer portal (the per-entity proof walk) ------------------------
205
- app.get('/api/portal/repairs', async (c) =>
206
- pageJson(c, await (await S(c)).invoke('shop/portal-repairs', pageInput(c))),
207
- );
208
-
209
- // -- invoicing (the sibling engine, fed by event) ---------------------------
210
- app.get('/api/invoicing', async (c) =>
211
- pageJson(c, await (await S(c)).invoke('invoicing/list', pageInput(c))),
212
- );
213
- app.get('/api/invoicing/:id', async (c) =>
214
- c.json(await (await S(c)).invoke('invoicing/get', { underlagId: c.req.param('id') })),
215
- );
216
- app.post('/api/invoicing/:id/export', async (c) =>
217
- c.json(await (await S(c)).invoke('invoicing/export', { underlagId: c.req.param('id') })),
218
- );
82
+ // Returns what it mounted, in registration order — a test pins the complete
83
+ // method/path set, so a derived table that mounted nothing, moved a path or
84
+ // changed a verb fails loudly rather than passing over an empty app.
85
+ return mountOperations(app, ROUTED, resolveStub);
219
86
  }
@@ -314,7 +314,7 @@ app.all('/api/*', (c) => c.json({ error: `unknown route: ${new URL(c.req.raw.url
314
314
  app.all('*', (c) =>
315
315
  c.json({
316
316
  service: 'substrat vertical',
317
- api: 'POST /api/invoke { op, input } — plus the named routes in src/routes.ts',
317
+ api: 'POST /api/invoke { op, input } — plus the routes declared on the operations in src/operations.ts',
318
318
  docs: 'https://substrat.net',
319
319
  }),
320
320
  );
@@ -355,13 +355,43 @@ describe('bike-shop scenario', () => {
355
355
 
356
356
  // ROUTE-LEVEL, and it has to be: everything above calls operations through a
357
357
  // `ScopeStub`, where a paged read's answer IS the `Page`. On the wire it is not
358
- // — the entries are the body and the walk rides in a `Link` header — and this
359
- // template's route table is hand-written, so nothing else holds the two halves
360
- // together. A route that forwarded no cursor would pass every assertion above
361
- // and still pin its endpoint to page one forever.
358
+ // — the entries are the body and the walk rides in a `Link` header. The route
359
+ // table is derived from the operations' `http` declarations, so this is the
360
+ // one place that proves the derivation reached every route: a table that
361
+ // silently mounted nothing, or a paged read that forwarded no cursor, would
362
+ // pass every assertion above and still be broken in a browser.
362
363
  it('12. the HTTP routes forward the page and hand back a Link to the next one', async () => {
363
364
  const app = new Hono();
364
- mountApi(app, async () => greta);
365
+ const mounted = mountApi(app, async () => greta);
366
+
367
+ // Ten of this vertical's own, six of the work-order engine's, three of the
368
+ // invoicing engine's — every operation that declares a URL, and no more.
369
+ // Pinned as the complete method/path set rather than a count: this table is
370
+ // the public route table the hand-written one used to publish, and a count
371
+ // would let a wrong verb or a moved path leave nineteen entries and every
372
+ // walk below green. A derived table that mounted nothing would otherwise
373
+ // 404 its way through those walks with the same message for every route.
374
+ expect(mounted.map((r) => `${r.method} ${r.path}`).sort()).toEqual([
375
+ 'GET /api/customers',
376
+ 'GET /api/invoicing',
377
+ 'GET /api/invoicing/:underlagId',
378
+ 'GET /api/portal/repairs',
379
+ 'GET /api/prices',
380
+ 'GET /api/repairs',
381
+ 'GET /api/repairs/:entityId/timeline',
382
+ 'GET /api/repairs/:orderId',
383
+ 'POST /api/customers',
384
+ 'POST /api/customers/:customerId/bikes',
385
+ 'POST /api/invoicing/:underlagId/export',
386
+ 'POST /api/prices',
387
+ 'POST /api/repairs',
388
+ 'POST /api/repairs/:orderId/assign',
389
+ 'POST /api/repairs/:orderId/close',
390
+ 'POST /api/repairs/:orderId/complete',
391
+ 'POST /api/repairs/:orderId/material',
392
+ 'POST /api/repairs/:orderId/start',
393
+ 'POST /api/repairs/:orderId/time',
394
+ ]);
365
395
 
366
396
  const nextOf = (res: Response): string | null => {
367
397
  // `<http://localhost/api/prices?limit=1&cursor=labor>; rel="next"` — the
@@ -389,9 +419,11 @@ describe('bike-shop scenario', () => {
389
419
  expect(next).toBeNull();
390
420
  expect(walked).toEqual(['chain-9s', 'labor', 'shop-supplies', 'tube-28']);
391
421
 
392
- // The other hand-written paged routes forward the trio the same way. The
393
- // customer list is the one with its own keyset SQL; the timeline is the
394
- // kernel's own read, reached through a per-entity permission check.
422
+ // The other paged routes forward the trio the same way. The customer list is
423
+ // the one with its own keyset SQL; the timeline is the kernel's own read,
424
+ // reached through a per-entity permission check — and its route supplies
425
+ // `entityType` from the declared literal, which is why the URL carries only
426
+ // the id.
395
427
  const customers = await app.request('/api/customers?limit=1');
396
428
  expect(((await customers.json()) as { number: string }[]).map((c) => c.number)).toEqual([
397
429
  '2001',