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 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.100.0';
40
- const ENGINE_WORKORDER = '^0.10.8';
41
- const ENGINE_INVOICING = '^0.9.16';
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.15';
43
+ const DEV_ISSUER = '^0.1.20';
44
44
 
45
45
  const DOCS = 'https://substrat.net';
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,7 +18,7 @@
18
18
  "run",
19
19
  "server"
20
20
  ],
21
- "port": 8873,
21
+ "port": 8891,
22
22
  "autoPort": false
23
23
  }
24
24
  ]
@@ -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 :8871 (PORT=… WEB_PORT=… to move it)
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:8871` works on the
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
@@ -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 imports both; operations + registration ← module code
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);
@@ -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 Money,
10
- type Page,
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 operations. Each is either:
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
- export interface CustomerRow {
44
- id: string;
45
- number: string;
46
- name: string;
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
- // Every operation that takes an input declares it as a Zod object here, and the
70
- // handler's input type is `z.infer` of that object — one description of the
71
- // shape, so the schema and the type cannot drift apart. `bikeShopOperations` at
72
- // the bottom of this file hands the whole set to the host.
73
- const createCustomerInput = z.object({
74
- number: z.string().min(1),
75
- name: z.string().min(1),
76
- phone: z.string().min(1).optional(),
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: OperationHandler<z.infer<typeof createCustomerInput>, CustomerRow> = async (
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
- const listCustomersOp: OperationHandler<undefined, (CustomerRow & { bikes: BikeRow[] })[]> = async (
93
- ctx,
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 customers = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers ORDER BY number');
97
- return customers.map((c) => ({
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 registerBikeInput = z.object({
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 upsertPriceInput = z.object({
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
- const priceListOp: OperationHandler<undefined, PriceRow[]> = async (ctx) => {
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
- return ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list ORDER BY article');
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 createRepairInput = z.object({
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 repairIdInput = z.object({ orderId: z.string().min(1) });
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: OperationHandler<z.infer<typeof repairIdInput>, WorkOrder> = async (
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: OperationHandler<PageParams | undefined, Page<WorkOrder>> = async (
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
- * No `.parse` in here: the host already parsed `entity` against
311
- * `timelineInput` before this line ran, on whichever path the call came in by.
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: OperationHandler<
314
- z.infer<typeof timelineInput>,
315
- { type: string; occurred_at: string; actor: string }[]
316
- > = async (ctx, entity) => {
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
- // Append order is authoritative — rowid, not ULID (ids minted in the same
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
- * What each operation accepts. A complete census of the ten below: an entry with
329
- * no `input` takes nothing, and `paged: true` is how the portal read says the
330
- * platform supplies the page trio (`limit`/`cursor`/`order`/`sort`) — declaring
331
- * those four by hand is how the two descriptions of one page come to disagree.
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 bikeShopOperations = {
334
- 'shop/create-customer': { input: createCustomerInput },
335
- 'shop/list-customers': {},
336
- 'shop/register-bike': { input: registerBikeInput },
337
- 'shop/upsert-price': { input: upsertPriceInput },
338
- 'shop/price-list': {},
339
- 'shop/create-repair': { input: createRepairInput },
340
- 'shop/complete-repair': { input: repairIdInput },
341
- 'shop/close-repair': { input: repairIdInput },
342
- 'shop/portal-repairs': { paged: true },
343
- 'shop/timeline': { input: timelineInput },
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 these before the guards, the
350
- // permission check and the handler — so "parse, don't trust" holds on every
351
- // path in (HTTP, test, seed, schedule) rather than in the handlers that
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
- 'shop/create-customer': createCustomerOp as never,
356
- 'shop/list-customers': listCustomersOp as never,
357
- 'shop/register-bike': registerBikeOp as never,
358
- 'shop/upsert-price': upsertPriceOp as never,
359
- 'shop/price-list': priceListOp as never,
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
+ });
@@ -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) => c.json(await (await S(c)).invoke('shop/list-customers')));
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) => c.json(await (await S(c)).invoke('shop/price-list')));
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
- c.json(await (await S(c)).invoke('workorder/list', { status: c.req.query('status') })),
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
- c.json(
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) => c.json(await (await S(c)).invoke('shop/portal-repairs')));
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) => c.json(await (await S(c)).invoke('invoicing/list')));
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
  );
@@ -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
- const PORT = Number(process.env.PORT ?? 8873);
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 { addMoney, moneyOf, mulMoney, type Page } from '@substrat-run/contracts';
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
- const timeline = await greta.invoke<{ type: string }[]>('shop/timeline', {
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
  });