@skyf0xx/hedgehog 4.3.0 → 4.3.4

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.
Files changed (30) hide show
  1. package/README.md +25 -26
  2. package/bin/cli.mjs +176 -9
  3. package/package.json +10 -2
  4. package/src/agents/planner.md +38 -6
  5. package/src/agents/ux-planner.md +29 -13
  6. package/src/db/core.mjs +10 -8
  7. package/src/db/next.mjs +72 -3
  8. package/src/db/rebuild.mjs +72 -14
  9. package/src/golden-cores/full-stack-app/apps/web/package.json +12 -0
  10. package/src/golden-cores/full-stack-app/apps/web/src/app/module-routes.ts +13 -0
  11. package/src/golden-cores/full-stack-app/apps/web/src/app/page.tsx +21 -1
  12. package/src/golden-cores/full-stack-app/nx.json +7 -3
  13. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.ts +15 -1
  14. package/src/golden-cores/full-stack-app/tools/generate-module-routes.cjs +89 -0
  15. package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +59 -6
  16. package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +5 -1
  17. package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +75 -8
  18. package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +5 -1
  19. package/src/golden-cores/full-stack-app/tools/generators/fields.ts +38 -4
  20. package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +29 -12
  21. package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +1 -1
  22. package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +1 -1
  23. package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +82 -6
  24. package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +4 -0
  25. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +26 -0
  26. package/src/skills/hedgehog-core-design/SKILL.md +9 -1
  27. package/src/skills/hedgehog-loop/SKILL.md +39 -7
  28. package/src/skills/hedgehog-planning-intake/SKILL.md +103 -6
  29. package/src/templates/CLAUDE.core.full-stack-app.md +7 -2
  30. package/src/templates/CLAUDE.md +21 -6
@@ -43,6 +43,67 @@ function intentExists(db, id) {
43
43
  return db.prepare('SELECT 1 FROM intents WHERE id = ?').get(id) !== undefined;
44
44
  }
45
45
 
46
+ // A rebuild's result is a pure function of the committed sources
47
+ // (`.hedgehog/intents/`, core.yaml, overrides, git history), so the
48
+ // derived graph is cleared before replay rather than replayed on top of
49
+ // whatever the DB already held. Without this, an intent file that was
50
+ // renamed or deleted leaves its tasks behind: the scheduler then sees a
51
+ // ghost task whose scope overlaps the real one and holds the real one
52
+ // back, producing a graph no set of committed intents describes.
53
+ //
54
+ // Deleting `intents` cascades through requirements, tasks,
55
+ // task_requirements, dependencies, artifacts and verifications — every
56
+ // one of which this run re-derives. `debt` and `friction` are the
57
+ // exception: they are operator-recorded notes with no committed source,
58
+ // so they are carried across by task id (deterministic, so a note
59
+ // re-attaches to the same task the replay recompiles). A note whose task
60
+ // no longer exists in the new graph has nowhere to live and is reported
61
+ // rather than silently dropped.
62
+ function clearDerivedGraph(db) {
63
+ const debt = db.prepare('SELECT task_id, note, logged_at FROM debt').all();
64
+ const friction = db.prepare('SELECT task_id, note, logged_at FROM friction').all();
65
+
66
+ db.prepare('DELETE FROM intents').run();
67
+ // `friction.task_id` is ON DELETE SET NULL rather than CASCADE, so its
68
+ // rows outlive the delete above. Clear them too and let restoreNotes be
69
+ // the single writer, so a note is not duplicated against its own copy.
70
+ db.prepare('DELETE FROM friction').run();
71
+
72
+ return { debt, friction };
73
+ }
74
+
75
+ function restoreNotes(db, { debt, friction }) {
76
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
77
+ const insertDebt = db.prepare(
78
+ 'INSERT INTO debt (task_id, note, logged_at) VALUES (?, ?, ?)',
79
+ );
80
+ const insertFriction = db.prepare(
81
+ 'INSERT INTO friction (task_id, note, logged_at) VALUES (?, ?, ?)',
82
+ );
83
+
84
+ const orphaned = [];
85
+
86
+ for (const row of debt) {
87
+ if (taskExists.get(row.task_id) === undefined) {
88
+ orphaned.push({ kind: 'debt', taskId: row.task_id, note: row.note });
89
+ continue;
90
+ }
91
+ insertDebt.run(row.task_id, row.note, row.logged_at);
92
+ }
93
+
94
+ for (const row of friction) {
95
+ // friction.task_id is nullable — an unattached note always survives.
96
+ if (row.task_id !== null && taskExists.get(row.task_id) === undefined) {
97
+ insertFriction.run(null, row.note, row.logged_at);
98
+ orphaned.push({ kind: 'friction', taskId: row.task_id, note: row.note });
99
+ continue;
100
+ }
101
+ insertFriction.run(row.task_id, row.note, row.logged_at);
102
+ }
103
+
104
+ return orphaned;
105
+ }
106
+
46
107
  // Topological sort over `depends_on`, tie-broken by the file order above
