@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.
Files changed (29) hide show
  1. package/bin/cli.mjs +164 -8
  2. package/package.json +1 -1
  3. package/src/agents/planner.md +38 -6
  4. package/src/agents/ux-planner.md +29 -13
  5. package/src/db/core.mjs +10 -8
  6. package/src/db/next.mjs +72 -3
  7. package/src/db/rebuild.mjs +72 -14
  8. package/src/golden-cores/full-stack-app/apps/web/package.json +12 -0
  9. package/src/golden-cores/full-stack-app/apps/web/src/app/module-routes.ts +13 -0
  10. package/src/golden-cores/full-stack-app/apps/web/src/app/page.tsx +21 -1
  11. package/src/golden-cores/full-stack-app/nx.json +7 -3
  12. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.ts +15 -1
  13. package/src/golden-cores/full-stack-app/tools/generate-module-routes.cjs +89 -0
  14. package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +59 -6
  15. package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +5 -1
  16. package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +75 -8
  17. package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +5 -1
  18. package/src/golden-cores/full-stack-app/tools/generators/fields.ts +38 -4
  19. package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +29 -12
  20. package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +1 -1
  21. package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +1 -1
  22. package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +82 -6
  23. package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +4 -0
  24. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +26 -0
  25. package/src/skills/hedgehog-core-design/SKILL.md +9 -1
  26. package/src/skills/hedgehog-loop/SKILL.md +39 -7
  27. package/src/skills/hedgehog-planning-intake/SKILL.md +103 -6
  28. package/src/templates/CLAUDE.core.full-stack-app.md +7 -2
  29. 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
- <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"]
@@ -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 type = (nullable ? rawType.slice(0, -1) : rawType) as FieldType;
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: 255 })`,
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: 'z.string().min(1).max(255)',
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 update = useUpdate${entityPascal}();
201
-
202
- return {
203
- ...update,
204
- mutate: (variables: { id: string; ${toggleField}: boolean }) =>
205
- update.mutate({ id: variables.id, ${toggleField}: !variables.${toggleField} }),
206
- mutateAsync: (variables: { id: string; ${toggleField}: boolean }) =>
207
- update.mutateAsync({
208
- id: variables.id,
209
- ${toggleField}: !variables.${toggleField},
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 dedicated toggle mutation. Omit when the module has none."
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
  },