@skyf0xx/hedgehog 4.3.1 → 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/bin/cli.mjs +164 -8
- package/package.json +1 -1
- 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
|
@@ -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"]
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* Wire format (the `--fields` option): a comma-separated list of
|
|
8
8
|
* `name:type` pairs, with a trailing `?` on the type marking the column
|
|
9
9
|
* nullable — `title:string,done:boolean,dueDate:timestamp?`.
|
|
10
|
+
*
|
|
11
|
+
* A `string` takes an optional length in parentheses —
|
|
12
|
+
* `title:string(500)`, or `title:string(500)?` when it is also nullable.
|
|
13
|
+
* The value threads to both the Drizzle `varchar` and the Zod `.max()`,
|
|
14
|
+
* so the two bounds are the same number by construction: a Zod bound that
|
|
15
|
+
* outran its column would let an over-long value past validation and fail
|
|
16
|
+
* at the driver as a 500 instead of a 400.
|
|
10
17
|
*/
|
|
11
18
|
|
|
12
19
|
export type FieldType =
|
|
@@ -20,8 +27,13 @@ export interface Field {
|
|
|
20
27
|
name: string;
|
|
21
28
|
type: FieldType;
|
|
22
29
|
nullable: boolean;
|
|
30
|
+
/** Max characters for a `string`; undefined on every other type. */
|
|
31
|
+
length?: number;
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
/** The `varchar` bound a `string` gets when the field list names none. */
|
|
35
|
+
export const DEFAULT_STRING_LENGTH = 255;
|
|
36
|
+
|
|
25
37
|
const FIELD_TYPES: readonly FieldType[] = [
|
|
26
38
|
'string',
|
|
27
39
|
'text',
|
|
@@ -43,18 +55,35 @@ export function parseFields(raw: string): Field[] {
|
|
|
43
55
|
);
|
|
44
56
|
}
|
|
45
57
|
const nullable = rawType.endsWith('?');
|
|
46
|
-
const
|
|
58
|
+
const bare = nullable ? rawType.slice(0, -1) : rawType;
|
|
59
|
+
|
|
60
|
+
const sized = /^(\w+)\((\d+)\)$/.exec(bare);
|
|
61
|
+
const type = (sized ? sized[1] : bare) as FieldType;
|
|
62
|
+
const length = sized ? Number(sized[2]) : undefined;
|
|
63
|
+
|
|
47
64
|
if (!FIELD_TYPES.includes(type)) {
|
|
48
65
|
throw new Error(
|
|
49
66
|
`Field "${entry}" has unknown type "${type}". Known types: ${FIELD_TYPES.join(', ')}.`,
|
|
50
67
|
);
|
|
51
68
|
}
|
|
69
|
+
// A length on anything but `string` has no column to bind to, so it
|
|
70
|
+
// would be silently dropped — the exact failure mode the syntax
|
|
71
|
+
// exists to remove. `text` is unbounded by design: cap it and you
|
|
72
|
+
// want a `string`.
|
|
73
|
+
if (length !== undefined && type !== 'string') {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Field "${entry}" puts a length on "${type}", which has no length. Only string does (title:string(500)).`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (length !== undefined && length < 1) {
|
|
79
|
+
throw new Error(`Field "${entry}" has length ${length}; it must be at least 1.`);
|
|
80
|
+
}
|
|
52
81
|
if (RESERVED_FIELD_NAMES.includes(name)) {
|
|
53
82
|
throw new Error(
|
|
54
83
|
`Field "${name}" collides with a column every table already carries (${RESERVED_FIELD_NAMES.join(', ')}).`,
|
|
55
84
|
);
|
|
56
85
|
}
|
|
57
|
-
return { name, type, nullable };
|
|
86
|
+
return { name, type, nullable, length };
|
|
58
87
|
});
|
|
59
88
|
|
|
60
89
|
if (fields.length === 0) {
|
|
@@ -88,10 +117,15 @@ const DRIZZLE_IMPORT: Record<FieldType, string> = {
|
|
|
88
117
|
timestamp: 'timestamp',
|
|
89
118
|
};
|
|
90
119
|
|
|
120
|
+
/** The character bound for a `string`, named or defaulted. */
|
|
121
|
+
export function stringLength(field: Field): number {
|
|
122
|
+
return field.length ?? DEFAULT_STRING_LENGTH;
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
export function drizzleColumn(field: Field): string {
|
|
92
126
|
const column = `'${columnName(field.name)}'`;
|
|
93
127
|
const base: Record<FieldType, string> = {
|
|
94
|
-
string: `varchar(${column}, { length:
|
|
128
|
+
string: `varchar(${column}, { length: ${stringLength(field)} })`,
|
|
95
129
|
text: `text(${column})`,
|
|
96
130
|
boolean: `boolean(${column})`,
|
|
97
131
|
integer: `integer(${column})`,
|
|
@@ -116,7 +150,7 @@ export function isDateField(field: Field): boolean {
|
|
|
116
150
|
/** The Zod schema for a field as it appears in a hand-written override. */
|
|
117
151
|
export function zodSchema(field: Field): string {
|
|
118
152
|
const base: Record<FieldType, string> = {
|
|
119
|
-
string:
|
|
153
|
+
string: `z.string().min(1).max(${stringLength(field)})`,
|
|
120
154
|
text: 'z.string()',
|
|
121
155
|
boolean: 'z.boolean()',
|
|
122
156
|
integer: 'z.number().int()',
|
|
@@ -196,19 +196,29 @@ export function useRemove${entityPascal}() {
|
|
|
196
196
|
toggleField
|
|
197
197
|
? `
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Sends the id and nothing else. The server reads the current
|
|
201
|
+
* ${toggleField} and flips it inside one transaction, so a stale row in
|
|
202
|
+
* this cache cannot overwrite a newer state — which is what computing
|
|
203
|
+
* the new value here from a cached row would do, losing one of any two
|
|
204
|
+
* flips that raced.
|
|
205
|
+
*/
|
|
199
206
|
export function useToggle${entityPascal}() {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
const queryClient = useQueryClient();
|
|
208
|
+
|
|
209
|
+
return useMutation({
|
|
210
|
+
mutationFn: async (id: string) => {
|
|
211
|
+
const response = await client.toggle({ params: { id } });
|
|
212
|
+
if (response.status !== 200) {
|
|
213
|
+
throw new Error(\`Failed to toggle ${names.entityCamel} \${id}.\`);
|
|
214
|
+
}
|
|
215
|
+
return response.body;
|
|
216
|
+
},
|
|
217
|
+
onSuccess: (_result, id) => {
|
|
218
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.lists() });
|
|
219
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.detail(id) });
|
|
220
|
+
},
|
|
221
|
+
});
|
|
212
222
|
}`
|
|
213
223
|
: ''
|
|
214
224
|
}
|
|
@@ -266,6 +276,13 @@ describe('${camel} hooks', () => {
|
|
|
266
276
|
const { useToggle${entityPascal} } = await import('./use-${names.module}');
|
|
267
277
|
|
|
268
278
|
expect(useToggle${entityPascal}).toBeTypeOf('function');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('routes the toggle at a server endpoint that takes no body', async () => {
|
|
282
|
+
const { ${camel}Contract } = await import('contracts');
|
|
283
|
+
|
|
284
|
+
expect(${camel}Contract.toggle.method).toBe('POST');
|
|
285
|
+
expect(String(${camel}Contract.toggle.body)).toBe('Symbol(ContractNoBody)');
|
|
269
286
|
});`
|
|
270
287
|
: ''
|
|
271
288
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"toggleField": {
|
|
14
14
|
"type": "string",
|
|
15
|
-
"description": "A boolean field from the schema layer to expose as a
|
|
15
|
+
"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."
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"required": ["module"]
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"fields": {
|
|
14
14
|
"type": "string",
|
|
15
|
-
"description": "Comma-separated name:type pairs; a trailing ? marks the column nullable. Types: string, text, boolean, integer, timestamp. Example: title:string,done:boolean,dueDate:timestamp?",
|
|
15
|
+
"description": "Comma-separated name:type pairs; a trailing ? marks the column nullable, and string takes an optional length in parentheses (default 255). Types: string, text, boolean, integer, timestamp. Example: title:string(500),done:boolean,dueDate:timestamp?",
|
|
16
16
|
"x-prompt": "Fields (name:type, comma-separated)?"
|
|
17
17
|
}
|
|
18
18
|
},
|