47
108
  // so two runs over the same directory always replay identically.
48
109
  //
@@ -71,9 +132,9 @@ function orderIntentsForReplay(records, isSatisfied) {
71
132
 
72
133
  const entry = byId.get(id);
73
134
  if (!entry) {
74
- // Not a file in this directory. Fine if the DB already has it
75
- // (rebuild tolerates an already-populated DB); otherwise the edge
76
- // points at nothing and would fail the FOREIGN KEY.
135
+ // Not a file in this directory, and the derived graph was cleared
136
+ // before replay, so the edge points at nothing and would fail the
137
+ // FOREIGN KEY.
77
138
  if (isSatisfied(id)) return;
78
139
  throw new Error(
79
140
  `intent "${requiredBy}" depends_on "${id}", which has no intent file in ` +
@@ -98,12 +159,6 @@ function orderIntentsForReplay(records, isSatisfied) {
98
159
  // Reads every intent file, normalizes it, orders the set by depends_on,
99
160
  // and inserts the rows. Read-only with respect to the files themselves.
100
161
  //
101
- // The insert is unconditional (an intent id is never re-added through
102
- // `intent add` in normal use), so rebuild — the one caller that must also
103
- // tolerate an already-populated DB, per "re-derive from source-of-truth
104
- // after suspected corruption" — skips any intent whose id is already
105
- // present rather than letting the UNIQUE constraint fail the whole run.
106
- //
107
162
  // Ordering is resolved for the whole set BEFORE any row is written, so a
108
163
  // cycle or a dangling depends_on fails the run without having half-built
109
164
  // the graph.
@@ -232,10 +287,9 @@ function markCompletedTasks(db, commitSubjects) {
232
287
  const setComplete = db.prepare("UPDATE tasks SET status = 'complete' WHERE id = ?");
233
288
  for (const id of complete) setComplete.run(id);
234
289
 
235
- // Rebuild also runs against an already-populated DB ("after suspected
236
- // corruption"), where a once-task may be sitting at `complete` from
237
- // before a re-entry pass added work under it. Reconcile it back rather
238
- // than leaving the stale status untouched.
290
+ // A once-task can be marked complete by the loop above and then fail
291
+ // one of the extra conditions on a later pass of the fixpoint walk.
292
+ // Reconcile it back rather than leaving the stale status untouched.
239
293
  const reopen = db.prepare(
240
294
  "UPDATE tasks SET status = 'planned' WHERE id = ? AND status = 'complete'",
241
295
  );
@@ -268,6 +322,8 @@ export async function rebuildDb(
268
322
  ) {
269
323
  applySchema(db);
270
324
 
325
+ const notes = clearDerivedGraph(db);
326
+
271
327
  const intentsReplayed = await replayIntents(db, intentsDir);
272
328
 
273
329
  const core = await loadCore(corePath);
@@ -277,7 +333,9 @@ export async function rebuildDb(
277
333
  const commitSubjects = loadCommitSubjects();
278
334
  const tasksMarkedComplete = markCompletedTasks(db, commitSubjects);
279
335
 
336
+ const orphanedNotes = restoreNotes(db, notes);
337
+
280
338
  const drift = detectDrift(db, core, { overrides });
281
339
 
282
- return { intentsReplayed, tasksMarkedComplete, drift };
340
+ return { intentsReplayed, tasksMarkedComplete, orphanedNotes, drift };
283
341
  }
@@ -21,6 +21,18 @@
21
21
  "prettier-plugin-tailwindcss": "^0.7.4"
22
22
  },
23
23
  "nx": {
24
+ "targets": {
25
+ "generate-module-routes": {
26
+ "executor": "nx:run-commands",
27
+ "cache": true,
28
+ "inputs": ["{projectRoot}/src/app/*/page.tsx"],
29
+ "outputs": ["{projectRoot}/src/app/module-routes.ts"],
30
+ "options": {
31
+ "command": "node tools/generate-module-routes.cjs",
32
+ "cwd": "{workspaceRoot}"
33
+ }
34
+ }
35
+ },
24
36
  "tags": ["scope:web"]
25
37
  }
26
38
  }
@@ -0,0 +1,13 @@
1
+ // GENERATED FILE — do not hand-edit. Regenerated by
2
+ // tools/generate-module-routes.cjs (the generate-module-routes Nx
3
+ // target, which build, typecheck and test depend on) from every
4
+ // apps/web/src/app/*/page.tsx file on disk. A module's screen layer
5
+ // only ever creates its own page.tsx inside its own directory —
6
+ // never edits this file or the root page.
7
+
8
+ export interface ModuleRoute {
9
+ href: string;
10
+ label: string;
11
+ }
12
+
13
+ export const moduleRoutes: ModuleRoute[] = [];
@@ -1,6 +1,13 @@
1
+ import Link from 'next/link';
1
2
  import { Button } from '@/components/ui/button';
2
3
  import { ThemeToggle } from '@/components/theme-toggle';
4
+ import { moduleRoutes } from './module-routes';
3
5
 
6
+ // The index of everything built so far. `moduleRoutes` is generated from
7
+ // the route directories on disk (tools/generate-module-routes.cjs), so a
8
+ // module's screen layer only writes its own apps/web/src/app/<module>/
9
+ // and this page picks it up — no task ever edits this file, which is what
10
+ // keeps it outside every module-scoped layer's ALLOWED SCOPE.
4
11
  export default function Index() {
5
12
  return (
6
13
  <div className="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
@@ -8,7 +15,20 @@ export default function Index() {
8
15
  <h1 className="text-2xl font-semibold">Hedgehog</h1>
9
16
  <ThemeToggle />
10
17
  </div>
11
- <Button>Get started</Button>
18
+
19
+ {moduleRoutes.length === 0 ? (
20
+ <p className="text-muted-foreground max-w-prose text-center text-sm">
21
+ No modules yet. Each one you build gets a route here.
22
+ </p>
23
+ ) : (
24
+ <nav className="flex flex-wrap items-center justify-center gap-3">
25
+ {moduleRoutes.map((route) => (
26
+ <Button key={route.href} asChild variant="outline">
27
+ <Link href={route.href}>{route.label}</Link>
28
+ </Button>
29
+ ))}
30
+ </nav>
31
+ )}
12
32
  </div>
13
33
  );
14
34
  }
@@ -2,14 +2,18 @@
2
2
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
3
3
  "targetDefaults": {
4
4
  "build": {
5
- "cache": true
5
+ "cache": true,
6
+ "dependsOn": ["generate-module-routes"]
7
+ },
8
+ "dev": {
9
+ "dependsOn": ["generate-module-routes"]
6
10
  },
7
11
  "test": {
8
12
  "cache": true,
9
- "dependsOn": ["^build", "generate-feature-modules"]
13
+ "dependsOn": ["^build", "generate-feature-modules", "generate-module-routes"]
10
14
  },
11
15
  "typecheck": {
12
- "dependsOn": ["generate-feature-modules"]
16
+ "dependsOn": ["generate-feature-modules", "generate-module-routes"]
13
17
  },
14
18
  "lint": {
15
19
  "cache": true
@@ -19,7 +19,21 @@ let client: NodePgDatabase | undefined;
19
19
  export function getPool(): Pool {
20
20
  if (!pool) {
21
21
  const env = loadEnv();
22
- pool = new Pool({ connectionString: env.DATABASE_URL });
22
+ pool = new Pool({
23
+ connectionString: env.DATABASE_URL,
24
+ // A wedged connection surfaces as a query error rather than a hang.
25
+ connectionTimeoutMillis: 10_000,
26
+ idleTimeoutMillis: 30_000,
27
+ });
28
+ // `pg` emits 'error' on the pool when an *idle* client's connection dies
29
+ // (Postgres restart, failover, laptop sleep). Node terminates the process
30
+ // on an unhandled 'error' event, so without this listener a routine
31
+ // database blip crashes the API. `pg` has already discarded the bad
32
+ // client by the time this fires: the next query opens a fresh connection,
33
+ // so logging and swallowing is the recovery.
34
+ pool.on('error', (err) => {
35
+ console.error('idle postgres client error', err);
36
+ });
23
37
  }
24
38
  return pool;
25
39
  }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regenerates apps/web/src/app/module-routes.ts — the list of every
4
+ * domain module's route for the root page to link.
5
+ *
6
+ * Written as CommonJS (`.cjs`) for the same reason as its sibling
7
+ * generate-feature-modules.cjs: root `package.json` sets `"type":
8
+ * "module"`, so a plain `.js` file here would be parsed as ESM.
9
+ *
10
+ * Why generated rather than hand-edited: the screen layer's scope is
11
+ * `apps/web/src/app/{module}/**` (core.yaml) — module-disjoint by
12
+ * construction, and `validateCore` requires that `{module}` on a
13
+ * module-axis core, so `apps/web/src/app/page.tsx` one level up is
14
+ * outside every layer's scope. Without this the root page can never
15
+ * learn about the modules built beneath it: a finished build ships a
16
+ * landing page with no route into the app it just built. This is the
17
+ * same shape as the API's feature-modules barrel, and gets the same
18
+ * answer — a shared file no module-scoped task can safely touch is
19
+ * generated, never hand-edited.
20
+ *
21
+ * Convention: any apps/web/src/app/<segment>/page.tsx (route directories
22
+ * only, one level deep — not the root page.tsx itself) is a module
23
+ * route. Next.js route groups and private directories are skipped:
24
+ * `(group)` does not appear in the URL, and `_private`/`.`-prefixed
25
+ * directories are not routes at all.
26
+ *
27
+ * Run via the `generate-module-routes` Nx target, which build, typecheck
28
+ * and test depend on (nx.json targetDefaults) — never run by hand as
29
+ * part of normal development.
30
+ */
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const APP_DIR = path.join(__dirname, '..', 'apps', 'web', 'src', 'app');
35
+ const OUTPUT_FILE = path.join(APP_DIR, 'module-routes.ts');
36
+ const HEADER =
37
+ '// GENERATED FILE — do not hand-edit. Regenerated by\n' +
38
+ '// tools/generate-module-routes.cjs (the generate-module-routes Nx\n' +
39
+ '// target, which build, typecheck and test depend on) from every\n' +
40
+ "// apps/web/src/app/*/page.tsx file on disk. A module's screen layer\n" +
41
+ '// only ever creates its own page.tsx inside its own directory —\n' +
42
+ '// never edits this file or the root page.\n';
43
+
44
+ function findRouteDirs() {
45
+ if (!fs.existsSync(APP_DIR)) return [];
46
+ const entries = fs.readdirSync(APP_DIR, { withFileTypes: true });
47
+ const routes = [];
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) continue;
50
+ // `(group)` is a route group — it organises files without adding a
51
+ // URL segment, so it is not a route. `_x` and `.x` are conventions
52
+ // for "not a route" too.
53
+ if (/^[(_.]/.test(entry.name)) continue;
54
+ if (fs.existsSync(path.join(APP_DIR, entry.name, 'page.tsx'))) {
55
+ routes.push(entry.name);
56
+ }
57
+ }
58
+ return routes.sort();
59
+ }
60
+
61
+ /** "order-items" -> "Order items" — a label, not a class name. */
62
+ function labelFor(segment) {
63
+ const spaced = segment.replace(/-/g, ' ');
64
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
65
+ }
66
+
67
+ function main() {
68
+ const routes = findRouteDirs();
69
+
70
+ const entries = routes
71
+ .map((segment) => ` { href: '/${segment}', label: '${labelFor(segment)}' },`)
72
+ .join('\n');
73
+
74
+ const body =
75
+ `export interface ModuleRoute {\n` +
76
+ ` href: string;\n` +
77
+ ` label: string;\n` +
78
+ `}\n\n` +
79
+ (routes.length > 0
80
+ ? `export const moduleRoutes: ModuleRoute[] = [\n${entries}\n];\n`
81
+ : `export const moduleRoutes: ModuleRoute[] = [];\n`);
82
+
83
+ fs.writeFileSync(OUTPUT_FILE, HEADER + '\n' + body);
84
+ console.log(
85
+ `Generated ${path.relative(process.cwd(), OUTPUT_FILE)} with ${routes.length} module route(s).`,
86
+ );
87
+ }
88
+
89
+ main();
@@ -6,6 +6,7 @@ import { moduleNames, ModuleNames } from '../naming';
6
6
  interface ContractGeneratorOptions {
7
7
  module: string;
8
8
  fields: string;
9
+ toggleField?: string;
9
10
  }
10
11
 
11
12
  const PACKAGE_ROOT = 'packages/contracts';
@@ -32,9 +33,15 @@ export default async function contractGenerator(
32
33
  const dir = `${PACKAGE_ROOT}/src/${names.module}`;
33
34
  tree.write(`${dir}/${names.module}.schema.ts`, entitySchemaFile(names, fields));
34
35
  tree.write(`${dir}/${names.module}.errors.ts`, errorSchemaFile(names));
35
- tree.write(`${dir}/${names.module}.contract.ts`, contractFile(names));
36
+ tree.write(
37
+ `${dir}/${names.module}.contract.ts`,
38
+ contractFile(names, options.toggleField),
39
+ );
36
40
  tree.write(`${dir}/index.ts`, barrelFile(names));
37
- tree.write(`${dir}/${names.module}.spec.ts`, specFile(names, fields));
41
+ tree.write(
42
+ `${dir}/${names.module}.spec.ts`,
43
+ specFile(names, fields, options.toggleField),
44
+ );
38
45
 
39
46
  appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, `./${names.module}/index`);
40
47
 
@@ -115,7 +122,34 @@ export type ${names.entityPascal}BadRequest = z.infer<typeof ${names.entityCamel
115
122
  `;
116
123
  }
117
124
 
118
- function contractFile(names: ModuleNames): string {
125
+ /**
126
+ * The toggle route carries `c.noBody()` deliberately: the new value is
127
+ * derived from stored state on the server, inside the service's existing
128
+ * transaction. A route that took the current value as a body would be a
129
+ * read-modify-write with the read served from the client's cache, so two
130
+ * toggles racing from one stale row would both write the same value and
131
+ * lose a flip.
132
+ *
133
+ * `@ts-rest/core` generates no `body` parameter for a `c.noBody()` route,
134
+ * so the call site is `client.toggle({ params: { id } })` — passing
135
+ * `body: undefined` is a compile error.
136
+ */
137
+ function toggleRoute(names: ModuleNames): string {
138
+ return ` toggle: {
139
+ method: 'POST',
140
+ path: '/${names.module}/:id/toggle',
141
+ pathParams: z.object({ id: z.uuid() }),
142
+ body: c.noBody(),
143
+ responses: {
144
+ 200: ${names.entityCamel}Schema,
145
+ 404: ${names.entityCamel}NotFoundSchema,
146
+ },
147
+ summary: 'Flip a ${names.entityCamel} from its stored state',
148
+ },
149
+ `;
150
+ }
151
+
152
+ function contractFile(names: ModuleNames, toggleField?: string): string {
119
153
  return `import { initContract } from '@ts-rest/core';
120
154
  import { z } from 'zod';
121
155
  import {
@@ -182,7 +216,7 @@ export const ${names.camel}Contract = c.router(
182
216
  },
183
217
  summary: 'Delete a ${names.entityCamel}',
184
218
  },
185
- },
219
+ ${toggleField ? toggleRoute(names) : ''} },
186
220
  {
187
221
  // apps/api sets /api as a global prefix at runtime (apps/api/src/main.ts),
188
222
  // so the paths above stay prefix-free and the client adds the base URL.
@@ -199,7 +233,11 @@ export * from './${names.module}.schema';
199
233
  `;
200
234
  }
201
235
 
202
- function specFile(names: ModuleNames, fields: Field[]): string {
236
+ function specFile(
237
+ names: ModuleNames,
238
+ fields: Field[],
239
+ toggleField?: string,
240
+ ): string {
203
241
  const dateField = fields.find(isDateField);
204
242
  const requiredField = fields.find((field) => !field.nullable);
205
243
 
@@ -221,7 +259,22 @@ describe('${names.camel} contract', () => {
221
259
  expect(${names.camel}Contract.update.method).toBe('PATCH');
222
260
  expect(${names.camel}Contract.remove.method).toBe('DELETE');
223
261
  });
224
-
262
+ ${
263
+ toggleField
264
+ ? `
265
+ it('takes no body on toggle, so a stale client value cannot overwrite state', () => {
266
+ expect(${names.camel}Contract.toggle.method).toBe('POST');
267
+ expect(${names.camel}Contract.toggle.path).toBe('/${names.module}/:id/toggle');
268
+ // c.noBody() is a marker symbol at runtime; what it buys is a client
269
+ // call signature with no body parameter at all, so the new ${toggleField}
270
+ // can only be derived server-side.
271
+ expect(String(${names.camel}Contract.toggle.body)).toBe(
272
+ 'Symbol(ContractNoBody)',
273
+ );
274
+ });
275
+ `
276
+ : ''
277
+ }
225
278
  it('accepts a server-side row, where every timestamp is a Date', () => {
226
279
  expect(${names.entityCamel}Schema.parse(sample)).toBeDefined();
227
280
  });
@@ -12,8 +12,12 @@
12
12
  },
13
13
  "fields": {
14
14
  "type": "string",
15
- "description": "The same name:type list the schema layer was generated with; a trailing ? marks the field nullable.",
15
+ "description": "The same name:type list the schema layer was generated with; a trailing ? marks the field nullable, and string takes an optional length (title:string(500)).",
16
16
  "x-prompt": "Fields (name:type, comma-separated)?"
17
+ },
18
+ "toggleField": {
19
+ "type": "string",
20
+ "description": "A boolean field from the schema layer to expose as a server-side toggle. Pass the same value to the schema, contract, service, controller, and hook layers. Omit when the module has none."
17
21
  }
18
22
  },
19
23
  "required": ["module", "fields"]
@@ -6,6 +6,7 @@ import { moduleNames, ModuleNames } from '../naming';
6
6
  interface ControllerGeneratorOptions {
7
7
  module: string;
8
8
  fields: string;
9
+ toggleField?: string;
9
10
  }
10
11
 
11
12
  const API_ROOT = 'apps/api';
@@ -43,9 +44,15 @@ export default async function controllerGenerator_(
43
44
  // full path, so leaving it would prefix each route twice.
44
45
  stripControllerBasePath(tree, `${stem}.controller.ts`);
45
46
 
46
- tree.write(`${stem}.controller.ts`, controllerFile(names, fields));
47
+ tree.write(
48
+ `${stem}.controller.ts`,
49
+ controllerFile(names, fields, options.toggleField),
50
+ );
47
51
  tree.write(`${stem}.module.ts`, moduleFile(names));
48
- tree.write(`${stem}.controller.spec.ts`, controllerSpecFile(names));
52
+ tree.write(
53
+ `${stem}.controller.spec.ts`,
54
+ controllerSpecFile(names, options.toggleField),
55
+ );
49
56
 
50
57
  addApiDependencies(tree, names);
51
58
 
@@ -62,7 +69,11 @@ function stripControllerBasePath(tree: Tree, path: string) {
62
69
  tree.write(path, source.replace(/@Controller\((['"]).*?\1\)/, '@Controller()'));
63
70
  }
64
71
 
65
- function controllerFile(names: ModuleNames, fields: Field[]): string {
72
+ function controllerFile(
73
+ names: ModuleNames,
74
+ fields: Field[],
75
+ toggleField?: string,
76
+ ): string {
66
77
  const { entityPascal, entityCamel, camel, module } = names;
67
78
  const dateFields = fields.filter(isDateField);
68
79
  const wireShape = dateFields.length
@@ -129,7 +140,25 @@ export class ${names.pascal}Controller {
129
140
  } catch (error) {
130
141
  return notFoundOr(error, params.id);
131
142
  }
132
- },
143
+ },${
144
+ toggleField
145
+ ? `
146
+
147
+ // The id is the whole request: the service reads the current value
148
+ // and flips it inside its own transaction, so there is no client
149
+ // value here that could be stale.
150
+ toggle: async ({ params }) => {
151
+ try {
152
+ return {
153
+ status: 200 as const,
154
+ body: await this.${entityCamel}s.toggle${entityPascal}(params.id),
155
+ };
156
+ } catch (error) {
157
+ return notFoundOr(error, params.id);
158
+ }
159
+ },`
160
+ : ''
161
+ }
133
162
  });
134
163
  }
135
164
  }
@@ -170,10 +199,14 @@ function fromWire<T extends ${wireShape}>(body: T) {
170
199
  };
171
200
  }
172
201
 
173
- function toDate(value: Date | string | null | undefined) {
202
+ ${
203
+ dateFields.length
204
+ ? `function toDate(value: Date | string | null | undefined) {
174
205
  return typeof value === 'string' ? new Date(value) : value;
175
206
  }
176
- `;
207
+ `
208
+ : ''
209
+ }`;
177
210
  }
178
211
 
179
212
  function moduleFile(names: ModuleNames): string {
@@ -217,7 +250,7 @@ export class ${pascal}Module {}
217
250
  `;
218
251
  }
219
252
 
220
- function controllerSpecFile(names: ModuleNames): string {
253
+ function controllerSpecFile(names: ModuleNames, toggleField?: string): string {
221
254
  const { entityPascal, pascal, module } = names;
222
255
 
223
256
  return `import { Test } from '@nestjs/testing';
@@ -285,7 +318,41 @@ describe('${pascal}Controller', () => {
285
318
  handler.get({ params: { id: 'any' } } as never),
286
319
  ).rejects.toBe(boom);
287
320
  });
288
- });
321
+ ${
322
+ toggleField
323
+ ? `
324
+ it('passes only the id to the service, so no client value reaches the write', async () => {
325
+ const toggle${entityPascal} = vi.fn(async () => ({ id: 'one' }) as never);
326
+ const controller = await controllerWith({ toggle${entityPascal} });
327
+
328
+ const handler = await controller.handler();
329
+ const response = await handler.toggle({
330
+ params: { id: 'one' },
331
+ } as never);
332
+
333
+ expect(toggle${entityPascal}).toHaveBeenCalledWith('one');
334
+ expect(response).toMatchObject({ status: 200 });
335
+ });
336
+
337
+ it('maps a not-found on toggle to 404', async () => {
338
+ const controller = await controllerWith({
339
+ toggle${entityPascal}: vi.fn(async () => {
340
+ throw Object.assign(new Error('absent'), {
341
+ name: '${entityPascal}NotFoundError',
342
+ });
343
+ }),
344
+ });
345
+
346
+ const handler = await controller.handler();
347
+ const response = await handler.toggle({
348
+ params: { id: 'missing' },
349
+ } as never);
350
+
351
+ expect(response).toMatchObject({ status: 404 });
352
+ });
353
+ `
354
+ : ''
355
+ }});
289
356
  `;
290
357
  }
291
358
 
@@ -12,8 +12,12 @@
12
12
  },
13
13
  "fields": {
14
14
  "type": "string",
15
- "description": "The same name:type list the schema layer was generated with; a trailing ? marks the field nullable.",
15
+ "description": "The same name:type list the schema layer was generated with; a trailing ? marks the field nullable, and string takes an optional length (title:string(500)).",
16
16
  "x-prompt": "Fields (name:type, comma-separated)?"
17
+ },
18
+ "toggleField": {
19
+ "type": "string",
20
+ "description": "A boolean field from the schema layer to expose as a server-side toggle. Pass the same value to the schema, contract, service, controller, and hook layers. Omit when the module has none."
17
21
  }
18
22
  },
19
23
  "required": ["module", "fields"]