@eventmodelers/cli 1.0.55 → 1.0.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.55",
3
+ "version": "1.0.56",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,6 +57,9 @@ Follow the **build-state-change** skill to create:
57
57
 
58
58
  **Do NOT create a `routes.ts`** for automations — the command is fired internally by the processor, not via HTTP.
59
59
 
60
+ **No `routes.ts` also means no OpenAPI block** — an automation has no HTTP surface, so it contributes nothing to `/api-docs` or `/swagger.json`. If the slice also defines a todo-list read model that is queried over HTTP, that endpoint belongs to **build-state-view**, and its `@openapi` annotation is required there (see that skill's Step 6a).
61
+
62
+
60
63
  Refer to the build-state-change skill for the full command handler structure.
61
64
 
62
65
  ### Storyline-derived tests
@@ -269,4 +272,5 @@ src/common/
269
272
  - [ ] Command data fields map exclusively from fields available on the trigger event per slice.json — no invented mappings
270
273
  - [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
271
274
  - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
272
- - [ ] If `storylines[]` is present, its command-handler segment was covered via build-state-change's storyline-derived tests (no separate reactor test needed)
275
+ - [ ] If `storylines[]` is present, its command-handler segment was covered via build-state-change's storyline-derived tests (no separate reactor test needed)
276
+ - [ ] No `routes.ts`, therefore no `@openapi` block — any todo-list read-model query endpoint is documented by build-state-view instead
@@ -244,7 +244,7 @@ Put these in their own `describe` block named after the storyline (same pattern
244
244
 
245
245
  File: `src/slices/{context}/{SliceName}/routes.ts`
246
246
 
247
- > **Concrete example**: `src/slices/example/routes.ts` shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
247
+ > **Pattern reference**: the template below is the full pattern auth, error mapping, and the mandatory `@openapi` annotation (Step 5a). If the project already has slices under `src/slices/`, open one of their `routes.ts` files first and match it.
248
248
 
249
249
  ```typescript
250
250
  import {Request, Response, Router} from 'express';
@@ -254,6 +254,59 @@ import {{SliceName}Command, handle{SliceName}} from './{SliceName}Command';
254
254
 
255
255
  export const api = (): WebApiSetup => (router: Router): void => {
256
256
 
257
+ /**
258
+ * @openapi
259
+ * /api/{slicename}/{id}:
260
+ * post:
261
+ * tags: [{Context}]
262
+ * summary: {slice title from slice.json}
263
+ * description: {slice.json description — plus any comments that explain the endpoint}
264
+ * security:
265
+ * - bearerAuth: []
266
+ * parameters:
267
+ * - in: path
268
+ * name: id
269
+ * required: true
270
+ * schema:
271
+ * type: string
272
+ * description: Stream id this command is applied to
273
+ * - in: header
274
+ * name: correlation_id
275
+ * required: false
276
+ * schema:
277
+ * type: string
278
+ * requestBody:
279
+ * required: true
280
+ * content:
281
+ * application/json:
282
+ * schema:
283
+ * type: object
284
+ * required: [{command fields without optional: true}]
285
+ * properties:
286
+ * {fieldName}:
287
+ * type: string
288
+ * example: {the field's own example from slice.json, if it has one}
289
+ * responses:
290
+ * '201':
291
+ * description: Accepted — {EmittedEventName} appended
292
+ * content:
293
+ * application/json:
294
+ * schema:
295
+ * type: object
296
+ * properties:
297
+ * ok:
298
+ * type: boolean
299
+ * next_expected_stream_version:
300
+ * type: string
301
+ * last_event_global_position:
302
+ * type: string
303
+ * '401':
304
+ * description: Not authenticated
305
+ * '409':
306
+ * description: {message errorMapping returns — one line per error code}
307
+ * '500':
308
+ * description: Server error
309
+ */
257
310
  router.post('/api/{slicename}/:id', async (req: Request, res: Response) => {
258
311
  const auth = await requireUser(req, res);
259
312
  if (auth.error) return;
@@ -306,6 +359,39 @@ const errorMapping = (code: string): string | null => {
306
359
 
307
360
  ---
308
361
 
362
+ ### Step 5a — OpenAPI annotation (required)
363
+
364
+ `src/swagger.ts` builds the published OpenAPI document by scanning `./src/slices/**/routes.ts` for `@openapi` JSDoc blocks. A handler without one is **invisible** in Swagger UI (`/api-docs`) and in `/swagger.json` — the endpoint works, but nobody can find it. `src/swagger.ts` is shared infra and outside a slice's commit scope, so the block in this slice's own `routes.ts` is the only place the endpoint can be documented. The `openapi-annotation` commit check rejects a `routes.ts` whose handlers have no matching block.
365
+
366
+ Everything in the block comes from slice.json — same rule as the code: no invented fields, no guessed types.
367
+
368
+ | slice.json field `type` | OpenAPI schema |
369
+ |---|---|
370
+ | `String` | `type: string` |
371
+ | `UUID` | `type: string`, `format: uuid` |
372
+ | `Int` | `type: integer`, `format: int32` |
373
+ | `Long` | `type: integer`, `format: int64` |
374
+ | `Double` | `type: number`, `format: double` |
375
+ | `Decimal` | `type: number` |
376
+ | `Boolean` | `type: boolean` |
377
+ | `Date` | `type: string`, `format: date` |
378
+ | `DateTime` | `type: string`, `format: date-time` |
379
+ | `Custom` | `type: object` |
380
+
381
+ Mapping rules:
382
+
383
+ - **path key** — the express path with `:param` rewritten as `{param}` (`/api/foo/:id` → `/api/foo/{id}`). If the two disagree, Swagger publishes a path that does not exist.
384
+ - **tags** — `[{Context}]`, the slice's context, so every slice of one context groups under one heading.
385
+ - **summary** — the slice title. **description** — slice.json `description`, plus any `comments[]` that explain what the endpoint does.
386
+ - **requestBody properties** — exactly `commands[].fields`, minus the ones taken from the URL path or a header. Types from the table above.
387
+ - **required** — every command field not marked `optional: true`.
388
+ - **example** — only from the field's own `example` in slice.json. A field with no example gets no `example:` line; do not invent one.
389
+ - **responses** — `'201'` with the body the handler actually returns; one `'409'` per error code in `errorMapping` (i.e. per failing specification); `'401'` whenever the handler enforces auth; `'500'`.
390
+
391
+ A placeholder left unreplaced ships straight into the published spec, and `npm run build` will not catch it — open `/api-docs` and look at the rendered endpoint before marking the slice `Done`.
392
+
393
+ ---
394
+
309
395
  ## Step 6 — Wire up the route
310
396
 
311
397
  Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
@@ -353,4 +439,6 @@ Before marking this slice as `Done`, verify the implementation against slice.jso
353
439
  - [ ] Every entry in `specifications[]` maps to a test case in `{SliceName}.test.ts`
354
440
  - [ ] No business rules, defaults, or constraints were added that do not appear in slice.json `description` or `comments`
355
441
  - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
356
- - [ ] If `storylines[]` is present, a storyline-derived test was added for every COMMAND beat matching this slice's command
442
+ - [ ] If `storylines[]` is present, a storyline-derived test was added for every COMMAND beat matching this slice's command
443
+ - [ ] `routes.ts` carries an `@openapi` JSDoc block above every handler, and its path key matches the registered route with `:param` written as `{param}`
444
+ - [ ] The documented request body is exactly `commands[].fields`, and every error code in `errorMapping` has a `'409'` line — no undocumented fields, no invented ones
@@ -352,7 +352,7 @@ Skip a beat pair when a COMMAND beat sits in between (that half belongs to build
352
352
 
353
353
  File: `src/slices/{context}/{SliceName}/routes.ts`
354
354
 
355
- > **Concrete example**: `src/slices/example/routes.ts` shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
355
+ > **Pattern reference**: the template below is the full pattern auth, error mapping, and the mandatory `@openapi` annotation (Step 6a). If the project already has slices under `src/slices/`, open one of their `routes.ts` files first and match it.
356
356
 
357
357
  ```typescript
358
358
  import {Request, Response, Router} from 'express';
@@ -364,6 +364,48 @@ import createClient from '../../../supabase/api';
364
364
 
365
365
  export const api = (): WebApiSetup => (router: Router): void => {
366
366
 
367
+ /**
368
+ * @openapi
369
+ * /api/query/{slicename}-collection:
370
+ * get:
371
+ * tags: [{Context}]
372
+ * summary: {slice title from slice.json}
373
+ * description: {slice.json description — what this read model answers}
374
+ * security:
375
+ * - bearerAuth: []
376
+ * parameters:
377
+ * - in: query
378
+ * name: _id
379
+ * required: false
380
+ * schema:
381
+ * type: string
382
+ * description: When set, returns the single row with this id instead of the full collection
383
+ * responses:
384
+ * '200':
385
+ * description: The {SliceName} read model
386
+ * content:
387
+ * application/json:
388
+ * schema:
389
+ * oneOf:
390
+ * - $ref: '#/components/schemas/{SliceName}ReadModel'
391
+ * - type: array
392
+ * items:
393
+ * $ref: '#/components/schemas/{SliceName}ReadModel'
394
+ * '401':
395
+ * description: Not authenticated
396
+ * '500':
397
+ * description: Server error
398
+ * components:
399
+ * schemas:
400
+ * {SliceName}ReadModel:
401
+ * type: object
402
+ * properties:
403
+ * id:
404
+ * type: string
405
+ * {fieldName}:
406
+ * type: string
407
+ * example: {the field's own example from slice.json, if it has one}
408
+ */
367
409
  router.get('/api/query/{slicename}-collection', async (req: Request, res: Response) => {
368
410
  try {
369
411
  const principal = await requireUser(req, res, true);
@@ -394,6 +436,40 @@ export const api = (): WebApiSetup => (router: Router): void => {
394
436
 
395
437
  ---
396
438
 
439
+ ### Step 6a — OpenAPI annotation (required)
440
+
441
+ `src/swagger.ts` builds the published OpenAPI document by scanning `./src/slices/**/routes.ts` for `@openapi` JSDoc blocks. A handler without one is **invisible** in Swagger UI (`/api-docs`) and in `/swagger.json` — the endpoint works, but nobody can find it. `src/swagger.ts` is shared infra and outside a slice's commit scope, so the block in this slice's own `routes.ts` is the only place the endpoint can be documented. The `openapi-annotation` commit check rejects a `routes.ts` whose handlers have no matching block.
442
+
443
+ Everything in the block comes from slice.json — same rule as the code: no invented fields, no guessed types.
444
+
445
+ | slice.json field `type` | OpenAPI schema |
446
+ |---|---|
447
+ | `String` | `type: string` |
448
+ | `UUID` | `type: string`, `format: uuid` |
449
+ | `Int` | `type: integer`, `format: int32` |
450
+ | `Long` | `type: integer`, `format: int64` |
451
+ | `Double` | `type: number`, `format: double` |
452
+ | `Decimal` | `type: number` |
453
+ | `Boolean` | `type: boolean` |
454
+ | `Date` | `type: string`, `format: date` |
455
+ | `DateTime` | `type: string`, `format: date-time` |
456
+ | `Custom` | `type: object` |
457
+
458
+ Mapping rules:
459
+
460
+ - **path key** — the express path exactly as registered (a query read model has no path params; if you do add one, rewrite `:param` as `{param}`).
461
+ - **tags** — `[{Context}]`, the slice's context, so every slice of one context groups under one heading.
462
+ - **summary** — the slice title. **description** — slice.json `description`, plus any `comments[]` that explain what the read model answers.
463
+ - **schema properties** — exactly the read model fields from slice.json, the same set as the migration columns and the `{SliceName}ReadModel` type. Keep the JSON field spelling the route returns, not the snake_case column name, when they differ.
464
+ - **required** — omit it unless slice.json marks fields as mandatory; a projection row can legitimately be sparse.
465
+ - **example** — only from the field's own `example` in slice.json. A field with no example gets no `example:` line; do not invent one.
466
+ - **`components.schemas`** — declaring the read model once and `$ref`-ing it keeps the single-row and collection responses in sync. swagger-jsdoc merges the `components` block from every scanned file, so name the schema `{SliceName}ReadModel` to avoid colliding with another slice's.
467
+ - **responses** — `'200'` with the shape above, `'401'` whenever the handler enforces auth, `'500'`.
468
+
469
+ A placeholder left unreplaced ships straight into the published spec, and `npm run build` will not catch it — open `/api-docs` and look at the rendered endpoint before marking the slice `Done`.
470
+
471
+ ---
472
+
397
473
  ## Step 7 — Wire up the route
398
474
 
399
475
  Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
@@ -434,8 +510,10 @@ src/common/
434
510
  - [ ] No `db.destroy()` calls in `evolve()` — the projection never owns its own connection
435
511
  - [ ] Tests use `runFlywayMigrations()` to apply the real schema
436
512
  - [ ] One test scenario per specification in slice.json
437
- o- [ ] Every field in the read model definition in slice.json has a column in the migration and a field in the TypeScript type — no invented columns
513
+ - [ ] Every field in the read model definition in slice.json has a column in the migration and a field in the TypeScript type — no invented columns
438
514
  - [ ] Every event type in `events[]` is listed in the projection's `canHandle` — no assumed events
439
515
  - [ ] No extra columns or fields were added beyond what slice.json defines
440
516
  - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
441
- - [ ] If `storylines[]` is present, a storyline-derived test was added for every isolable read-model-chain transition (adjacent READMODEL beats with only EVENT beats between them)
517
+ - [ ] If `storylines[]` is present, a storyline-derived test was added for every isolable read-model-chain transition (adjacent READMODEL beats with only EVENT beats between them)
518
+ - [ ] `routes.ts` carries an `@openapi` JSDoc block above every handler, and its path key matches the registered route
519
+ - [ ] The documented response schema lists exactly the read model fields from slice.json — same set as the migration columns and the `{SliceName}ReadModel` type
@@ -20,7 +20,9 @@ Read Events in src/events to understand the global structure.
20
20
  3. Follow TypeScript best practices for type definitions and interfaces
21
21
 
22
22
  Only check src/slices/{slice}/*.ts, do not check subfolders unless explicitely tasked to.
23
- If not tasked explicitely to change routes, ignore routes*.ts
23
+ If not tasked explicitely to change routes, ignore routes*.ts — except the `routes.ts` of the slice you are
24
+ building: the build skill owns that file, and its `@openapi` block has to stay in step with the slice's
25
+ fields (the `openapi-annotation` check blocks the commit otherwise).
24
26
 
25
27
  Ignore case for files and slices in prompts. "CartItems" slice is the same as "cartitems"
26
28
 
@@ -72,6 +74,9 @@ It loads every check under `.build-kit/lib/checks/` and rejects the commit if an
72
74
  `.build-kit/.slices/{context}/{slice}/slice.json`
73
75
  - **spec-coverage** — heuristic: the test file needs at least as many `it(...)` blocks as slice.json
74
76
  has `specifications[]` entries
77
+ - **openapi-annotation** — every handler in a slice's `routes.ts` needs an `@openapi` JSDoc block
78
+ above it, keyed on the registered path (`:param` written as `{param}`); without it the endpoint
79
+ never reaches `/api-docs` or `/swagger.json`
75
80
  - **tsc-build** — `npx tsc --noEmit` must still pass
76
81
 
77
82
  If a commit is rejected, split it — commit the out-of-scope file separately from the slice work, or add
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // Every HTTP handler in a slice's routes.ts must carry an `@openapi` JSDoc block
4
+ // directly above it. src/swagger.ts builds the published OpenAPI document by
5
+ // scanning ./src/slices/**/routes.ts for those blocks, so a handler without one
6
+ // is a working endpoint that never appears in Swagger UI (/api-docs) or in
7
+ // /swagger.json — and swagger.ts itself is shared infra a slice commit may not
8
+ // touch, so the block in routes.ts is the only place the endpoint can be
9
+ // documented. See the build-state-change / build-state-view SKILL.md files for
10
+ // the block template and the slice.json -> OpenAPI field mapping.
11
+ //
12
+ // Heuristic, not an OpenAPI parser: it checks that a block exists between the
13
+ // previous handler and this one, and that the block names this handler's own
14
+ // path (express `:param` rewritten as `{param}`). An invalid schema, or a
15
+ // placeholder left unreplaced, still slips through — the rendered /api-docs
16
+ // page is the real check.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const ROUTES_FILE = /^src\/slices\/[^/]+\/[^/]+\/routes\.ts$/;
22
+ const ROUTE_CALL = /\brouter\s*\.\s*(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2/g;
23
+ const OPENAPI_BLOCK = /\/\*\*(?:[\s\S]*?)@openapi(?:[\s\S]*?)\*\//g;
24
+
25
+ // /api/foo/:id -> /api/foo/{id}
26
+ const toOpenApiPath = (p) => p.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
27
+
28
+ module.exports = {
29
+ name: 'openapi-annotation',
30
+ run(ctx) {
31
+ const violations = [];
32
+
33
+ for (const { path: p } of ctx.changes) {
34
+ if (!ROUTES_FILE.test(p)) continue;
35
+
36
+ let content;
37
+ try {
38
+ content = fs.readFileSync(path.join(ctx.repoRoot, p), 'utf8');
39
+ } catch {
40
+ continue; // deleted — nothing to check
41
+ }
42
+
43
+ ROUTE_CALL.lastIndex = 0;
44
+ let cursor = 0; // start of the text belonging to the handler being checked
45
+ let call;
46
+ while ((call = ROUTE_CALL.exec(content))) {
47
+ const [, method, , routePath] = call;
48
+ const preceding = content.slice(cursor, call.index);
49
+ cursor = ROUTE_CALL.lastIndex;
50
+
51
+ const blocks = preceding.match(OPENAPI_BLOCK) || [];
52
+ if (blocks.length === 0) {
53
+ violations.push({
54
+ path: p,
55
+ reason: `${method.toUpperCase()} ${routePath} has no @openapi JSDoc block above it — the endpoint would be missing from /api-docs and /swagger.json`,
56
+ });
57
+ continue;
58
+ }
59
+
60
+ const documented = toOpenApiPath(routePath);
61
+ if (!blocks[blocks.length - 1].includes(documented)) {
62
+ violations.push({
63
+ path: p,
64
+ reason: `the @openapi block above ${method.toUpperCase()} ${routePath} does not document "${documented}" — the path key must match the registered route, with express ":param" written as "{param}"`,
65
+ });
66
+ }
67
+ }
68
+ }
69
+
70
+ return violations;
71
+ },
72
+ };
@@ -57,6 +57,9 @@ Follow the **build-state-change** skill to create:
57
57
 
58
58
  **Do NOT create a `routes.ts`** for automations — the command is fired internally by the processor, not via HTTP.
59
59
 
60
+ **No `routes.ts` also means no OpenAPI block** — an automation has no HTTP surface, so it contributes nothing to `/api-docs` or `/swagger.json`. If the slice also defines a todo-list read model that is queried over HTTP, that endpoint belongs to **build-state-view**, and its `@openapi` annotation is required there (see that skill's Step 6a).
61
+
62
+
60
63
  Refer to the build-state-change skill for the full command handler structure.
61
64
 
62
65
  > **Storyline-derived tests**: if slice.json has a `storylines[]` array, build-state-change's Step 4b applies here too — treat the trigger EVENT beat as the "given" and the fired command's resulting EVENT beat(s) as "then", exactly as it would for an ordinary command-change slice. Skip silently if there's nothing relevant.
@@ -267,4 +270,5 @@ src/common/
267
270
  - [ ] Every processor in `processors[]` has a corresponding `processor.ts` implementation
268
271
  - [ ] Command data fields map exclusively from fields available on the trigger event per slice.json — no invented mappings
269
272
  - [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
270
- - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
273
+ - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
274
+ - [ ] No `routes.ts`, therefore no `@openapi` block — any todo-list read-model query endpoint is documented by build-state-view instead
@@ -251,7 +251,7 @@ Skip (do not fabricate) a segment when the command beat has no immediately-follo
251
251
 
252
252
  File: `src/slices/{context}/{SliceName}/routes.ts`
253
253
 
254
- > **Concrete example**: `src/slices/example/routes.ts` shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
254
+ > **Pattern reference**: the template below is the full pattern auth, error mapping, and the mandatory `@openapi` annotation (Step 5a). If the project already has slices under `src/slices/`, open one of their `routes.ts` files first and match it.
255
255
 
256
256
  ```typescript
257
257
  import {Request, Response, Router} from 'express';
@@ -261,6 +261,59 @@ import {{SliceName}Command, handle{SliceName}} from './{SliceName}Command';
261
261
 
262
262
  export const api = (): WebApiSetup => (router: Router): void => {
263
263
 
264
+ /**
265
+ * @openapi
266
+ * /api/{slicename}/{id}:
267
+ * post:
268
+ * tags: [{Context}]
269
+ * summary: {slice title from slice.json}
270
+ * description: {slice.json description — plus any comments that explain the endpoint}
271
+ * security:
272
+ * - bearerAuth: []
273
+ * parameters:
274
+ * - in: path
275
+ * name: id
276
+ * required: true
277
+ * schema:
278
+ * type: string
279
+ * description: Stream id this command is applied to
280
+ * - in: header
281
+ * name: correlation_id
282
+ * required: false
283
+ * schema:
284
+ * type: string
285
+ * requestBody:
286
+ * required: true
287
+ * content:
288
+ * application/json:
289
+ * schema:
290
+ * type: object
291
+ * required: [{command fields without optional: true}]
292
+ * properties:
293
+ * {fieldName}:
294
+ * type: string
295
+ * example: {the field's own example from slice.json, if it has one}
296
+ * responses:
297
+ * '201':
298
+ * description: Accepted — {EmittedEventName} appended
299
+ * content:
300
+ * application/json:
301
+ * schema:
302
+ * type: object
303
+ * properties:
304
+ * ok:
305
+ * type: boolean
306
+ * next_expected_stream_version:
307
+ * type: string
308
+ * last_event_global_position:
309
+ * type: string
310
+ * '401':
311
+ * description: Not authenticated
312
+ * '409':
313
+ * description: {message errorMapping returns — one line per error code}
314
+ * '500':
315
+ * description: Server error
316
+ */
264
317
  router.post('/api/{slicename}/:id', async (req: Request, res: Response) => {
265
318
  const auth = await requireUser(req, res);
266
319
  if (auth.error) return;
@@ -313,6 +366,39 @@ const errorMapping = (code: string): string | null => {
313
366
 
314
367
  ---
315
368
 
369
+ ### Step 5a — OpenAPI annotation (required)
370
+
371
+ `src/swagger.ts` builds the published OpenAPI document by scanning `./src/slices/**/routes.ts` for `@openapi` JSDoc blocks. A handler without one is **invisible** in Swagger UI (`/api-docs`) and in `/swagger.json` — the endpoint works, but nobody can find it. `src/swagger.ts` is shared infra and outside a slice's commit scope, so the block in this slice's own `routes.ts` is the only place the endpoint can be documented. The `openapi-annotation` commit check rejects a `routes.ts` whose handlers have no matching block.
372
+
373
+ Everything in the block comes from slice.json — same rule as the code: no invented fields, no guessed types.
374
+
375
+ | slice.json field `type` | OpenAPI schema |
376
+ |---|---|
377
+ | `String` | `type: string` |
378
+ | `UUID` | `type: string`, `format: uuid` |
379
+ | `Int` | `type: integer`, `format: int32` |
380
+ | `Long` | `type: integer`, `format: int64` |
381
+ | `Double` | `type: number`, `format: double` |
382
+ | `Decimal` | `type: number` |
383
+ | `Boolean` | `type: boolean` |
384
+ | `Date` | `type: string`, `format: date` |
385
+ | `DateTime` | `type: string`, `format: date-time` |
386
+ | `Custom` | `type: object` |
387
+
388
+ Mapping rules:
389
+
390
+ - **path key** — the express path with `:param` rewritten as `{param}` (`/api/foo/:id` → `/api/foo/{id}`). If the two disagree, Swagger publishes a path that does not exist.
391
+ - **tags** — `[{Context}]`, the slice's context, so every slice of one context groups under one heading.
392
+ - **summary** — the slice title. **description** — slice.json `description`, plus any `comments[]` that explain what the endpoint does.
393
+ - **requestBody properties** — exactly `commands[].fields`, minus the ones taken from the URL path or a header. Types from the table above.
394
+ - **required** — every command field not marked `optional: true`.
395
+ - **example** — only from the field's own `example` in slice.json. A field with no example gets no `example:` line; do not invent one.
396
+ - **responses** — `'201'` with the body the handler actually returns; one `'409'` per error code in `errorMapping` (i.e. per failing specification); `'401'` whenever the handler enforces auth; `'500'`.
397
+
398
+ A placeholder left unreplaced ships straight into the published spec, and `npm run build` will not catch it — open `/api-docs` and look at the rendered endpoint before marking the slice `Done`.
399
+
400
+ ---
401
+
316
402
  ## Step 6 — Wire up the route
317
403
 
318
404
  Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
@@ -360,4 +446,6 @@ Before marking this slice as `Done`, verify the implementation against slice.jso
360
446
  - [ ] Every entry in `specifications[]` maps to a test case in `{SliceName}.test.ts`
361
447
  - [ ] If `storylines[]` is present, each command-to-event transition relevant to this slice has a corresponding test (or a documented reason it was skipped)
362
448
  - [ ] No business rules, defaults, or constraints were added that do not appear in slice.json `description` or `comments`
363
- - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
449
+ - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
450
+ - [ ] `routes.ts` carries an `@openapi` JSDoc block above every handler, and its path key matches the registered route with `:param` written as `{param}`
451
+ - [ ] The documented request body is exactly `commands[].fields`, and every error code in `errorMapping` has a `'409'` line — no undocumented fields, no invented ones
@@ -327,7 +327,7 @@ Skip (do not fabricate) a segment when:
327
327
 
328
328
  File: `src/slices/{context}/{SliceName}/routes.ts`
329
329
 
330
- > **Concrete example**: `src/slices/example/routes.ts` shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
330
+ > **Pattern reference**: the template below is the full pattern auth, error mapping, and the mandatory `@openapi` annotation (Step 6a). If the project already has slices under `src/slices/`, open one of their `routes.ts` files first and match it.
331
331
 
332
332
  ```typescript
333
333
  import {Request, Response, Router} from 'express';
@@ -339,6 +339,48 @@ import createClient from '../../../supabase/api';
339
339
 
340
340
  export const api = (): WebApiSetup => (router: Router): void => {
341
341
 
342
+ /**
343
+ * @openapi
344
+ * /api/query/{slicename}-collection:
345
+ * get:
346
+ * tags: [{Context}]
347
+ * summary: {slice title from slice.json}
348
+ * description: {slice.json description — what this read model answers}
349
+ * security:
350
+ * - bearerAuth: []
351
+ * parameters:
352
+ * - in: query
353
+ * name: _id
354
+ * required: false
355
+ * schema:
356
+ * type: string
357
+ * description: When set, returns the single row with this id instead of the full collection
358
+ * responses:
359
+ * '200':
360
+ * description: The {SliceName} read model
361
+ * content:
362
+ * application/json:
363
+ * schema:
364
+ * oneOf:
365
+ * - $ref: '#/components/schemas/{SliceName}ReadModel'
366
+ * - type: array
367
+ * items:
368
+ * $ref: '#/components/schemas/{SliceName}ReadModel'
369
+ * '401':
370
+ * description: Not authenticated
371
+ * '500':
372
+ * description: Server error
373
+ * components:
374
+ * schemas:
375
+ * {SliceName}ReadModel:
376
+ * type: object
377
+ * properties:
378
+ * id:
379
+ * type: string
380
+ * {fieldName}:
381
+ * type: string
382
+ * example: {the field's own example from slice.json, if it has one}
383
+ */
342
384
  router.get('/api/query/{slicename}-collection', async (req: Request, res: Response) => {
343
385
  try {
344
386
  const principal = await requireUser(req, res, true);
@@ -369,6 +411,40 @@ export const api = (): WebApiSetup => (router: Router): void => {
369
411
 
370
412
  ---
371
413
 
414
+ ### Step 6a — OpenAPI annotation (required)
415
+
416
+ `src/swagger.ts` builds the published OpenAPI document by scanning `./src/slices/**/routes.ts` for `@openapi` JSDoc blocks. A handler without one is **invisible** in Swagger UI (`/api-docs`) and in `/swagger.json` — the endpoint works, but nobody can find it. `src/swagger.ts` is shared infra and outside a slice's commit scope, so the block in this slice's own `routes.ts` is the only place the endpoint can be documented. The `openapi-annotation` commit check rejects a `routes.ts` whose handlers have no matching block.
417
+
418
+ Everything in the block comes from slice.json — same rule as the code: no invented fields, no guessed types.
419
+
420
+ | slice.json field `type` | OpenAPI schema |
421
+ |---|---|
422
+ | `String` | `type: string` |
423
+ | `UUID` | `type: string`, `format: uuid` |
424
+ | `Int` | `type: integer`, `format: int32` |
425
+ | `Long` | `type: integer`, `format: int64` |
426
+ | `Double` | `type: number`, `format: double` |
427
+ | `Decimal` | `type: number` |
428
+ | `Boolean` | `type: boolean` |
429
+ | `Date` | `type: string`, `format: date` |
430
+ | `DateTime` | `type: string`, `format: date-time` |
431
+ | `Custom` | `type: object` |
432
+
433
+ Mapping rules:
434
+
435
+ - **path key** — the express path exactly as registered (a query read model has no path params; if you do add one, rewrite `:param` as `{param}`).
436
+ - **tags** — `[{Context}]`, the slice's context, so every slice of one context groups under one heading.
437
+ - **summary** — the slice title. **description** — slice.json `description`, plus any `comments[]` that explain what the read model answers.
438
+ - **schema properties** — exactly the read model fields from slice.json, the same set as the migration columns and the `{SliceName}ReadModel` type. Keep the JSON field spelling the route returns, not the snake_case column name, when they differ.
439
+ - **required** — omit it unless slice.json marks fields as mandatory; a projection row can legitimately be sparse.
440
+ - **example** — only from the field's own `example` in slice.json. A field with no example gets no `example:` line; do not invent one.
441
+ - **`components.schemas`** — declaring the read model once and `$ref`-ing it keeps the single-row and collection responses in sync. swagger-jsdoc merges the `components` block from every scanned file, so name the schema `{SliceName}ReadModel` to avoid colliding with another slice's.
442
+ - **responses** — `'200'` with the shape above, `'401'` whenever the handler enforces auth, `'500'`.
443
+
444
+ A placeholder left unreplaced ships straight into the published spec, and `npm run build` will not catch it — open `/api-docs` and look at the rendered endpoint before marking the slice `Done`.
445
+
446
+ ---
447
+
372
448
  ## Step 7 — Wire up the route
373
449
 
374
450
  Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
@@ -412,4 +488,6 @@ src/common/
412
488
  - [ ] Every field in the read model definition in slice.json has a column in the migration and a field in the TypeScript type — no invented columns
413
489
  - [ ] Every event type in `events[]` is listed in the projection's `canHandle` — no assumed events
414
490
  - [ ] No extra columns or fields were added beyond what slice.json defines
415
- - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
491
+ - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
492
+ - [ ] `routes.ts` carries an `@openapi` JSDoc block above every handler, and its path key matches the registered route
493
+ - [ ] The documented response schema lists exactly the read model fields from slice.json — same set as the migration columns and the `{SliceName}ReadModel` type
@@ -182,6 +182,48 @@ async function handle{SliceName}(id: string, command: {SliceName}Command) {
182
182
 
183
183
  console.info('{function-name} started');
184
184
 
185
+ /**
186
+ * @openapi
187
+ * /functions/v1/{function-name}:
188
+ * post:
189
+ * tags: [{Context}]
190
+ * summary: {slice title from slice.json}
191
+ * description: {slice.json description — which external system calls this, and when}
192
+ * requestBody:
193
+ * required: true
194
+ * content:
195
+ * application/json:
196
+ * schema:
197
+ * type: object
198
+ * required: [{command fields without optional: true}]
199
+ * properties:
200
+ * id:
201
+ * type: string
202
+ * description: Aggregate id — generated when the caller omits it
203
+ * {fieldName}:
204
+ * type: string
205
+ * example: {the field's own example from slice.json, if it has one}
206
+ * responses:
207
+ * '201':
208
+ * description: Accepted — {EventName} appended
209
+ * content:
210
+ * application/json:
211
+ * schema:
212
+ * type: object
213
+ * properties:
214
+ * ok:
215
+ * type: boolean
216
+ * id:
217
+ * type: string
218
+ * nextExpectedStreamVersion:
219
+ * type: string
220
+ * lastEventGlobalPosition:
221
+ * type: string
222
+ * '409':
223
+ * description: {message thrown by decide — one line per error code}
224
+ * '500':
225
+ * description: Internal server error
226
+ */
185
227
  export default {
186
228
  fetch: withSupabase({auth: ['publishable', 'secret']}, async (req, _ctx) => {
187
229
  try {
@@ -233,6 +275,35 @@ export default {
233
275
 
234
276
  ---
235
277
 
278
+ ### Step 3a — OpenAPI annotation (required)
279
+
280
+ `src/swagger.ts` scans `./supabase/functions/**/index.ts` alongside the express routes, so the webhook shows up in Swagger UI (`/api-docs`) and `/swagger.json` next to the rest of the API — but only if the function carries an `@openapi` JSDoc block. Without one the endpoint is undocumented, which matters more here than anywhere else: a webhook's only consumer is an external system whose integrator cannot read this codebase.
281
+
282
+ The block goes directly above the `export default {` handler (as shown in the template above). Everything in it comes from slice.json.
283
+
284
+ | slice.json field `type` | OpenAPI schema |
285
+ |---|---|
286
+ | `String` | `type: string` |
287
+ | `UUID` | `type: string`, `format: uuid` |
288
+ | `Int` | `type: integer`, `format: int32` |
289
+ | `Long` | `type: integer`, `format: int64` |
290
+ | `Double` | `type: number`, `format: double` |
291
+ | `Decimal` | `type: number` |
292
+ | `Boolean` | `type: boolean` |
293
+ | `Date` | `type: string`, `format: date` |
294
+ | `DateTime` | `type: string`, `format: date-time` |
295
+ | `Custom` | `type: object` |
296
+
297
+ Mapping rules:
298
+
299
+ - **path key** — `/functions/v1/{function-name}`, the deployed URL, not the file path.
300
+ - **requestBody properties** — exactly the `{SliceName}Payload` fields, which are exactly `commands[].fields`. **required** — every one not marked `optional: true` (never `id`, which the handler generates when absent).
301
+ - **responses** — `'201'` with the body the handler returns; one `'409'` per error code thrown in `decide`; `'500'`.
302
+ - **security** — a `verify_jwt = false` webhook takes no bearer token, so omit the `security:` block. When the function verifies a provider signature header instead (Stripe et al.), document that header under `parameters:` so the integrator knows to send it.
303
+ - `example` only from the field's own `example` in slice.json; never invent one.
304
+
305
+ ---
306
+
236
307
  ## Step 4 — Verify event store schema migration
237
308
 
238
309
  The Emmett schema must be migrated before the edge function can write events. This happens once in the **backend startup**. Confirm that `src/common/loadPostgresEventstore.ts` calls `schema.migrate()`:
@@ -286,6 +357,8 @@ curl -i -X POST http://localhost:54321/functions/v1/{function-name} \
286
357
  - [ ] `supabase/config.toml` entry added with correct `verify_jwt` setting
287
358
  - [ ] `schema.migrate()` confirmed in backend startup (or run manually)
288
359
  - [ ] Local test with `supabase functions serve` passes
360
+ - [ ] An `@openapi` block sits above the `export default` handler, keyed on `/functions/v1/{function-name}`
361
+ - [ ] The documented request body is exactly the `{SliceName}Payload` fields, and every error code thrown in `decide` has a `'409'` line
289
362
 
290
363
  ---
291
364
 
@@ -20,7 +20,9 @@ Read Events in src/events to understand the global structure.
20
20
  3. Follow TypeScript best practices for type definitions and interfaces
21
21
 
22
22
  Only check src/slices/{slice}/*.ts, do not check subfolders unless explicitely tasked to.
23
- If not tasked explicitely to change routes, ignore routes*.ts
23
+ If not tasked explicitely to change routes, ignore routes*.ts — except the `routes.ts` of the slice you are
24
+ building: the build skill owns that file, and its `@openapi` block has to stay in step with the slice's
25
+ fields (the `openapi-annotation` check blocks the commit otherwise).
24
26
 
25
27
  Ignore case for files and slices in prompts. "CartItems" slice is the same as "cartitems"
26
28
 
@@ -76,6 +78,9 @@ and rejects the commit if any of them find a problem:
76
78
  `.build-kit/.slices/{context}/{slice}/slice.json`
77
79
  - **spec-coverage** — heuristic: the test file needs at least as many `it(...)` blocks as slice.json
78
80
  has `specifications[]` entries
81
+ - **openapi-annotation** — every handler in a slice's `routes.ts` needs an `@openapi` JSDoc block
82
+ above it, keyed on the registered path (`:param` written as `{param}`); without it the endpoint
83
+ never reaches `/api-docs` or `/swagger.json`
79
84
  - **tsc-build** — `npx tsc --noEmit` must still pass
80
85
 
81
86
  If a commit is rejected, split it — commit the out-of-scope file separately from the slice work, or add
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // Every HTTP handler in a slice's routes.ts must carry an `@openapi` JSDoc block
4
+ // directly above it. src/swagger.ts builds the published OpenAPI document by
5
+ // scanning ./src/slices/**/routes.ts for those blocks, so a handler without one
6
+ // is a working endpoint that never appears in Swagger UI (/api-docs) or in
7
+ // /swagger.json — and swagger.ts itself is shared infra a slice commit may not
8
+ // touch, so the block in routes.ts is the only place the endpoint can be
9
+ // documented. See the build-state-change / build-state-view SKILL.md files for
10
+ // the block template and the slice.json -> OpenAPI field mapping.
11
+ //
12
+ // Heuristic, not an OpenAPI parser: it checks that a block exists between the
13
+ // previous handler and this one, and that the block names this handler's own
14
+ // path (express `:param` rewritten as `{param}`). An invalid schema, or a
15
+ // placeholder left unreplaced, still slips through — the rendered /api-docs
16
+ // page is the real check.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const ROUTES_FILE = /^src\/slices\/[^/]+\/[^/]+\/routes\.ts$/;
22
+ const ROUTE_CALL = /\brouter\s*\.\s*(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2/g;
23
+ const OPENAPI_BLOCK = /\/\*\*(?:[\s\S]*?)@openapi(?:[\s\S]*?)\*\//g;
24
+
25
+ // /api/foo/:id -> /api/foo/{id}
26
+ const toOpenApiPath = (p) => p.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
27
+
28
+ module.exports = {
29
+ name: 'openapi-annotation',
30
+ run(ctx) {
31
+ const violations = [];
32
+
33
+ for (const { path: p } of ctx.changes) {
34
+ if (!ROUTES_FILE.test(p)) continue;
35
+
36
+ let content;
37
+ try {
38
+ content = fs.readFileSync(path.join(ctx.repoRoot, p), 'utf8');
39
+ } catch {
40
+ continue; // deleted — nothing to check
41
+ }
42
+
43
+ ROUTE_CALL.lastIndex = 0;
44
+ let cursor = 0; // start of the text belonging to the handler being checked
45
+ let call;
46
+ while ((call = ROUTE_CALL.exec(content))) {
47
+ const [, method, , routePath] = call;
48
+ const preceding = content.slice(cursor, call.index);
49
+ cursor = ROUTE_CALL.lastIndex;
50
+
51
+ const blocks = preceding.match(OPENAPI_BLOCK) || [];
52
+ if (blocks.length === 0) {
53
+ violations.push({
54
+ path: p,
55
+ reason: `${method.toUpperCase()} ${routePath} has no @openapi JSDoc block above it — the endpoint would be missing from /api-docs and /swagger.json`,
56
+ });
57
+ continue;
58
+ }
59
+
60
+ const documented = toOpenApiPath(routePath);
61
+ if (!blocks[blocks.length - 1].includes(documented)) {
62
+ violations.push({
63
+ path: p,
64
+ reason: `the @openapi block above ${method.toUpperCase()} ${routePath} does not document "${documented}" — the path key must match the registered route, with express ":param" written as "{param}"`,
65
+ });
66
+ }
67
+ }
68
+ }
69
+
70
+ return violations;
71
+ },
72
+ };
@@ -28,7 +28,9 @@ const options = {
28
28
  },
29
29
  },
30
30
  },
31
- apis: ['./src/slices/**/routes.ts'],
31
+ // Edge functions carry their own @openapi blocks (see the build-webhook skill),
32
+ // so a webhook shows up in the same document as the express routes.
33
+ apis: ['./src/slices/**/routes.ts', './supabase/functions/**/index.ts'],
32
34
  };
33
35
 
34
36
  export const specs = swaggerJsdoc(options);