@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.
- package/README.md +25 -26
- package/bin/cli.mjs +176 -9
- package/package.json +10 -2
- package/src/agents/planner.md +38 -6
- package/src/agents/ux-planner.md +29 -13
- package/src/db/core.mjs +10 -8
- package/src/db/next.mjs +72 -3
- package/src/db/rebuild.mjs +72 -14
- package/src/golden-cores/full-stack-app/apps/web/package.json +12 -0
- package/src/golden-cores/full-stack-app/apps/web/src/app/module-routes.ts +13 -0
- package/src/golden-cores/full-stack-app/apps/web/src/app/page.tsx +21 -1
- package/src/golden-cores/full-stack-app/nx.json +7 -3
- package/src/golden-cores/full-stack-app/packages/db/src/lib/db.ts +15 -1
- package/src/golden-cores/full-stack-app/tools/generate-module-routes.cjs +89 -0
- package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +59 -6
- package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +5 -1
- package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +75 -8
- package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +5 -1
- package/src/golden-cores/full-stack-app/tools/generators/fields.ts +38 -4
- package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +29 -12
- package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +1 -1
- package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +1 -1
- package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +82 -6
- package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +4 -0
- package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +26 -0
- package/src/skills/hedgehog-core-design/SKILL.md +9 -1
- package/src/skills/hedgehog-loop/SKILL.md +39 -7
- package/src/skills/hedgehog-planning-intake/SKILL.md +103 -6
- package/src/templates/CLAUDE.core.full-stack-app.md +7 -2
- package/src/templates/CLAUDE.md +21 -6
package/src/db/rebuild.mjs
CHANGED
|
@@ -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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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"]
|