create-substrat 0.9.4 → 0.9.6
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/playbook.md +1 -1
- package/template/AGENTS.md +24 -15
- package/template/src/operations.ts +85 -8
- package/template/src/provision.ts +8 -0
- 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.
|
|
39
|
+
const SUBSTRAT = '^0.111.0';
|
|
40
|
+
const ENGINE_WORKORDER = '^0.11.10';
|
|
41
|
+
const ENGINE_INVOICING = '^0.10.1';
|
|
42
42
|
const BOUNDARY_LINT = '^0.4.2';
|
|
43
|
-
const DEV_ISSUER = '^0.1.
|
|
43
|
+
const DEV_ISSUER = '^0.1.26';
|
|
44
44
|
|
|
45
45
|
const DOCS = 'https://substrat.net';
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -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,27 @@ 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
|
|
49
|
+
src/routes.ts the routes, DERIVED from the operations ← harness
|
|
50
50
|
src/server.ts the dev entrypoint (node + persona picker) ← harness
|
|
51
51
|
src/worker.ts the deployable Cloudflare worker ← harness
|
|
52
52
|
src/config-do.ts per-instance config store (Cloudflare only) ← harness
|
|
53
53
|
test/scenario.test.ts the scenario — including the denials
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
**A new route
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
**A new route is an `http` declaration on its operation, never a handler in an
|
|
57
|
+
entrypoint.** `src/routes.ts` holds no table: `mountOperations` (from
|
|
58
|
+
`@substrat-run/vertical-host`) derives one from the `http` each operation in
|
|
59
|
+
`src/operations.ts` declares — method, path, and which input fields the path
|
|
60
|
+
carries, compile-checked there — and both `server.ts` and `worker.ts` mount that
|
|
61
|
+
one derivation, so a route is live on both the moment it is declared. A route
|
|
62
|
+
added to only one entrypoint is a surface that works in dev and 404s in production
|
|
59
63
|
(or the reverse), which nothing catches until you deploy: the scenario tests call
|
|
60
|
-
operations directly and never boot either host.
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
operations directly and never boot either host. A composed engine's operations
|
|
65
|
+
carry no `http` of their own (an engine does not own a URL shape), so the vertical
|
|
66
|
+
binds them with `defineEngineRoutes` beside its own declarations. What an
|
|
67
|
+
entrypoint may still own is only what is genuinely its own — building a host,
|
|
68
|
+
resolving a caller, and its own auth-shaped routes (`/api/auth/*` and `/api/me`
|
|
69
|
+
in dev, `/api/me` in the worker).
|
|
63
70
|
|
|
64
71
|
`provision.ts` is deliberately node-free: both hosts register from it (the dev
|
|
65
72
|
server's SQLite host and the worker's `ScopeDO`), and `substrat push` reads the
|
|
@@ -161,15 +168,17 @@ walk, a table the registry does not carry — the handler composes its own and n
|
|
|
161
168
|
field the cursor walks (`paged: { sortKey: 'article' }`). Keyset, never offset: on
|
|
162
169
|
live data an offset shifts between requests, so pages drop and duplicate rows.
|
|
163
170
|
|
|
164
|
-
**A paged read has an HTTP half, and
|
|
171
|
+
**A paged read has an HTTP half, and the derived route does it for you.** The
|
|
165
172
|
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
|
-
|
|
173
|
+
must be able to walk a list with no response to read headers off — so the projection
|
|
174
|
+
happens at the edge: `mountOperations` forwards the page trio in (`limit`, `cursor`,
|
|
175
|
+
`order`, parsed with the platform's own defaults and ceiling) and hands the entries
|
|
176
|
+
back as the body with the walk in a `Link` header. Declare `http` on the paged
|
|
177
|
+
operation and both halves are there; hand-mount a paged read yourself and you have
|
|
178
|
+
to do both by hand — forget the first and the endpoint is pinned to page one no
|
|
179
|
+
matter what the operation supports, forget the second and it answers with an
|
|
180
|
+
envelope where it used to answer with an array. That is the case for not
|
|
181
|
+
hand-mounting anything an operation can declare.
|
|
173
182
|
|
|
174
183
|
## The rules (non-negotiable)
|
|
175
184
|
|
|
@@ -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,25 +19,44 @@ 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
|
/**
|
|
19
29
|
* The permission keys an operation here may name.
|
|
20
30
|
*
|
|
21
31
|
* Two of them are this vertical's own (they mirror `SHOP_PERM` in
|
|
22
|
-
* `src/manifest.ts`); the
|
|
23
|
-
* is listed because a vertical operation may be gated by one — this
|
|
24
|
-
* vocabulary a `permission` may draw on, not a second declaration of who
|
|
25
|
-
* the key. The engine still declares them.
|
|
32
|
+
* `src/manifest.ts`); the rest belong to the ENGINES this vertical composes. An
|
|
33
|
+
* engine key is listed because a vertical operation may be gated by one — this
|
|
34
|
+
* is the vocabulary a `permission` may draw on, not a second declaration of who
|
|
35
|
+
* owns the key. The engine still declares them.
|
|
36
|
+
*
|
|
37
|
+
* One array, two readers, and that is what makes it checked (#1208).
|
|
38
|
+
* `defineOperations` takes it below as the union a mistyped `permission:` fails
|
|
39
|
+
* against; `definePermissions` in `src/provision.ts` takes the SAME array as
|
|
40
|
+
* `keys` and throws at module load if it and `MODULES` disagree in either
|
|
41
|
+
* direction. So every key a registered module declares is here, including the
|
|
42
|
+
* ones no shop operation checks today — `MODULES` registers both engines, so a
|
|
43
|
+
* scope declares them, and an operation gated on one had no way to say so while
|
|
44
|
+
* the list held only the subset the shop happened to check.
|
|
26
45
|
*/
|
|
27
46
|
export const SHOP_PERMISSIONS = [
|
|
47
|
+
// The shop's own — `SHOP_PERM` in src/manifest.ts.
|
|
28
48
|
'customer:manage',
|
|
29
49
|
'bike:manage',
|
|
50
|
+
// @substrat-run/engine-workorder
|
|
30
51
|
'workorder:create',
|
|
31
52
|
'workorder:read',
|
|
53
|
+
'workorder:assign',
|
|
54
|
+
'workorder:report',
|
|
32
55
|
'workorder:complete',
|
|
33
56
|
'workorder:close',
|
|
57
|
+
// @substrat-run/engine-invoicing
|
|
58
|
+
'invoicing:read',
|
|
59
|
+
'invoicing:export',
|
|
34
60
|
] as const;
|
|
35
61
|
|
|
36
62
|
/**
|
|
@@ -84,10 +110,12 @@ export const bikeShopOperations = defineOperations(
|
|
|
84
110
|
}),
|
|
85
111
|
// The row shape comes from the registry — not restated here.
|
|
86
112
|
output: bikeShopEntities.customer.fields,
|
|
113
|
+
http: { method: 'POST', path: '/customers' },
|
|
87
114
|
},
|
|
88
115
|
'shop/list-customers': {
|
|
89
116
|
summary: 'List customers with the bikes they have registered',
|
|
90
117
|
permission: 'customer:manage',
|
|
118
|
+
http: { method: 'GET', path: '/customers' },
|
|
91
119
|
// The ENTRY, not the envelope — `paged` wraps it. The page also BOUNDS the
|
|
92
120
|
// hydration: one bikes query per customer ON THE PAGE, where an unpaged read
|
|
93
121
|
// ran one per customer in the scope.
|
|
@@ -108,6 +136,8 @@ export const bikeShopOperations = defineOperations(
|
|
|
108
136
|
frameNo: z.string().min(1).optional(),
|
|
109
137
|
}),
|
|
110
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' },
|
|
111
141
|
},
|
|
112
142
|
'shop/upsert-price': {
|
|
113
143
|
summary: 'Create or update a price-list article',
|
|
@@ -122,11 +152,13 @@ export const bikeShopOperations = defineOperations(
|
|
|
122
152
|
internal: z.boolean().optional(),
|
|
123
153
|
}),
|
|
124
154
|
output: priceRow,
|
|
155
|
+
http: { method: 'POST', path: '/prices' },
|
|
125
156
|
},
|
|
126
157
|
'shop/price-list': {
|
|
127
158
|
summary: 'The workshop price list',
|
|
128
159
|
permission: 'customer:manage',
|
|
129
160
|
output: priceRow,
|
|
161
|
+
http: { method: 'GET', path: '/prices' },
|
|
130
162
|
// Handler-composed rather than `over`: `shop_price_list` is value-keyed and
|
|
131
163
|
// deliberately not a declared entity, so the registry has no table for the
|
|
132
164
|
// kernel to index. It still pages, and still carries a cursor.
|
|
@@ -147,18 +179,21 @@ export const bikeShopOperations = defineOperations(
|
|
|
147
179
|
// and NOT the row: the engine stores `facility_type`/`facility_id` as two
|
|
148
180
|
// snake_case columns and publishes one `EntityRef` in camelCase.
|
|
149
181
|
output: workOrder,
|
|
182
|
+
http: { method: 'POST', path: '/repairs' },
|
|
150
183
|
},
|
|
151
184
|
'shop/complete-repair': {
|
|
152
185
|
summary: 'Complete a repair and price its billable lines',
|
|
153
186
|
permission: 'workorder:complete',
|
|
154
187
|
input: z.object({ orderId: z.string().min(1) }),
|
|
155
188
|
output: z.object({ order: workOrder, billable: z.array(billableLine), total: money }),
|
|
189
|
+
http: { method: 'POST', path: '/repairs/{orderId}/complete' },
|
|
156
190
|
},
|
|
157
191
|
'shop/close-repair': {
|
|
158
192
|
summary: 'Hand the bike back — completed to closed',
|
|
159
193
|
permission: 'workorder:close',
|
|
160
194
|
input: z.object({ orderId: z.string().min(1) }),
|
|
161
195
|
output: workOrder,
|
|
196
|
+
http: { method: 'POST', path: '/repairs/{orderId}/close' },
|
|
162
197
|
},
|
|
163
198
|
'shop/portal-repairs': {
|
|
164
199
|
summary: 'The repairs visible to the calling portal customer',
|
|
@@ -179,6 +214,7 @@ export const bikeShopOperations = defineOperations(
|
|
|
179
214
|
// compose. It pages by OVER-fetching, so a SHORT page does not end the walk
|
|
180
215
|
// — only an absent cursor does.
|
|
181
216
|
paged: { sortKey: 'id' },
|
|
217
|
+
http: { method: 'GET', path: '/portal/repairs' },
|
|
182
218
|
},
|
|
183
219
|
'shop/timeline': {
|
|
184
220
|
summary: 'The event timeline for one repair',
|
|
@@ -197,5 +233,46 @@ export const bikeShopOperations = defineOperations(
|
|
|
197
233
|
// The cursor is `id` — the event's ULID, which IS this entity's version at
|
|
198
234
|
// that point.
|
|
199
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' },
|
|
200
240
|
},
|
|
201
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
|
+
});
|
|
@@ -7,6 +7,7 @@ import { workorderModule, PERM as WO } from '@substrat-run/engine-workorder';
|
|
|
7
7
|
import { invoicingModule, INVOICING_PERM as INV } from '@substrat-run/engine-invoicing';
|
|
8
8
|
import { bikeShopModule } from './module.js';
|
|
9
9
|
import { SHOP_PERM } from './manifest.js';
|
|
10
|
+
import { SHOP_PERMISSIONS } from './operations.js';
|
|
10
11
|
|
|
11
12
|
// The manifest's config surface rides the same import `substrat push` already makes for
|
|
12
13
|
// `permissions` (#1206): this export is what the push uploads, so `src/manifest.ts` is the
|
|
@@ -73,9 +74,16 @@ export const ENTITY_GRANTS: { entityType: string; permissions: PermissionKey[] }
|
|
|
73
74
|
* The single typed source for this vertical's permission surface — what the
|
|
74
75
|
* permission checkpoint and `substrat push` read (via package.json
|
|
75
76
|
* `substrat.permissions`).
|
|
77
|
+
*
|
|
78
|
+
* `keys` is `SHOP_PERMISSIONS` — the array `src/operations.ts` already hands
|
|
79
|
+
* `defineOperations` as the union a mistyped `permission:` fails against — which
|
|
80
|
+
* is what makes that restatement checked (#1208): `definePermissions` throws at
|
|
81
|
+
* module load if it and `MODULES` disagree in either direction. Keep handing
|
|
82
|
+
* both readers the SAME array; a second copy is the thing this removes.
|
|
76
83
|
*/
|
|
77
84
|
export const permissions = definePermissions({
|
|
78
85
|
modules: MODULES,
|
|
79
86
|
roles: ROLES,
|
|
80
87
|
entityGrants: ENTITY_GRANTS,
|
|
88
|
+
keys: SHOP_PERMISSIONS,
|
|
81
89
|
});
|
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',
|