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 +4 -4
- package/package.json +1 -1
- package/template/.substrat/hooks/session-start.mjs +1 -1
- package/template/.substrat/playbook.md +1 -1
- package/template/AGENTS.md +32 -15
- package/template/src/operations.ts +65 -4
- package/template/src/routes.ts +45 -178
- package/template/src/worker.ts +1 -1
- package/template/test/scenario.test.ts +40 -8
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.
|
|
40
|
-
const ENGINE_WORKORDER = '^0.11.
|
|
41
|
-
const ENGINE_INVOICING = '^0.10.
|
|
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.
|
|
43
|
+
const DEV_ISSUER = '^0.1.27';
|
|
44
44
|
|
|
45
45
|
const DOCS = 'https://substrat.net';
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
package/template/AGENTS.md
CHANGED
|
@@ -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/
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 {
|
|
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,
|
|
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
|
+
});
|
package/template/src/routes.ts
CHANGED
|
@@ -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 —
|
|
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
|
|
20
|
-
*
|
|
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/
|
|
29
|
-
* `/api/me` in the worker) — those answer "who am I on THIS host"
|
|
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
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
111
|
-
// The kernel checks a permission inside EVERY operation,
|
|
112
|
-
// exactly as safe as one route per operation
|
|
113
|
-
//
|
|
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
|
|
79
|
+
return c.json((await (await resolveStub(c)).invoke(op, input)) ?? null);
|
|
117
80
|
});
|
|
118
81
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
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
|
}
|
package/template/src/worker.ts
CHANGED
|
@@ -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
|
|
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
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
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',
|