create-substrat 0.9.1 → 0.9.3
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/.claude/launch.json +1 -1
- package/template/.substrat/playbook.md +2 -2
- package/template/AGENTS.md +81 -1
- package/template/src/entities.ts +83 -0
- package/template/src/module.ts +111 -147
- package/template/src/operations.ts +201 -0
- package/template/src/routes.ts +82 -6
- package/template/src/server.ts +10 -1
- package/template/test/entities.test.ts +104 -0
- package/template/test/scenario.test.ts +126 -3
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.
|
|
41
|
-
const ENGINE_INVOICING = '^0.9.
|
|
39
|
+
const SUBSTRAT = '^0.105.0';
|
|
40
|
+
const ENGINE_WORKORDER = '^0.11.4';
|
|
41
|
+
const ENGINE_INVOICING = '^0.9.21';
|
|
42
42
|
const BOUNDARY_LINT = '^0.4.2';
|
|
43
|
-
const DEV_ISSUER = '^0.1.
|
|
43
|
+
const DEV_ISSUER = '^0.1.20';
|
|
44
44
|
|
|
45
45
|
const DOCS = 'https://substrat.net';
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -472,7 +472,7 @@ Build confidence in this order, and **show the user the output of each**:
|
|
|
472
472
|
pnpm install
|
|
473
473
|
pnpm test # the scenario, including the denials
|
|
474
474
|
npx @substrat-run/boundary-lint # the layer rules
|
|
475
|
-
pnpm dev # API on :
|
|
475
|
+
pnpm dev # API on :8891 (PORT=… WEB_PORT=… to move it)
|
|
476
476
|
```
|
|
477
477
|
|
|
478
478
|
Then **actually exercise it** — don't just report that the server started. A green scenario
|
|
@@ -519,7 +519,7 @@ Two more that each fail silently:
|
|
|
519
519
|
`notFoundHandling: "single-page-application"`, a missing `/api/*` entry answers every API
|
|
520
520
|
call with `index.html` — the app then reports parse errors instead of denials.
|
|
521
521
|
- **The app calls its own origin** (`fetch('/api' + path)`), never a baked base URL. The Vite
|
|
522
|
-
`proxy` block is a dev-only convenience; `VITE_API_URL` or `localhost:
|
|
522
|
+
`proxy` block is a dev-only convenience; `VITE_API_URL` or `localhost:8891` works on the
|
|
523
523
|
author's machine and reaches nothing from a phone.
|
|
524
524
|
|
|
525
525
|
`substrat push` refuses an `app/` that nothing would serve, so this cannot reach a hostname
|
package/template/AGENTS.md
CHANGED
|
@@ -39,9 +39,11 @@ The linter and tests expect this shape. `manifest`/`migrations`/`module` are **m
|
|
|
39
39
|
code** (the rules below bind them); `seed`/`server` are **harness** (exempt).
|
|
40
40
|
|
|
41
41
|
```
|
|
42
|
+
src/entities.ts defineEntities — WHAT EXISTS ← module code
|
|
43
|
+
src/operations.ts defineOperations — the declared surface ← module code
|
|
42
44
|
src/manifest.ts moduleManifest.parse({…}) + PERM consts ← module code
|
|
43
45
|
src/migrations.ts the SqlMigration[] ← module code
|
|
44
|
-
src/module.ts
|
|
46
|
+
src/module.ts the handlers, bound to the declaration ← module code
|
|
45
47
|
src/provision.ts MODULES, ROLES, grant shapes — node-free ← module code
|
|
46
48
|
src/seed.ts host, tenants, demo cast, seed world ← harness
|
|
47
49
|
src/routes.ts the HTTP route table — BOTH hosts mount it ← harness
|
|
@@ -91,6 +93,84 @@ tenant no matter what any of them saved. Declare a setting once, in
|
|
|
91
93
|
`src/manifest.ts` (`SHOP_ENV`) — `src/provision.ts` re-exports it as `envSpec`,
|
|
92
94
|
which is what `substrat push` uploads; package.json carries no copy.
|
|
93
95
|
|
|
96
|
+
## Declare what exists, then implement it
|
|
97
|
+
|
|
98
|
+
A vertical declares its **entities** and its **operations** in two typed modules, and
|
|
99
|
+
the compiler checks the joins between them. This is not documentation of the code —
|
|
100
|
+
it is the code's other half, and the reason a whole class of mistake stops being
|
|
101
|
+
something a reviewer has to catch.
|
|
102
|
+
|
|
103
|
+
`src/entities.ts` says what exists. Each entry names the table, the row shape as
|
|
104
|
+
`ctx.sql` returns it (snake_case included — a prettier second naming is exactly the
|
|
105
|
+
second description this removes), its natural `key`, its `parents` (the permission
|
|
106
|
+
walk), and which fields are `erasable`:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
export const bikeShopEntities = defineEntities({
|
|
110
|
+
customer: {
|
|
111
|
+
table: 'shop_customers',
|
|
112
|
+
fields: z.object({ id: z.string(), number: z.string(), name: z.string(), … }),
|
|
113
|
+
key: ['number'],
|
|
114
|
+
erasable: ['name', 'phone'],
|
|
115
|
+
},
|
|
116
|
+
bike: { table: 'shop_bikes', fields: …, parents: ['customer'] },
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Not every table is an entity. An entity is a thing the platform can point AT —
|
|
121
|
+
attachments hang off one, grants narrow to one, events are about one. `shop_price_list`
|
|
122
|
+
is keyed by article, has no id and is never the subject of an `EntityRef`, so it is
|
|
123
|
+
deliberately absent and its shape lives beside the operations that return it.
|
|
124
|
+
|
|
125
|
+
`src/operations.ts` says what each operation accepts, answers with, and is gated by —
|
|
126
|
+
against those entities and a declared list of permission keys:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
export const bikeShopOperations = defineOperations(bikeShopEntities, SHOP_PERMISSIONS)({
|
|
130
|
+
'shop/create-customer': {
|
|
131
|
+
summary: 'Register a workshop customer',
|
|
132
|
+
permission: 'customer:manage',
|
|
133
|
+
input: z.object({ number: z.string().min(1), name: z.string().min(1) }),
|
|
134
|
+
output: bikeShopEntities.customer.fields, // from the registry, not restated
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Then `src/module.ts` holds only the **bodies**, bound to that declaration:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
} satisfies OperationImpl<typeof bikeShopOperations, OperationContext>;
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Four things are now compile errors at the exact method: a handler whose input
|
|
146
|
+
disagrees with the declared `input`, one whose return disagrees with `output`, an
|
|
147
|
+
operation declared and not implemented, and one implemented and not declared. The
|
|
148
|
+
same object feeds `operationInputs: operationInputsOf(bikeShopOperations)`, so the
|
|
149
|
+
schemas the host parses with are the ones the declaration states — they cannot drift,
|
|
150
|
+
because there is only one of them.
|
|
151
|
+
|
|
152
|
+
**A list read must declare `paged`.** `defineOperations` refuses a bare-array `output`
|
|
153
|
+
that does not, and it refuses it at module load — so it fires in every build, every
|
|
154
|
+
test and every dev server rather than in a lint tool that has to find you. A list
|
|
155
|
+
endpoint returning the whole table is a bug with a delay on it: it passes review, it
|
|
156
|
+
passes tests, and then one tenant's table gets large. Declare the **entry** as
|
|
157
|
+
`output` and let `paged` wrap it; the handler returns `Page<Entry>`, which `pageOf`
|
|
158
|
+
builds. Either the kernel composes the walk (`paged: { over: { entity: 'customer',
|
|
159
|
+
sortable: ['number'] } }`) or, where it cannot — a kernel table, a per-row permission
|
|
160
|
+
walk, a table the registry does not carry — the handler composes its own and names the
|
|
161
|
+
field the cursor walks (`paged: { sortKey: 'article' }`). Keyset, never offset: on
|
|
162
|
+
live data an offset shifts between requests, so pages drop and duplicate rows.
|
|
163
|
+
|
|
164
|
+
**A paged read has an HTTP half, and this route table is hand-written.** The
|
|
165
|
+
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.
|
|
173
|
+
|
|
94
174
|
## The rules (non-negotiable)
|
|
95
175
|
|
|
96
176
|
**Module code** = everything reachable from a `ModuleRegistration` (operations,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineEntities, emitModel, z } from '@substrat-run/contracts';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The bike shop's ENTITY REGISTRY — what this vertical declares exists.
|
|
5
|
+
//
|
|
6
|
+
// This is the first half of the declared model: the entities, and then
|
|
7
|
+
// `src/operations.ts` declares the operations against them. The compiler checks
|
|
8
|
+
// the joins between the two, so an operation narrowing to an entity that does
|
|
9
|
+
// not exist, or emitting about a field the output does not carry, is a build
|
|
10
|
+
// error rather than something a reviewer has to notice.
|
|
11
|
+
//
|
|
12
|
+
// Field names mirror the SQL columns verbatim, snake_case included. They are the
|
|
13
|
+
// row shape as `ctx.sql` returns it, not a prettier domain model — a second
|
|
14
|
+
// naming would be exactly the second description this exists to remove.
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* NOT every table is an entity. `shop_price_list` is a table — keyed by article,
|
|
19
|
+
* no id, never the subject of an `EntityRef`, never a node in a permission walk
|
|
20
|
+
* — so it is deliberately absent here, and its row shape lives beside the
|
|
21
|
+
* operations that return it instead.
|
|
22
|
+
*
|
|
23
|
+
* An entity is a thing the platform can point AT: attachments hang off one,
|
|
24
|
+
* grants narrow to one, events are about one. The price list is data those
|
|
25
|
+
* things operate on.
|
|
26
|
+
*/
|
|
27
|
+
export const bikeShopEntities = defineEntities({
|
|
28
|
+
customer: {
|
|
29
|
+
table: 'shop_customers',
|
|
30
|
+
fields: z.object({
|
|
31
|
+
id: z.string(),
|
|
32
|
+
number: z.string(),
|
|
33
|
+
name: z.string(),
|
|
34
|
+
phone: z.string().nullable(),
|
|
35
|
+
created_at: z.string(),
|
|
36
|
+
}),
|
|
37
|
+
/** `number` is UNIQUE in 0001-init — the natural key a human quotes. */
|
|
38
|
+
key: ['number'],
|
|
39
|
+
/**
|
|
40
|
+
* A workshop customer is usually a private person, so their name and phone
|
|
41
|
+
* are erasure-reachable. Declaring it is what keeps an event payload from
|
|
42
|
+
* ever carrying them: an immutable event is the one place in a scope an
|
|
43
|
+
* erasure cannot reach, and the compiler enforces the omission.
|
|
44
|
+
*/
|
|
45
|
+
erasable: ['name', 'phone'],
|
|
46
|
+
},
|
|
47
|
+
bike: {
|
|
48
|
+
table: 'shop_bikes',
|
|
49
|
+
fields: z.object({
|
|
50
|
+
id: z.string(),
|
|
51
|
+
customer_id: z.string(),
|
|
52
|
+
label: z.string(),
|
|
53
|
+
frame_no: z.string().nullable(),
|
|
54
|
+
created_at: z.string(),
|
|
55
|
+
}),
|
|
56
|
+
/**
|
|
57
|
+
* Permission flows bike → customer, which is the first hop of the portal
|
|
58
|
+
* walk (workorder → bike → customer). The manifest declares the same edge
|
|
59
|
+
* for `ctx.link`; this is the model's half of it.
|
|
60
|
+
*/
|
|
61
|
+
parents: ['customer'],
|
|
62
|
+
/**
|
|
63
|
+
* A frame number identifies a bike, and a bike identifies its owner — it is
|
|
64
|
+
* the serial a police report quotes. Pseudonymous rather than direct, which
|
|
65
|
+
* is exactly why it is easy to leave out: an erasure that kept it would
|
|
66
|
+
* leave a pointer to the person it just erased. Declaring it here is what
|
|
67
|
+
* keeps a future `bike.*` event payload from carrying it, since an immutable
|
|
68
|
+
* event is the one place in a scope an erasure cannot reach.
|
|
69
|
+
*/
|
|
70
|
+
erasable: ['frame_no'],
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The artifact of record, emitted from the declaration above.
|
|
76
|
+
*
|
|
77
|
+
* A scaffolded project is not in this repo's `demos/`, so nothing here re-emits
|
|
78
|
+
* a `model.json` for it — `pnpm lint:model` walks `demos/` and `engines/`. What
|
|
79
|
+
* this export is for in a scaffold is the same thing it is for in the reference
|
|
80
|
+
* verticals: one object downstream reads, so the authoring notation stays
|
|
81
|
+
* swappable.
|
|
82
|
+
*/
|
|
83
|
+
export const bikeShopModel = emitModel(bikeShopEntities);
|
package/template/src/module.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
2
|
addDecimal,
|
|
3
3
|
compareDecimal,
|
|
4
|
+
listLimitOf,
|
|
4
5
|
moneyOf,
|
|
5
6
|
mulMoney,
|
|
6
7
|
operationInputsOf,
|
|
8
|
+
pageOf,
|
|
7
9
|
pageVisible,
|
|
8
10
|
z,
|
|
9
|
-
type
|
|
10
|
-
type
|
|
11
|
+
type HandlerInput,
|
|
12
|
+
type HandlerOutput,
|
|
13
|
+
type OperationImpl,
|
|
11
14
|
} from '@substrat-run/contracts';
|
|
12
15
|
import {
|
|
13
16
|
assertAllowed,
|
|
17
|
+
readTimeline,
|
|
14
18
|
ulid,
|
|
15
19
|
type ModuleRegistration,
|
|
20
|
+
type OperationContext,
|
|
16
21
|
type OperationHandler,
|
|
17
|
-
type PageParams,
|
|
18
22
|
} from '@substrat-run/kernel';
|
|
19
23
|
import {
|
|
20
24
|
closeWorkOrder,
|
|
@@ -24,13 +28,14 @@ import {
|
|
|
24
28
|
listOrders,
|
|
25
29
|
PERM as WO,
|
|
26
30
|
type BillableLine,
|
|
27
|
-
type WorkOrder,
|
|
28
31
|
} from '@substrat-run/engine-workorder';
|
|
32
|
+
import { bikeShopEntities } from './entities.js';
|
|
33
|
+
import { bikeShopOperations, priceRow } from './operations.js';
|
|
29
34
|
import { bikeShopManifest, SHOP_PERM } from './manifest.js';
|
|
30
35
|
import { bikeShopMigrations } from './migrations.js';
|
|
31
36
|
|
|
32
37
|
// ============================================================================
|
|
33
|
-
// The bike-shop
|
|
38
|
+
// The bike-shop HANDLERS. Each is either:
|
|
34
39
|
// - a thin custodian of the vertical's own tables (customers, bikes, prices),
|
|
35
40
|
// OR
|
|
36
41
|
// - a COMPOSITION that wraps an engine's in-scope function inside the same
|
|
@@ -38,48 +43,33 @@ import { bikeShopMigrations } from './migrations.js';
|
|
|
38
43
|
//
|
|
39
44
|
// Every operation's FIRST line is the permission check. Data access is
|
|
40
45
|
// `ctx.sql` only. No `fetch`, no `node:*`, no other engine's tables.
|
|
46
|
+
//
|
|
47
|
+
// WHAT EACH OPERATION IS is declared in `src/operations.ts`, against the
|
|
48
|
+
// entities in `src/entities.ts` — this file holds only the bodies. The binding
|
|
49
|
+
// at the bottom is `satisfies OperationImpl<…>`, so a handler that disagrees
|
|
50
|
+
// with its declaration is a compile error at the exact method.
|
|
41
51
|
// ============================================================================
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
phone: string | null;
|
|
48
|
-
created_at: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface BikeRow {
|
|
52
|
-
id: string;
|
|
53
|
-
customer_id: string;
|
|
54
|
-
label: string;
|
|
55
|
-
frame_no: string | null;
|
|
56
|
-
created_at: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface PriceRow {
|
|
60
|
-
article: string;
|
|
61
|
-
description: string;
|
|
62
|
-
unit: string;
|
|
63
|
-
price_amount: string;
|
|
64
|
-
currency: string;
|
|
65
|
-
min_qty: string | null;
|
|
66
|
-
internal: number;
|
|
67
|
-
}
|
|
53
|
+
/** The row shapes, read off the registry so there is one description of each. */
|
|
54
|
+
export type CustomerRow = z.infer<typeof bikeShopEntities.customer.fields>;
|
|
55
|
+
export type BikeRow = z.infer<typeof bikeShopEntities.bike.fields>;
|
|
56
|
+
export type PriceRow = z.infer<typeof priceRow>;
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
58
|
+
/**
|
|
59
|
+
* One handler's signature, derived from its declaration.
|
|
60
|
+
*
|
|
61
|
+
* `HandlerInput` resolves what the host will hand in — the declared `input`,
|
|
62
|
+
* plus the page trio when the operation is `paged` — and `HandlerOutput`
|
|
63
|
+
* resolves what it must answer with, wrapping the declared entry in a `Page`
|
|
64
|
+
* for a paged read. Neither is restated here, so a change to `operations.ts`
|
|
65
|
+
* lands on the handler as a type error rather than as a silent disagreement.
|
|
66
|
+
*/
|
|
67
|
+
type Op<K extends keyof typeof bikeShopOperations> = OperationHandler<
|
|
68
|
+
HandlerInput<(typeof bikeShopOperations)[K]>,
|
|
69
|
+
HandlerOutput<(typeof bikeShopOperations)[K]>
|
|
70
|
+
>;
|
|
78
71
|
|
|
79
|
-
const createCustomerOp:
|
|
80
|
-
ctx,
|
|
81
|
-
input,
|
|
82
|
-
) => {
|
|
72
|
+
const createCustomerOp: Op<'shop/create-customer'> = async (ctx, input) => {
|
|
83
73
|
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
84
74
|
const id = ulid();
|
|
85
75
|
ctx.sql.exec(
|
|
@@ -89,29 +79,34 @@ const createCustomerOp: OperationHandler<z.infer<typeof createCustomerInput>, Cu
|
|
|
89
79
|
return ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [id])[0]!;
|
|
90
80
|
};
|
|
91
81
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The customer list, hydrated with each customer's bikes.
|
|
84
|
+
*
|
|
85
|
+
* Handler-composed keyset paging over `number`, the customer's natural key —
|
|
86
|
+
* keyset and never offset, because on live data an offset shifts between
|
|
87
|
+
* requests and pages then drop and duplicate rows. The page also BOUNDS the
|
|
88
|
+
* hydration: one bikes query per customer ON THE PAGE, where the unpaged read
|
|
89
|
+
* this replaced ran one per customer in the whole scope.
|
|
90
|
+
*/
|
|
91
|
+
const listCustomersOp: Op<'shop/list-customers'> = async (ctx, input) => {
|
|
95
92
|
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
96
|
-
const
|
|
97
|
-
|
|
93
|
+
const limit = listLimitOf(input?.limit);
|
|
94
|
+
const customers = input?.cursor
|
|
95
|
+
? ctx.sql.query<CustomerRow>(
|
|
96
|
+
'SELECT * FROM shop_customers WHERE number > ? ORDER BY number LIMIT ?',
|
|
97
|
+
[input.cursor, limit],
|
|
98
|
+
)
|
|
99
|
+
: ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers ORDER BY number LIMIT ?', [limit]);
|
|
100
|
+
const hydrated = customers.map((c) => ({
|
|
98
101
|
...c,
|
|
99
102
|
bikes: ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE customer_id = ? ORDER BY label', [
|
|
100
103
|
c.id,
|
|
101
104
|
]),
|
|
102
105
|
}));
|
|
106
|
+
return pageOf(hydrated, limit, (row) => row.number);
|
|
103
107
|
};
|
|
104
108
|
|
|
105
|
-
const
|
|
106
|
-
customerId: z.string().min(1),
|
|
107
|
-
label: z.string().min(1),
|
|
108
|
-
frameNo: z.string().min(1).optional(),
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const registerBikeOp: OperationHandler<z.infer<typeof registerBikeInput>, BikeRow> = async (
|
|
112
|
-
ctx,
|
|
113
|
-
input,
|
|
114
|
-
) => {
|
|
109
|
+
const registerBikeOp: Op<'shop/register-bike'> = async (ctx, input) => {
|
|
115
110
|
assertAllowed(await ctx.check(SHOP_PERM.bikeManage));
|
|
116
111
|
const customer = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [
|
|
117
112
|
input.customerId,
|
|
@@ -128,20 +123,7 @@ const registerBikeOp: OperationHandler<z.infer<typeof registerBikeInput>, BikeRo
|
|
|
128
123
|
return ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [id])[0]!;
|
|
129
124
|
};
|
|
130
125
|
|
|
131
|
-
const
|
|
132
|
-
article: z.string().min(1),
|
|
133
|
-
description: z.string().min(1),
|
|
134
|
-
unit: z.string().min(1),
|
|
135
|
-
priceAmount: z.string().min(1),
|
|
136
|
-
currency: z.string().min(1).optional(),
|
|
137
|
-
minQty: z.string().min(1).optional(),
|
|
138
|
-
internal: z.boolean().optional(),
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
const upsertPriceOp: OperationHandler<z.infer<typeof upsertPriceInput>, PriceRow> = async (
|
|
142
|
-
ctx,
|
|
143
|
-
input,
|
|
144
|
-
) => {
|
|
126
|
+
const upsertPriceOp: Op<'shop/upsert-price'> = async (ctx, input) => {
|
|
145
127
|
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
146
128
|
ctx.sql.exec(
|
|
147
129
|
`INSERT OR REPLACE INTO shop_price_list
|
|
@@ -162,9 +144,21 @@ const upsertPriceOp: OperationHandler<z.infer<typeof upsertPriceInput>, PriceRow
|
|
|
162
144
|
])[0]!;
|
|
163
145
|
};
|
|
164
146
|
|
|
165
|
-
|
|
147
|
+
/**
|
|
148
|
+
* The price list, paged. Handler-composed (see the declaration): a value-keyed
|
|
149
|
+
* table the entity registry deliberately does not carry, so there is no indexed
|
|
150
|
+
* entity for the kernel to walk. Keyset over `article`, its natural key.
|
|
151
|
+
*/
|
|
152
|
+
const priceListOp: Op<'shop/price-list'> = async (ctx, input) => {
|
|
166
153
|
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
167
|
-
|
|
154
|
+
const limit = listLimitOf(input?.limit);
|
|
155
|
+
const rows = input?.cursor
|
|
156
|
+
? ctx.sql.query<PriceRow>(
|
|
157
|
+
'SELECT * FROM shop_price_list WHERE article > ? ORDER BY article LIMIT ?',
|
|
158
|
+
[input.cursor, limit],
|
|
159
|
+
)
|
|
160
|
+
: ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list ORDER BY article LIMIT ?', [limit]);
|
|
161
|
+
return pageOf(rows, limit, (row) => row.article);
|
|
168
162
|
};
|
|
169
163
|
|
|
170
164
|
/**
|
|
@@ -172,17 +166,7 @@ const priceListOp: OperationHandler<undefined, PriceRow[]> = async (ctx) => {
|
|
|
172
166
|
* The vertical resolves its own vocabulary (a bike, its owner) into the engine's
|
|
173
167
|
* `facility`/`customer` refs; the engine owns the number, the state, the event.
|
|
174
168
|
*/
|
|
175
|
-
const
|
|
176
|
-
bikeId: z.string().min(1),
|
|
177
|
-
kind: z.string().min(1),
|
|
178
|
-
title: z.string().min(1),
|
|
179
|
-
description: z.string().optional(),
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const createRepairOp: OperationHandler<z.infer<typeof createRepairInput>, WorkOrder> = async (
|
|
183
|
-
ctx,
|
|
184
|
-
input,
|
|
185
|
-
) => {
|
|
169
|
+
const createRepairOp: Op<'shop/create-repair'> = async (ctx, input) => {
|
|
186
170
|
assertAllowed(await ctx.check(WO.create));
|
|
187
171
|
const bike = ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [input.bikeId])[0];
|
|
188
172
|
if (!bike) throw new Error(`bike not found: ${input.bikeId}`);
|
|
@@ -204,12 +188,7 @@ const createRepairOp: OperationHandler<z.infer<typeof createRepairInput>, WorkOr
|
|
|
204
188
|
* The engine's `workorder.completed` event carries these lines, and the
|
|
205
189
|
* invoicing engine consumes it — no import between the two.
|
|
206
190
|
*/
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
const completeRepairOp: OperationHandler<
|
|
210
|
-
z.infer<typeof repairIdInput>,
|
|
211
|
-
{ order: WorkOrder; billable: BillableLine[]; total: Money }
|
|
212
|
-
> = async (ctx, input) => {
|
|
191
|
+
const completeRepairOp: Op<'shop/complete-repair'> = async (ctx, input) => {
|
|
213
192
|
assertAllowed(await ctx.check(WO.complete));
|
|
214
193
|
const reported = getReportedLines(ctx, input.orderId);
|
|
215
194
|
const prices = new Map<string, PriceRow>(
|
|
@@ -264,10 +243,7 @@ const completeRepairOp: OperationHandler<
|
|
|
264
243
|
* engine's in-scope `closeWorkOrder`; the vertical owns the vocabulary
|
|
265
244
|
* ("pickup"), the engine owns the transition.
|
|
266
245
|
*/
|
|
267
|
-
const closeRepairOp:
|
|
268
|
-
ctx,
|
|
269
|
-
input,
|
|
270
|
-
) => {
|
|
246
|
+
const closeRepairOp: Op<'shop/close-repair'> = async (ctx, input) => {
|
|
271
247
|
assertAllowed(await ctx.check(WO.close));
|
|
272
248
|
return closeWorkOrder(ctx, { orderId: input.orderId });
|
|
273
249
|
};
|
|
@@ -286,10 +262,7 @@ const closeRepairOp: OperationHandler<z.infer<typeof repairIdInput>, WorkOrder>
|
|
|
286
262
|
* on the next request, and a page the walk rejects entirely would never advance at
|
|
287
263
|
* all. So a SHORT page does not end this walk; only a null `nextCursor` does.
|
|
288
264
|
*/
|
|
289
|
-
const portalRepairsOp:
|
|
290
|
-
ctx,
|
|
291
|
-
input,
|
|
292
|
-
) =>
|
|
265
|
+
const portalRepairsOp: Op<'shop/portal-repairs'> = async (ctx, input) =>
|
|
293
266
|
pageVisible(
|
|
294
267
|
(p) => listOrders(ctx, { ...input, ...p }),
|
|
295
268
|
input,
|
|
@@ -297,70 +270,61 @@ const portalRepairsOp: OperationHandler<PageParams | undefined, Page<WorkOrder>>
|
|
|
297
270
|
(await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id })).allowed,
|
|
298
271
|
);
|
|
299
272
|
|
|
300
|
-
const timelineInput = z.object({
|
|
301
|
-
entityType: z.string().min(1),
|
|
302
|
-
entityId: z.string().min(1),
|
|
303
|
-
});
|
|
304
|
-
|
|
305
273
|
/**
|
|
306
274
|
* An entity's event timeline, read straight off the spine (a read of `_substrat_*`
|
|
307
275
|
* for a projection is allowed; writing it is not). Gated by a per-entity
|
|
308
276
|
* `workorder:read` check, so it obeys the same walk as the portal.
|
|
309
277
|
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
278
|
+
* `readTimeline` rather than a `SELECT` of our own: it takes an `EntityRef`,
|
|
279
|
+
* pages like a list read, and DECODES the envelope — `actor` comes back as the
|
|
280
|
+
* union the spine recorded, where `SELECT actor` returns a string that looks
|
|
281
|
+
* usable and is not. It checks no permission; we do, above, as always.
|
|
282
|
+
*
|
|
283
|
+
* No `.parse` in here: the host already parsed `entity` against `timelineInput`
|
|
284
|
+
* before this line ran, on whichever path the call came in by.
|
|
312
285
|
*/
|
|
313
|
-
const timelineOp:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
286
|
+
const timelineOp: Op<'shop/timeline'> = async (ctx, input) => {
|
|
287
|
+
// The operation is `paged`, so the host hands the page trio in on the SAME
|
|
288
|
+
// object as the entity's two fields. An `EntityRef` is exactly those two, so
|
|
289
|
+
// name them rather than passing the whole input — `ctx.check` and
|
|
290
|
+
// `readTimeline` should be given a ref, not a ref with a cursor stuck to it.
|
|
291
|
+
const entity = { entityType: input.entityType, entityId: input.entityId };
|
|
317
292
|
assertAllowed(await ctx.check(WO.read, entity));
|
|
318
|
-
|
|
319
|
-
// millisecond are not mutually ordered).
|
|
320
|
-
return ctx.sql.query(
|
|
321
|
-
`SELECT type, occurred_at, actor FROM _substrat_outbox
|
|
322
|
-
WHERE entity_type = ? AND entity_id = ? ORDER BY rowid`,
|
|
323
|
-
[entity.entityType, entity.entityId],
|
|
324
|
-
);
|
|
293
|
+
return readTimeline(ctx, entity, input);
|
|
325
294
|
};
|
|
326
295
|
|
|
327
296
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
297
|
+
* The handlers, bound to `bikeShopOperations`. `satisfies` is the drift
|
|
298
|
+
* detector: change a declared input or return and tsc names the method whose
|
|
299
|
+
* handler no longer agrees. An operation declared but not implemented, or
|
|
300
|
+
* implemented but not declared, is an error here too.
|
|
332
301
|
*/
|
|
333
|
-
const
|
|
334
|
-
'shop/create-customer':
|
|
335
|
-
'shop/list-customers':
|
|
336
|
-
'shop/register-bike':
|
|
337
|
-
'shop/upsert-price':
|
|
338
|
-
'shop/price-list':
|
|
339
|
-
'shop/create-repair':
|
|
340
|
-
'shop/complete-repair':
|
|
341
|
-
'shop/close-repair':
|
|
342
|
-
'shop/portal-repairs':
|
|
343
|
-
'shop/timeline':
|
|
344
|
-
}
|
|
302
|
+
const declaredOperations = {
|
|
303
|
+
'shop/create-customer': createCustomerOp,
|
|
304
|
+
'shop/list-customers': listCustomersOp,
|
|
305
|
+
'shop/register-bike': registerBikeOp,
|
|
306
|
+
'shop/upsert-price': upsertPriceOp,
|
|
307
|
+
'shop/price-list': priceListOp,
|
|
308
|
+
'shop/create-repair': createRepairOp,
|
|
309
|
+
'shop/complete-repair': completeRepairOp,
|
|
310
|
+
'shop/close-repair': closeRepairOp,
|
|
311
|
+
'shop/portal-repairs': portalRepairsOp,
|
|
312
|
+
'shop/timeline': timelineOp,
|
|
313
|
+
} satisfies OperationImpl<typeof bikeShopOperations, OperationContext>;
|
|
345
314
|
|
|
346
315
|
export const bikeShopModule: ModuleRegistration = {
|
|
347
316
|
manifest: bikeShopManifest,
|
|
348
317
|
migrations: bikeShopMigrations,
|
|
349
|
-
// The host parses every invocation against
|
|
350
|
-
// permission check and the handler — so "parse, don't trust"
|
|
351
|
-
// path in (HTTP, test, seed, schedule) rather than in the
|
|
352
|
-
// remembered to do it themselves.
|
|
318
|
+
// The host parses every invocation against the DECLARED input schemas before
|
|
319
|
+
// the guards, the permission check and the handler — so "parse, don't trust"
|
|
320
|
+
// holds on every path in (HTTP, test, seed, schedule) rather than in the
|
|
321
|
+
// handlers that remembered to do it themselves.
|
|
353
322
|
operationInputs: operationInputsOf(bikeShopOperations),
|
|
354
323
|
operations: {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
'shop/create-repair': createRepairOp as never,
|
|
361
|
-
'shop/complete-repair': completeRepairOp as never,
|
|
362
|
-
'shop/close-repair': closeRepairOp as never,
|
|
363
|
-
'shop/portal-repairs': portalRepairsOp as never,
|
|
364
|
-
'shop/timeline': timelineOp as never,
|
|
324
|
+
// All ten bound to the declaration: input and return are checked against
|
|
325
|
+
// `bikeShopOperations` at the exact method. The `as never` casts this map
|
|
326
|
+
// used to carry were never necessary — `OperationHandler<never, unknown>`
|
|
327
|
+
// accepts any handler by contravariance — they simply threw the types away.
|
|
328
|
+
...(declaredOperations as Record<string, OperationHandler<never, unknown>>),
|
|
365
329
|
},
|
|
366
330
|
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { defineOperations, money, timelineEntry, z } from '@substrat-run/contracts';
|
|
2
|
+
import { billableLine, workOrder, workorderEntities } from '@substrat-run/engine-workorder';
|
|
3
|
+
import { bikeShopEntities } from './entities.js';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// The bike shop's DECLARED OPERATION SURFACE — what each operation accepts,
|
|
7
|
+
// what it answers with, and which permission gates it.
|
|
8
|
+
//
|
|
9
|
+
// This is one declaration, not documentation of another one. `src/module.ts`
|
|
10
|
+
// binds its handlers to this object with `satisfies OperationImpl<…>`, so four
|
|
11
|
+
// things become compile errors at the exact method: a handler whose input
|
|
12
|
+
// disagrees with `input`, one whose return disagrees with `output`, an
|
|
13
|
+
// operation declared and not implemented, and one implemented and not declared.
|
|
14
|
+
// `operationInputsOf(bikeShopOperations)` hands the host the same schemas, so
|
|
15
|
+
// every invocation is parsed before the guards and the handler.
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The permission keys an operation here may name.
|
|
20
|
+
*
|
|
21
|
+
* Two of them are this vertical's own (they mirror `SHOP_PERM` in
|
|
22
|
+
* `src/manifest.ts`); the other four are the WORKORDER ENGINE's. An engine key
|
|
23
|
+
* is listed because a vertical operation may be gated by one — this is the
|
|
24
|
+
* vocabulary a `permission` may draw on, not a second declaration of who owns
|
|
25
|
+
* the key. The engine still declares them.
|
|
26
|
+
*/
|
|
27
|
+
export const SHOP_PERMISSIONS = [
|
|
28
|
+
'customer:manage',
|
|
29
|
+
'bike:manage',
|
|
30
|
+
'workorder:create',
|
|
31
|
+
'workorder:read',
|
|
32
|
+
'workorder:complete',
|
|
33
|
+
'workorder:close',
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The engine entity registries this vertical composes — `defineOperations`'s
|
|
38
|
+
* third argument, and what lets `shop/timeline` narrow its check to a
|
|
39
|
+
* `workorder` rather than settling for a node-level check on a key of its own.
|
|
40
|
+
*/
|
|
41
|
+
const SHOP_ENGINE_ENTITIES = [workorderEntities] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A price-list row. A TABLE, not an entity (see `src/entities.ts`), so its shape
|
|
45
|
+
* is declared here rather than in the registry.
|
|
46
|
+
*/
|
|
47
|
+
export const priceRow = z.object({
|
|
48
|
+
article: z.string(),
|
|
49
|
+
description: z.string(),
|
|
50
|
+
unit: z.string(),
|
|
51
|
+
price_amount: z.string(),
|
|
52
|
+
currency: z.string(),
|
|
53
|
+
min_qty: z.string().nullable(),
|
|
54
|
+
internal: z.number(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Shop policy: a timeline is read for a repair. Declared once here and parsed by
|
|
59
|
+
* the host — the handler never re-parses it, which is the point of the model
|
|
60
|
+
* being TypeScript rather than prose.
|
|
61
|
+
*
|
|
62
|
+
* `entityType` is a literal rather than an open string. The handler is genuinely
|
|
63
|
+
* entity-agnostic — `ctx.check` is handed whatever ref the caller names — but
|
|
64
|
+
* "any entity at all" was never what the vertical meant, and an open string
|
|
65
|
+
* would cost the permission declaration below its accuracy.
|
|
66
|
+
*/
|
|
67
|
+
export const timelineInput = z.object({
|
|
68
|
+
entityType: z.literal('workorder'),
|
|
69
|
+
entityId: z.string().min(1),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const bikeShopOperations = defineOperations(
|
|
73
|
+
bikeShopEntities,
|
|
74
|
+
SHOP_PERMISSIONS,
|
|
75
|
+
SHOP_ENGINE_ENTITIES,
|
|
76
|
+
)({
|
|
77
|
+
'shop/create-customer': {
|
|
78
|
+
summary: 'Register a workshop customer',
|
|
79
|
+
permission: 'customer:manage',
|
|
80
|
+
input: z.object({
|
|
81
|
+
number: z.string().min(1),
|
|
82
|
+
name: z.string().min(1),
|
|
83
|
+
phone: z.string().min(1).optional(),
|
|
84
|
+
}),
|
|
85
|
+
// The row shape comes from the registry — not restated here.
|
|
86
|
+
output: bikeShopEntities.customer.fields,
|
|
87
|
+
},
|
|
88
|
+
'shop/list-customers': {
|
|
89
|
+
summary: 'List customers with the bikes they have registered',
|
|
90
|
+
permission: 'customer:manage',
|
|
91
|
+
// The ENTRY, not the envelope — `paged` wraps it. The page also BOUNDS the
|
|
92
|
+
// hydration: one bikes query per customer ON THE PAGE, where an unpaged read
|
|
93
|
+
// ran one per customer in the scope.
|
|
94
|
+
output: bikeShopEntities.customer.fields.extend({
|
|
95
|
+
bikes: z.array(bikeShopEntities.bike.fields),
|
|
96
|
+
}),
|
|
97
|
+
// Handler-composed, keyset over the customer's natural key. A list read that
|
|
98
|
+
// answered with the whole table would be a bug with a delay on it: it passes
|
|
99
|
+
// review, it passes tests, and then one workshop's table gets large.
|
|
100
|
+
paged: { sortKey: 'number' },
|
|
101
|
+
},
|
|
102
|
+
'shop/register-bike': {
|
|
103
|
+
summary: 'Register a bike against a customer',
|
|
104
|
+
permission: 'bike:manage',
|
|
105
|
+
input: z.object({
|
|
106
|
+
customerId: z.string().min(1),
|
|
107
|
+
label: z.string().min(1),
|
|
108
|
+
frameNo: z.string().min(1).optional(),
|
|
109
|
+
}),
|
|
110
|
+
output: bikeShopEntities.bike.fields,
|
|
111
|
+
},
|
|
112
|
+
'shop/upsert-price': {
|
|
113
|
+
summary: 'Create or update a price-list article',
|
|
114
|
+
permission: 'customer:manage',
|
|
115
|
+
input: z.object({
|
|
116
|
+
article: z.string().min(1),
|
|
117
|
+
description: z.string().min(1),
|
|
118
|
+
unit: z.string().min(1),
|
|
119
|
+
priceAmount: z.string().min(1),
|
|
120
|
+
currency: z.string().min(1).optional(),
|
|
121
|
+
minQty: z.string().min(1).optional(),
|
|
122
|
+
internal: z.boolean().optional(),
|
|
123
|
+
}),
|
|
124
|
+
output: priceRow,
|
|
125
|
+
},
|
|
126
|
+
'shop/price-list': {
|
|
127
|
+
summary: 'The workshop price list',
|
|
128
|
+
permission: 'customer:manage',
|
|
129
|
+
output: priceRow,
|
|
130
|
+
// Handler-composed rather than `over`: `shop_price_list` is value-keyed and
|
|
131
|
+
// deliberately not a declared entity, so the registry has no table for the
|
|
132
|
+
// kernel to index. It still pages, and still carries a cursor.
|
|
133
|
+
paged: { sortKey: 'article' },
|
|
134
|
+
},
|
|
135
|
+
'shop/create-repair': {
|
|
136
|
+
summary: 'Open a repair against a bike',
|
|
137
|
+
// An ENGINE key: the vertical owns the word "repair", the engine owns the
|
|
138
|
+
// work order it is.
|
|
139
|
+
permission: 'workorder:create',
|
|
140
|
+
input: z.object({
|
|
141
|
+
bikeId: z.string().min(1),
|
|
142
|
+
kind: z.string().min(1),
|
|
143
|
+
title: z.string().min(1),
|
|
144
|
+
description: z.string().optional(),
|
|
145
|
+
}),
|
|
146
|
+
// The engine's published type, not a transcription of it. Note `workOrder`
|
|
147
|
+
// and NOT the row: the engine stores `facility_type`/`facility_id` as two
|
|
148
|
+
// snake_case columns and publishes one `EntityRef` in camelCase.
|
|
149
|
+
output: workOrder,
|
|
150
|
+
},
|
|
151
|
+
'shop/complete-repair': {
|
|
152
|
+
summary: 'Complete a repair and price its billable lines',
|
|
153
|
+
permission: 'workorder:complete',
|
|
154
|
+
input: z.object({ orderId: z.string().min(1) }),
|
|
155
|
+
output: z.object({ order: workOrder, billable: z.array(billableLine), total: money }),
|
|
156
|
+
},
|
|
157
|
+
'shop/close-repair': {
|
|
158
|
+
summary: 'Hand the bike back — completed to closed',
|
|
159
|
+
permission: 'workorder:close',
|
|
160
|
+
input: z.object({ orderId: z.string().min(1) }),
|
|
161
|
+
output: workOrder,
|
|
162
|
+
},
|
|
163
|
+
'shop/portal-repairs': {
|
|
164
|
+
summary: 'The repairs visible to the calling portal customer',
|
|
165
|
+
/**
|
|
166
|
+
* No node-level permission, stated rather than left as an absence: a portal
|
|
167
|
+
* customer holds an entity-narrowed `workorder:read` on their own customer
|
|
168
|
+
* record and nothing at the node, so a blanket check would deny every one of
|
|
169
|
+
* them. Visibility is decided per row by the proof walk instead.
|
|
170
|
+
*/
|
|
171
|
+
narrows: {
|
|
172
|
+
reason: 'a portal customer sees their own repairs, not a denial',
|
|
173
|
+
// Walks on `workorder:read` alone — an engine key, declared by the engine.
|
|
174
|
+
checks: [],
|
|
175
|
+
},
|
|
176
|
+
output: workOrder,
|
|
177
|
+
// Handler-composed, and it has to be: visibility here is decided by a
|
|
178
|
+
// per-row walk, not by a column, so there is no `WHERE` the kernel could
|
|
179
|
+
// compose. It pages by OVER-fetching, so a SHORT page does not end the walk
|
|
180
|
+
// — only an absent cursor does.
|
|
181
|
+
paged: { sortKey: 'id' },
|
|
182
|
+
},
|
|
183
|
+
'shop/timeline': {
|
|
184
|
+
summary: 'The event timeline for one repair',
|
|
185
|
+
/**
|
|
186
|
+
* `workorder:read` ON THE ENTITY named, not at the node. A `mechanic` holds
|
|
187
|
+
* `workorder:read` and a portal customer holds it narrowed to their own
|
|
188
|
+
* customer record; both reach a repair's timeline through the walk
|
|
189
|
+
* (workorder → bike → customer), and neither would pass a node check.
|
|
190
|
+
*/
|
|
191
|
+
permission: { key: 'workorder:read', entity: 'workorder', idFrom: 'entityId' },
|
|
192
|
+
input: timelineInput,
|
|
193
|
+
// The KERNEL's shape, not a fourth copy of it: `readTimeline` decodes the
|
|
194
|
+
// envelope, and `actor` is a union the spine recorded rather than the raw
|
|
195
|
+
// string a hand-rolled `SELECT actor` returns.
|
|
196
|
+
output: timelineEntry,
|
|
197
|
+
// The cursor is `id` — the event's ULID, which IS this entity's version at
|
|
198
|
+
// that point.
|
|
199
|
+
paged: { sortKey: 'id' },
|
|
200
|
+
},
|
|
201
|
+
});
|
package/template/src/routes.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { Context, Hono } from 'hono';
|
|
2
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';
|
|
3
11
|
import type { ScopeStub } from '@substrat-run/kernel';
|
|
4
12
|
|
|
5
13
|
/**
|
|
@@ -22,6 +30,58 @@ import type { ScopeStub } from '@substrat-run/kernel';
|
|
|
22
30
|
*/
|
|
23
31
|
export type ResolveStub = (c: Context) => Promise<ScopeStub>;
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The page trio, off the query string — what a route hands a PAGED operation.
|
|
35
|
+
*
|
|
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.
|
|
43
|
+
*
|
|
44
|
+
* `order` and `sort` travel only when asked for, so the DECLARATION's own
|
|
45
|
+
* defaults stay the answer when a caller says nothing.
|
|
46
|
+
*/
|
|
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
|
+
}
|
|
57
|
+
|
|
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
|
+
|
|
25
85
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
86
|
export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): void {
|
|
27
87
|
const S = resolveStub;
|
|
@@ -57,7 +117,9 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
|
|
|
57
117
|
});
|
|
58
118
|
|
|
59
119
|
// -- customers, bikes, price list (the vertical's own tables) ---------------
|
|
60
|
-
app.get('/api/customers', async (c) =>
|
|
120
|
+
app.get('/api/customers', async (c) =>
|
|
121
|
+
pageJson(c, await (await S(c)).invoke('shop/list-customers', pageInput(c))),
|
|
122
|
+
);
|
|
61
123
|
app.post('/api/customers', async (c) =>
|
|
62
124
|
c.json(await (await S(c)).invoke('shop/create-customer', await c.req.json())),
|
|
63
125
|
);
|
|
@@ -69,7 +131,9 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
|
|
|
69
131
|
}),
|
|
70
132
|
),
|
|
71
133
|
);
|
|
72
|
-
app.get('/api/prices', async (c) =>
|
|
134
|
+
app.get('/api/prices', async (c) =>
|
|
135
|
+
pageJson(c, await (await S(c)).invoke('shop/price-list', pageInput(c))),
|
|
136
|
+
);
|
|
73
137
|
app.post('/api/prices', async (c) =>
|
|
74
138
|
c.json(await (await S(c)).invoke('shop/upsert-price', await c.req.json())),
|
|
75
139
|
);
|
|
@@ -79,7 +143,13 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
|
|
|
79
143
|
// the pricing moment); assign/start/report/get/list are the ENGINE's own, invoked
|
|
80
144
|
// directly. Which is which is the composition boundary, visible right here.
|
|
81
145
|
app.get('/api/repairs', async (c) =>
|
|
82
|
-
|
|
146
|
+
pageJson(
|
|
147
|
+
c,
|
|
148
|
+
await (await S(c)).invoke('workorder/list', {
|
|
149
|
+
status: c.req.query('status'),
|
|
150
|
+
...pageInput(c),
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
83
153
|
);
|
|
84
154
|
app.post('/api/repairs', async (c) =>
|
|
85
155
|
c.json(await (await S(c)).invoke('shop/create-repair', await c.req.json())),
|
|
@@ -88,10 +158,12 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
|
|
|
88
158
|
c.json(await (await S(c)).invoke('workorder/get', { orderId: c.req.param('id') })),
|
|
89
159
|
);
|
|
90
160
|
app.get('/api/repairs/:id/timeline', async (c) =>
|
|
91
|
-
|
|
161
|
+
pageJson(
|
|
162
|
+
c,
|
|
92
163
|
await (await S(c)).invoke('shop/timeline', {
|
|
93
164
|
entityType: 'workorder',
|
|
94
165
|
entityId: c.req.param('id'),
|
|
166
|
+
...pageInput(c),
|
|
95
167
|
}),
|
|
96
168
|
),
|
|
97
169
|
);
|
|
@@ -130,10 +202,14 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
|
|
|
130
202
|
);
|
|
131
203
|
|
|
132
204
|
// -- the customer portal (the per-entity proof walk) ------------------------
|
|
133
|
-
app.get('/api/portal/repairs', async (c) =>
|
|
205
|
+
app.get('/api/portal/repairs', async (c) =>
|
|
206
|
+
pageJson(c, await (await S(c)).invoke('shop/portal-repairs', pageInput(c))),
|
|
207
|
+
);
|
|
134
208
|
|
|
135
209
|
// -- invoicing (the sibling engine, fed by event) ---------------------------
|
|
136
|
-
app.get('/api/invoicing', async (c) =>
|
|
210
|
+
app.get('/api/invoicing', async (c) =>
|
|
211
|
+
pageJson(c, await (await S(c)).invoke('invoicing/list', pageInput(c))),
|
|
212
|
+
);
|
|
137
213
|
app.get('/api/invoicing/:id', async (c) =>
|
|
138
214
|
c.json(await (await S(c)).invoke('invoicing/get', { underlagId: c.req.param('id') })),
|
|
139
215
|
);
|
package/template/src/server.ts
CHANGED
|
@@ -57,6 +57,11 @@ const staffActor = platformActorId.parse(ulid());
|
|
|
57
57
|
// Both ports are bound in THIS file so they move together: `substrat.devServers` names
|
|
58
58
|
// it for the API and for the issuer alike, and `ISSUER_PORT=… PORT=… pnpm dev` shifts the
|
|
59
59
|
// pair without either end losing track of the other.
|
|
60
|
+
//
|
|
61
|
+
// The issuer default is 8879 because that is `@substrat-run/dev-issuer`'s OWN default —
|
|
62
|
+
// the `issuer` script passes no `--port`, so this number and the one the issuer actually
|
|
63
|
+
// binds have to be the same or the login round-trip points at nothing. Change one and you
|
|
64
|
+
// must change the other; `ISSUER_PORT=…` moves both at once, which is the supported way.
|
|
60
65
|
const ISSUER_PORT = Number(process.env.ISSUER_PORT ?? 8879);
|
|
61
66
|
const login = devLogin({
|
|
62
67
|
directory: host.admin,
|
|
@@ -87,7 +92,11 @@ async function stub(c: Context): Promise<ScopeStub> {
|
|
|
87
92
|
// Everything else — including `/api/invoke` and the shared error envelope.
|
|
88
93
|
mountApi(app, stub);
|
|
89
94
|
|
|
90
|
-
|
|
95
|
+
// 8891, not 8873. The `887x`/`527x` block is reserved for this monorepo's own demos
|
|
96
|
+
// (CLAUDE.md, "Commands"), and 8873 is the shop demo's API — so a scaffolded project used
|
|
97
|
+
// to refuse to boot beside the demo it was read from. A scaffold has no claim on that
|
|
98
|
+
// block; `PORT=…` moves this one.
|
|
99
|
+
const PORT = Number(process.env.PORT ?? 8891);
|
|
91
100
|
serve({ fetch: app.fetch, port: PORT });
|
|
92
101
|
console.log(`Bike-shop API on http://localhost:${PORT} — data in ${dataDir}`);
|
|
93
102
|
console.log(`Sign in at http://localhost:${PORT}/api/auth/login — issuer: ${login.issuer}`);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { bikeShopEntities, bikeShopModel } from '../src/entities.js';
|
|
3
|
+
import { bikeShopMigrations } from '../src/migrations.js';
|
|
4
|
+
import { bikeShopManifest } from '../src/manifest.js';
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// The registry and the migration journal are TWO DESCRIPTIONS OF ONE SCHEMA.
|
|
8
|
+
//
|
|
9
|
+
// `src/entities.ts` declares the row shape as `ctx.sql` returns it; `0001-init`
|
|
10
|
+
// creates the table it returns rows from. Nothing derives one from the other
|
|
11
|
+
// yet, so nothing stops them drifting: rename a column in the journal, add its
|
|
12
|
+
// migration, and the registry goes stale while `tsc` stays green and every
|
|
13
|
+
// scenario assertion above still passes — the declaration would then be wrong
|
|
14
|
+
// about the very thing it exists to state.
|
|
15
|
+
//
|
|
16
|
+
// This is the check that makes that a red build. It is also the one worth
|
|
17
|
+
// copying when you replace the bike shop with your own domain: every entity you
|
|
18
|
+
// declare should be held to the table it names.
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const journalSql = bikeShopMigrations.map((m) => m.sql).join('\n');
|
|
22
|
+
|
|
23
|
+
/** The columns each `CREATE TABLE` actually leaves behind, plus any `ADD COLUMN`. */
|
|
24
|
+
function columnsFromJournal(): Map<string, Set<string>> {
|
|
25
|
+
const tables = new Map<string, Set<string>>();
|
|
26
|
+
for (const [, table, body] of journalSql.matchAll(
|
|
27
|
+
/CREATE TABLE (?:IF NOT EXISTS )?([a-z_][a-z0-9_]*)\s*\(([\s\S]*?)\n\s*\);/gi,
|
|
28
|
+
)) {
|
|
29
|
+
if (!table || !body) continue;
|
|
30
|
+
const cols = new Set<string>();
|
|
31
|
+
for (const raw of body.split('\n')) {
|
|
32
|
+
const line = raw.trim();
|
|
33
|
+
// Table constraints are not columns, and neither is a comment.
|
|
34
|
+
if (!line || line.startsWith('--') || /^(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT)\b/i.test(line))
|
|
35
|
+
continue;
|
|
36
|
+
const name = /^([a-z_][a-z0-9_]*)\b/i.exec(line)?.[1];
|
|
37
|
+
if (name) cols.add(name);
|
|
38
|
+
}
|
|
39
|
+
tables.set(table, cols);
|
|
40
|
+
}
|
|
41
|
+
for (const [, table, col] of journalSql.matchAll(
|
|
42
|
+
/ALTER TABLE ([a-z_][a-z0-9_]*)\s+ADD COLUMN\s+([a-z_][a-z0-9_]*)/gi,
|
|
43
|
+
)) {
|
|
44
|
+
if (table && col) tables.get(table)?.add(col);
|
|
45
|
+
}
|
|
46
|
+
return tables;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('the registry agrees with the migration journal', () => {
|
|
50
|
+
const journal = columnsFromJournal();
|
|
51
|
+
|
|
52
|
+
it('parsed the journal at all', () => {
|
|
53
|
+
// A comparison that silently parsed nothing would pass everything under it,
|
|
54
|
+
// which is the one way this file could be worse than not existing.
|
|
55
|
+
expect(journal.size).toBe(3);
|
|
56
|
+
expect(journal.get('shop_customers')?.size).toBeGreaterThan(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
for (const [name, entity] of Object.entries(bikeShopEntities)) {
|
|
60
|
+
it(`${name} → ${entity.table}: the declared fields ARE the table's columns`, () => {
|
|
61
|
+
const actual = journal.get(entity.table);
|
|
62
|
+
expect(actual, `no CREATE TABLE for '${entity.table}'`).toBeDefined();
|
|
63
|
+
expect(Object.keys(entity.fields.shape).sort()).toEqual([...(actual ?? [])].sort());
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it('leaves the price list out — a table, not an entity', () => {
|
|
68
|
+
// Stated as an assertion rather than an absence, so deleting the entity that
|
|
69
|
+
// should not exist is a decision somebody made and not one that rotted away.
|
|
70
|
+
expect(journal.has('shop_price_list')).toBe(true);
|
|
71
|
+
expect(Object.values(bikeShopEntities).map((e) => e.table)).not.toContain('shop_price_list');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('the declared model and the manifest describe one walk', () => {
|
|
76
|
+
it('emits every declared entity, deterministically', () => {
|
|
77
|
+
expect(Object.keys(bikeShopModel.entities)).toEqual(['bike', 'customer']);
|
|
78
|
+
expect(bikeShopModel.entities.bike?.parents).toEqual(['customer']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps the model's local edge and the manifest's in step", () => {
|
|
82
|
+
// `entityRelations` is what `ctx.link` and the portal walk resolve against;
|
|
83
|
+
// `parents` is the model's half of the same edge. Two spellings of one fact,
|
|
84
|
+
// until the manifest is derived from the registry.
|
|
85
|
+
expect(bikeShopManifest.entityRelations).toContainEqual({
|
|
86
|
+
entityType: 'bike',
|
|
87
|
+
parentType: 'customer',
|
|
88
|
+
});
|
|
89
|
+
// The hop the shop's own registry cannot produce: `workorder` is the ENGINE's
|
|
90
|
+
// entity, and the portal walk (workorder → bike → customer) needs it.
|
|
91
|
+
expect(bikeShopManifest.entityRelations).toContainEqual({
|
|
92
|
+
entityType: 'workorder',
|
|
93
|
+
parentType: 'bike',
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('declares the identifying bike field erasable', () => {
|
|
98
|
+
// A frame number points at its owner. An erasure that kept it would leave
|
|
99
|
+
// that pointer behind, and an event payload carrying it could not be reached
|
|
100
|
+
// at all — so the declaration is what keeps it out of one.
|
|
101
|
+
expect(bikeShopEntities.bike.erasable).toContain('frame_no');
|
|
102
|
+
expect(bikeShopEntities.customer.erasable).toEqual(['name', 'phone']);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -2,12 +2,20 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
|
+
import { Hono } from 'hono';
|
|
5
6
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
addMoney,
|
|
9
|
+
moneyOf,
|
|
10
|
+
mulMoney,
|
|
11
|
+
type Page,
|
|
12
|
+
type TimelineEntry,
|
|
13
|
+
} from '@substrat-run/contracts';
|
|
7
14
|
import type { ScopeStub } from '@substrat-run/kernel';
|
|
8
15
|
import type { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
|
|
9
16
|
import type { WorkOrder, BillableLine } from '@substrat-run/engine-workorder';
|
|
10
17
|
import { buildBikeShopHost, seedBikeShop, type BikeShopWorld } from '../src/seed.js';
|
|
18
|
+
import { mountApi } from '../src/routes.js';
|
|
11
19
|
|
|
12
20
|
// ============================================================================
|
|
13
21
|
// The bike-shop scenario, replayed headlessly against a temp dir: a repair's
|
|
@@ -63,11 +71,14 @@ describe('bike-shop scenario', () => {
|
|
|
63
71
|
expect(repair.facility).toEqual({ entityType: 'bike', entityId: w.crescentId });
|
|
64
72
|
expect(repair.customer.entityId).toBe(w.lisbethId);
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
// A page, not a bare array: every list read on this surface is declared
|
|
75
|
+
// `paged`, so the envelope is `{ entries, nextCursor }` and a caller with
|
|
76
|
+
// more rows than one page walks the cursor rather than assuming it saw all.
|
|
77
|
+
const timeline = await greta.invoke<Page<TimelineEntry>>('shop/timeline', {
|
|
67
78
|
entityType: 'workorder',
|
|
68
79
|
entityId: repairId,
|
|
69
80
|
});
|
|
70
|
-
expect(timeline.map((e) => e.type)).toContain('workorder.created');
|
|
81
|
+
expect(timeline.entries.map((e) => e.type)).toContain('workorder.created');
|
|
71
82
|
});
|
|
72
83
|
|
|
73
84
|
it('3. assign → start → report time and parts', async () => {
|
|
@@ -280,4 +291,116 @@ describe('bike-shop scenario', () => {
|
|
|
280
291
|
rutger.invoke('shop/create-customer', { number: '9001', name: 'x' }),
|
|
281
292
|
).rejects.toThrow(/permission denied/);
|
|
282
293
|
});
|
|
294
|
+
|
|
295
|
+
// Every list read on this surface is DECLARED `paged` in src/operations.ts, and
|
|
296
|
+
// `defineOperations` refuses a bare-array output that is not — so an unbounded
|
|
297
|
+
// list read cannot be added here without the declaration going red.
|
|
298
|
+
//
|
|
299
|
+
// Asserting the envelope alone would not be worth much: the interesting half is
|
|
300
|
+
// that the keyset cursor actually WALKS. A cursor that returns the same page
|
|
301
|
+
// forever, or skips a row at the page boundary, produces a perfectly
|
|
302
|
+
// well-shaped `Page` every time — so the walk is driven at `limit: 1`, where
|
|
303
|
+
// every boundary is a boundary, and checked against the whole set.
|
|
304
|
+
it('11. the list reads answer with a page, and the cursor walks every row', async () => {
|
|
305
|
+
const prices = await greta.invoke<Page<{ article: string }>>('shop/price-list');
|
|
306
|
+
expect(prices.entries.map((p) => p.article)).toEqual([
|
|
307
|
+
'chain-9s',
|
|
308
|
+
'labor',
|
|
309
|
+
'shop-supplies',
|
|
310
|
+
'tube-28',
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
const walked: string[] = [];
|
|
314
|
+
let cursor: string | null = null;
|
|
315
|
+
// Bounded so a non-advancing cursor fails the assertion below rather than
|
|
316
|
+
// hanging the suite — a test that hangs reports nothing.
|
|
317
|
+
for (let hop = 0; hop < 10; hop += 1) {
|
|
318
|
+
const page: Page<{ article: string }> = await greta.invoke('shop/price-list', {
|
|
319
|
+
limit: 1,
|
|
320
|
+
...(cursor === null ? {} : { cursor }),
|
|
321
|
+
});
|
|
322
|
+
walked.push(...page.entries.map((p) => p.article));
|
|
323
|
+
cursor = page.nextCursor;
|
|
324
|
+
if (cursor === null) break;
|
|
325
|
+
}
|
|
326
|
+
expect(cursor).toBeNull();
|
|
327
|
+
expect(walked).toEqual(['chain-9s', 'labor', 'shop-supplies', 'tube-28']);
|
|
328
|
+
|
|
329
|
+
// The customer list has its OWN hand-written keyset SQL, so it gets its own
|
|
330
|
+
// walk rather than being trusted because the price list's worked. Its entries
|
|
331
|
+
// still carry the hydrated bikes — the page bounds that hydration rather than
|
|
332
|
+
// removing it.
|
|
333
|
+
type CustomerEntry = { number: string; bikes: { id: string }[] };
|
|
334
|
+
const first = await greta.invoke<Page<CustomerEntry>>('shop/list-customers', { limit: 1 });
|
|
335
|
+
expect(first.entries).toHaveLength(1);
|
|
336
|
+
expect(first.nextCursor).toBe(first.entries[0]!.number);
|
|
337
|
+
expect(Array.isArray(first.entries[0]!.bikes)).toBe(true);
|
|
338
|
+
|
|
339
|
+
const seen: string[] = [];
|
|
340
|
+
let at: string | null = null;
|
|
341
|
+
for (let hop = 0; hop < 10; hop += 1) {
|
|
342
|
+
const page: Page<CustomerEntry> = await greta.invoke('shop/list-customers', {
|
|
343
|
+
limit: 1,
|
|
344
|
+
...(at === null ? {} : { cursor: at }),
|
|
345
|
+
});
|
|
346
|
+
seen.push(...page.entries.map((c) => c.number));
|
|
347
|
+
at = page.nextCursor;
|
|
348
|
+
if (at === null) break;
|
|
349
|
+
}
|
|
350
|
+
expect(at).toBeNull();
|
|
351
|
+
// Every seeded customer, once, in the order the cursor walks — a boundary
|
|
352
|
+
// that repeated or skipped a row would show up here and nowhere else.
|
|
353
|
+
expect(seen).toEqual(['2001', '2002']);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ROUTE-LEVEL, and it has to be: everything above calls operations through a
|
|
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.
|
|
362
|
+
it('12. the HTTP routes forward the page and hand back a Link to the next one', async () => {
|
|
363
|
+
const app = new Hono();
|
|
364
|
+
mountApi(app, async () => greta);
|
|
365
|
+
|
|
366
|
+
const nextOf = (res: Response): string | null => {
|
|
367
|
+
// `<http://localhost/api/prices?limit=1&cursor=labor>; rel="next"` — the
|
|
368
|
+
// page size and every filter ride along, which is why a client FOLLOWS the
|
|
369
|
+
// link instead of assembling one.
|
|
370
|
+
const url = /<([^>]+)>;\s*rel="next"/.exec(res.headers.get('Link') ?? '')?.[1];
|
|
371
|
+
if (url === undefined) return null;
|
|
372
|
+
const parsed = new URL(url);
|
|
373
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const walked: string[] = [];
|
|
377
|
+
let next: string | null = '/api/prices?limit=1';
|
|
378
|
+
for (let hop = 0; hop < 10 && next !== null; hop += 1) {
|
|
379
|
+
const res = await app.request(next);
|
|
380
|
+
expect(res.status).toBe(200);
|
|
381
|
+
// The BODY is the entries — the same array this endpoint answered with
|
|
382
|
+
// before it was paged, which is what makes adopting the page cost a client
|
|
383
|
+
// nothing.
|
|
384
|
+
const body = (await res.json()) as { article: string }[];
|
|
385
|
+
expect(Array.isArray(body)).toBe(true);
|
|
386
|
+
walked.push(...body.map((p) => p.article));
|
|
387
|
+
next = nextOf(res);
|
|
388
|
+
}
|
|
389
|
+
expect(next).toBeNull();
|
|
390
|
+
expect(walked).toEqual(['chain-9s', 'labor', 'shop-supplies', 'tube-28']);
|
|
391
|
+
|
|
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.
|
|
395
|
+
const customers = await app.request('/api/customers?limit=1');
|
|
396
|
+
expect(((await customers.json()) as { number: string }[]).map((c) => c.number)).toEqual([
|
|
397
|
+
'2001',
|
|
398
|
+
]);
|
|
399
|
+
expect(nextOf(customers)).toBe('/api/customers?limit=1&cursor=2001');
|
|
400
|
+
|
|
401
|
+
const timeline = await app.request(`/api/repairs/${repairId}/timeline?limit=1`);
|
|
402
|
+
expect(timeline.status).toBe(200);
|
|
403
|
+
expect((await timeline.json()) as unknown[]).toHaveLength(1);
|
|
404
|
+
expect(nextOf(timeline)).toMatch(/^\/api\/repairs\/.+\/timeline\?limit=1&cursor=.+$/);
|
|
405
|
+
});
|
|
283
406
|
});
|