@skyf0xx/hedgehog-core-full-stack-app 1.0.0
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/CLAUDE.core.md +147 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/agents/backend-eng.md +189 -0
- package/agents/front-end-eng.md +176 -0
- package/agents/ux-planner.md +174 -0
- package/hedgehog-core.yaml +20 -0
- package/package.json +41 -0
- package/skills/hedgehog-bootstrap/SKILL.md +324 -0
- package/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +411 -0
- package/skills/hedgehog-loop/SKILL.md +643 -0
- package/skills/link-workspace-packages/SKILL.md +41 -0
- package/skills/nx-generate/SKILL.md +165 -0
- package/skills/nx-run-tasks/SKILL.md +52 -0
- package/skills/nx-workspace/SKILL.md +261 -0
- package/skills/nx-workspace/references/AFFECTED.md +27 -0
- package/vendor-skills/GSAP/ATTRIBUTION.md +30 -0
- package/vendor-skills/GSAP/LICENSE +21 -0
- package/vendor-skills/GSAP/gsap-core/SKILL.md +254 -0
- package/vendor-skills/GSAP/gsap-frameworks/SKILL.md +266 -0
- package/vendor-skills/GSAP/gsap-performance/SKILL.md +79 -0
- package/vendor-skills/GSAP/gsap-plugins/SKILL.md +433 -0
- package/vendor-skills/GSAP/gsap-react/SKILL.md +136 -0
- package/vendor-skills/GSAP/gsap-scrolltrigger/SKILL.md +296 -0
- package/vendor-skills/GSAP/gsap-timeline/SKILL.md +107 -0
- package/vendor-skills/GSAP/gsap-utils/SKILL.md +284 -0
- package/vendor-skills/GSAP/llms.txt +39 -0
- package/workspace/.env.example +5 -0
- package/workspace/.github/workflows/phase-gate.yml +17 -0
- package/workspace/.prettierignore +5 -0
- package/workspace/.prettierrc +3 -0
- package/workspace/.vscode/extensions.json +3 -0
- package/workspace/apps/api/eslint.config.mjs +3 -0
- package/workspace/apps/api/package.json +117 -0
- package/workspace/apps/api/src/app/app.module.spec.ts +15 -0
- package/workspace/apps/api/src/app/app.module.ts +15 -0
- package/workspace/apps/api/src/app/feature-modules.ts +8 -0
- package/workspace/apps/api/src/app/health.controller.ts +9 -0
- package/workspace/apps/api/src/assets/.gitkeep +0 -0
- package/workspace/apps/api/src/main.ts +32 -0
- package/workspace/apps/api/tsconfig.app.json +22 -0
- package/workspace/apps/api/tsconfig.json +13 -0
- package/workspace/apps/api/tsconfig.spec.json +36 -0
- package/workspace/apps/api/vitest.config.mts +18 -0
- package/workspace/apps/api/webpack.config.cjs +25 -0
- package/workspace/apps/api-e2e/eslint.config.mjs +3 -0
- package/workspace/apps/api-e2e/package.json +28 -0
- package/workspace/apps/api-e2e/src/api/api.spec.ts +11 -0
- package/workspace/apps/api-e2e/src/support/global-setup.ts +17 -0
- package/workspace/apps/api-e2e/src/support/test-setup.ts +6 -0
- package/workspace/apps/api-e2e/tsconfig.json +10 -0
- package/workspace/apps/api-e2e/tsconfig.spec.json +28 -0
- package/workspace/apps/api-e2e/vitest.config.mts +20 -0
- package/workspace/apps/web/.env.example +12 -0
- package/workspace/apps/web/.prettierrc.js +10 -0
- package/workspace/apps/web/.swcrc +30 -0
- package/workspace/apps/web/components.json +21 -0
- package/workspace/apps/web/eslint.config.mjs +12 -0
- package/workspace/apps/web/index.d.ts +7 -0
- package/workspace/apps/web/next-env.d.ts +7 -0
- package/workspace/apps/web/next.config.js +9 -0
- package/workspace/apps/web/package.json +38 -0
- package/workspace/apps/web/postcss.config.mjs +7 -0
- package/workspace/apps/web/public/.gitkeep +0 -0
- package/workspace/apps/web/public/favicon.ico +0 -0
- package/workspace/apps/web/src/app/global.css +83 -0
- package/workspace/apps/web/src/app/layout.tsx +34 -0
- package/workspace/apps/web/src/app/module-routes.ts +13 -0
- package/workspace/apps/web/src/app/page.tsx +34 -0
- package/workspace/apps/web/src/app/providers.tsx +12 -0
- package/workspace/apps/web/src/components/theme-toggle.spec.tsx +20 -0
- package/workspace/apps/web/src/components/theme-toggle.tsx +32 -0
- package/workspace/apps/web/src/components/ui/button.tsx +60 -0
- package/workspace/apps/web/src/lib/utils.ts +6 -0
- package/workspace/apps/web/src/test-setup.ts +1 -0
- package/workspace/apps/web/tsconfig.json +62 -0
- package/workspace/apps/web/tsconfig.spec.json +37 -0
- package/workspace/apps/web/vitest.config.mts +27 -0
- package/workspace/apps/web-e2e/eslint.config.mjs +12 -0
- package/workspace/apps/web-e2e/package.json +10 -0
- package/workspace/apps/web-e2e/playwright.config.mts +75 -0
- package/workspace/apps/web-e2e/src/example.spec.ts +7 -0
- package/workspace/apps/web-e2e/tsconfig.json +26 -0
- package/workspace/commitlint.config.cjs +3 -0
- package/workspace/core.yaml +68 -0
- package/workspace/docker-compose.yml +14 -0
- package/workspace/eslint.config.mjs +15 -0
- package/workspace/gitignore.template +24 -0
- package/workspace/lefthook.yml +52 -0
- package/workspace/nx.json +90 -0
- package/workspace/package.json +99 -0
- package/workspace/packages/config/eslint-base.js +205 -0
- package/workspace/packages/config/package.json +28 -0
- package/workspace/packages/config/prettier.js +13 -0
- package/workspace/packages/config/src/env.schema.spec.ts +38 -0
- package/workspace/packages/config/src/env.schema.ts +18 -0
- package/workspace/packages/config/src/index.ts +1 -0
- package/workspace/packages/config/tsconfig.json +13 -0
- package/workspace/packages/config/tsconfig.lib.json +27 -0
- package/workspace/packages/config/tsconfig.spec.json +34 -0
- package/workspace/packages/config/vitest.config.mts +18 -0
- package/workspace/packages/db/package.json +27 -0
- package/workspace/packages/db/src/index.ts +2 -0
- package/workspace/packages/db/src/lib/db.spec.ts +48 -0
- package/workspace/packages/db/src/lib/db.ts +54 -0
- package/workspace/packages/db/src/schema/index.ts +4 -0
- package/workspace/packages/db/tsconfig.json +13 -0
- package/workspace/packages/db/tsconfig.lib.json +27 -0
- package/workspace/packages/db/tsconfig.spec.json +34 -0
- package/workspace/packages/db/vitest.config.mts +18 -0
- package/workspace/pnpm-lock.yaml +23719 -0
- package/workspace/pnpm-workspace.yaml +7 -0
- package/workspace/tools/generate-feature-modules.cjs +104 -0
- package/workspace/tools/generate-module-routes.cjs +89 -0
- package/workspace/tools/generators/contract/generator.ts +336 -0
- package/workspace/tools/generators/contract/schema.json +24 -0
- package/workspace/tools/generators/controller/generator.ts +390 -0
- package/workspace/tools/generators/controller/schema.json +24 -0
- package/workspace/tools/generators/fields.ts +160 -0
- package/workspace/tools/generators/generators.json +42 -0
- package/workspace/tools/generators/hook/generator.ts +291 -0
- package/workspace/tools/generators/hook/schema.json +19 -0
- package/workspace/tools/generators/lib-shell.ts +124 -0
- package/workspace/tools/generators/naming.ts +84 -0
- package/workspace/tools/generators/package.json +6 -0
- package/workspace/tools/generators/repository/generator.ts +298 -0
- package/workspace/tools/generators/repository/schema.json +15 -0
- package/workspace/tools/generators/schema/generator.ts +169 -0
- package/workspace/tools/generators/schema/schema.json +20 -0
- package/workspace/tools/generators/screen/generator.ts +218 -0
- package/workspace/tools/generators/screen/schema.json +15 -0
- package/workspace/tools/generators/service/generator.ts +270 -0
- package/workspace/tools/generators/service/schema.json +19 -0
- package/workspace/tools/phase-gate.cjs +77 -0
- package/workspace/tsconfig.base.json +21 -0
- package/workspace/tsconfig.json +25 -0
- package/workspace/vitest.workspace.ts +4 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { controllerGenerator, moduleGenerator } from '@nx/nest';
|
|
2
|
+
import { formatFiles, Tree, updateJson } from '@nx/devkit';
|
|
3
|
+
import { Field, isDateField, parseFields } from '../fields';
|
|
4
|
+
import { moduleNames, ModuleNames } from '../naming';
|
|
5
|
+
|
|
6
|
+
interface ControllerGeneratorOptions {
|
|
7
|
+
module: string;
|
|
8
|
+
fields: string;
|
|
9
|
+
toggleField?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const API_ROOT = 'apps/api';
|
|
13
|
+
|
|
14
|
+
export default async function controllerGenerator_(
|
|
15
|
+
tree: Tree,
|
|
16
|
+
options: ControllerGeneratorOptions,
|
|
17
|
+
) {
|
|
18
|
+
const names = moduleNames(options.module);
|
|
19
|
+
const fields = parseFields(options.fields);
|
|
20
|
+
const dir = `${API_ROOT}/src/app/${names.module}`;
|
|
21
|
+
const stem = `${dir}/${names.module}`;
|
|
22
|
+
|
|
23
|
+
// The Nest generators own the module/controller shell so it cannot drift
|
|
24
|
+
// from a hand-copied sibling. `skipImport` is required: registration
|
|
25
|
+
// happens through the generated feature-modules.ts barrel, which globs
|
|
26
|
+
// apps/api/src/app/*/*.module.ts — the file this generator writes is
|
|
27
|
+
// picked up automatically by the generate-feature-modules Nx target that
|
|
28
|
+
// build, typecheck, and test all depend on. Nothing here edits
|
|
29
|
+
// app.module.ts.
|
|
30
|
+
await moduleGenerator(tree, {
|
|
31
|
+
path: stem,
|
|
32
|
+
skipImport: true,
|
|
33
|
+
skipFormat: true,
|
|
34
|
+
});
|
|
35
|
+
await controllerGenerator(tree, {
|
|
36
|
+
path: stem,
|
|
37
|
+
skipImport: true,
|
|
38
|
+
unitTestRunner: 'none',
|
|
39
|
+
skipFormat: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// @nx/nest:controller emits @Controller('<name>') — a base path derived
|
|
43
|
+
// from the file name. The ts-rest contract already encodes every route's
|
|
44
|
+
// full path, so leaving it would prefix each route twice.
|
|
45
|
+
stripControllerBasePath(tree, `${stem}.controller.ts`);
|
|
46
|
+
|
|
47
|
+
tree.write(
|
|
48
|
+
`${stem}.controller.ts`,
|
|
49
|
+
controllerFile(names, fields, options.toggleField),
|
|
50
|
+
);
|
|
51
|
+
tree.write(`${stem}.module.ts`, moduleFile(names));
|
|
52
|
+
tree.write(
|
|
53
|
+
`${stem}.controller.spec.ts`,
|
|
54
|
+
controllerSpecFile(names, options.toggleField),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
addApiDependencies(tree, names);
|
|
58
|
+
|
|
59
|
+
await formatFiles(tree);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Rewrites the generator's own decorator in place, so the strip is proved
|
|
64
|
+
* against what it actually emitted rather than assumed from its templates.
|
|
65
|
+
*/
|
|
66
|
+
function stripControllerBasePath(tree: Tree, path: string) {
|
|
67
|
+
const source = tree.read(path, 'utf-8');
|
|
68
|
+
if (!source) return;
|
|
69
|
+
tree.write(path, source.replace(/@Controller\((['"]).*?\1\)/, '@Controller()'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function controllerFile(
|
|
73
|
+
names: ModuleNames,
|
|
74
|
+
fields: Field[],
|
|
75
|
+
toggleField?: string,
|
|
76
|
+
): string {
|
|
77
|
+
const { entityPascal, entityCamel, camel, module } = names;
|
|
78
|
+
const dateFields = fields.filter(isDateField);
|
|
79
|
+
const wireShape = dateFields.length
|
|
80
|
+
? `{ ${dateFields
|
|
81
|
+
.map(
|
|
82
|
+
(field) =>
|
|
83
|
+
`${field.name}${field.nullable ? '?' : ''}: Date | string${field.nullable ? ' | null' : ''}`,
|
|
84
|
+
)
|
|
85
|
+
.join('; ')} }`
|
|
86
|
+
: 'object';
|
|
87
|
+
|
|
88
|
+
return `import { Controller, Inject } from '@nestjs/common';
|
|
89
|
+
import { TsRestHandler, tsRestHandler } from '@ts-rest/nest';
|
|
90
|
+
import { ${camel}Contract } from 'contracts';
|
|
91
|
+
import { ${entityPascal}Service } from '${module}-service';
|
|
92
|
+
|
|
93
|
+
// No base path: the ts-rest contract carries each route's full path, and
|
|
94
|
+
// apps/api/src/main.ts adds the one /api prefix the whole app shares.
|
|
95
|
+
@Controller()
|
|
96
|
+
export class ${names.pascal}Controller {
|
|
97
|
+
constructor(
|
|
98
|
+
@Inject(${entityPascal}Service) private readonly ${entityCamel}s: ${entityPascal}Service,
|
|
99
|
+
) {}
|
|
100
|
+
|
|
101
|
+
@TsRestHandler(${camel}Contract)
|
|
102
|
+
async handler() {
|
|
103
|
+
return tsRestHandler(${camel}Contract, {
|
|
104
|
+
list: async () => ({
|
|
105
|
+
status: 200 as const,
|
|
106
|
+
body: await this.${entityCamel}s.list(),
|
|
107
|
+
}),
|
|
108
|
+
|
|
109
|
+
get: async ({ params }) => {
|
|
110
|
+
try {
|
|
111
|
+
return {
|
|
112
|
+
status: 200 as const,
|
|
113
|
+
body: await this.${entityCamel}s.getById(params.id),
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return notFoundOr(error, params.id);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
create: async ({ body }) => ({
|
|
121
|
+
status: 201 as const,
|
|
122
|
+
body: await this.${entityCamel}s.create(fromWire(body)),
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
update: async ({ params, body }) => {
|
|
126
|
+
try {
|
|
127
|
+
return {
|
|
128
|
+
status: 200 as const,
|
|
129
|
+
body: await this.${entityCamel}s.update(params.id, fromWire(body)),
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return notFoundOr(error, params.id);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
remove: async ({ params }) => {
|
|
137
|
+
try {
|
|
138
|
+
await this.${entityCamel}s.remove(params.id);
|
|
139
|
+
return { status: 204 as const, body: undefined };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return notFoundOr(error, params.id);
|
|
142
|
+
}
|
|
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
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The one place a domain error becomes a status code — services throw
|
|
168
|
+
* ${entityPascal}NotFoundError knowing nothing about HTTP. Matching on
|
|
169
|
+
* \`name\` rather than \`instanceof\` survives the error class arriving
|
|
170
|
+
* through a separately bundled copy of ${module}-service.
|
|
171
|
+
*/
|
|
172
|
+
function notFoundOr(error: unknown, id: string) {
|
|
173
|
+
if (error instanceof Error && error.name === '${entityPascal}NotFoundError') {
|
|
174
|
+
return {
|
|
175
|
+
status: 404 as const,
|
|
176
|
+
body: {
|
|
177
|
+
error: '${entityPascal}NotFound' as const,
|
|
178
|
+
message: \`No ${entityCamel} exists with id \${id}.\`,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The seam the contract's date-mode timestamp union creates: the same
|
|
187
|
+
* field is a real Date server-side and an ISO string over JSON, so the
|
|
188
|
+
* contract accepts both and this boundary picks one — the Date the
|
|
189
|
+
* repository's column type actually stores. Past here nothing re-parses.
|
|
190
|
+
*/
|
|
191
|
+
function fromWire<T extends ${wireShape}>(body: T) {
|
|
192
|
+
return {
|
|
193
|
+
...body,${dateFields
|
|
194
|
+
.map(
|
|
195
|
+
(field) =>
|
|
196
|
+
`\n ${field.name}: toDate(body.${field.name}),`,
|
|
197
|
+
)
|
|
198
|
+
.join('')}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
${
|
|
203
|
+
dateFields.length
|
|
204
|
+
? `function toDate(value: Date | string | null | undefined) {
|
|
205
|
+
return typeof value === 'string' ? new Date(value) : value;
|
|
206
|
+
}
|
|
207
|
+
`
|
|
208
|
+
: ''
|
|
209
|
+
}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function moduleFile(names: ModuleNames): string {
|
|
213
|
+
const { entityPascal, entityConstant, pascal, module } = names;
|
|
214
|
+
|
|
215
|
+
return `import { Module } from '@nestjs/common';
|
|
216
|
+
import { getDb } from 'db';
|
|
217
|
+
import {
|
|
218
|
+
${entityConstant}_REPOSITORY,
|
|
219
|
+
Drizzle${entityPascal}Repository,
|
|
220
|
+
} from '${module}-repository';
|
|
221
|
+
import { ${entityPascal}Service } from '${module}-service';
|
|
222
|
+
import { ${pascal}Controller } from './${names.module}.controller';
|
|
223
|
+
|
|
224
|
+
// The composition root for this module, and the only file in apps/api
|
|
225
|
+
// allowed to name a *.adapter (eslint-base.js exempts *.module.ts from the
|
|
226
|
+
// port-discipline rule precisely for this binding).
|
|
227
|
+
//
|
|
228
|
+
// Registration is automatic: tools/generate-feature-modules.cjs globs
|
|
229
|
+
// every module directory's own *.module.ts into feature-modules.ts, which
|
|
230
|
+
// app.module.ts spreads — so this file is never referenced by hand.
|
|
231
|
+
@Module({
|
|
232
|
+
controllers: [${pascal}Controller],
|
|
233
|
+
providers: [
|
|
234
|
+
{
|
|
235
|
+
provide: ${entityConstant}_REPOSITORY,
|
|
236
|
+
// getDb is passed unevaluated: the DI container builds every provider
|
|
237
|
+
// eagerly, and calling it here would open a database connection just
|
|
238
|
+
// to compile the module — including inside a test.
|
|
239
|
+
useFactory: () => new Drizzle${entityPascal}Repository(getDb),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
provide: ${entityPascal}Service,
|
|
243
|
+
inject: [${entityConstant}_REPOSITORY],
|
|
244
|
+
useFactory: (repository: Drizzle${entityPascal}Repository) =>
|
|
245
|
+
new ${entityPascal}Service(repository),
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
})
|
|
249
|
+
export class ${pascal}Module {}
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function controllerSpecFile(names: ModuleNames, toggleField?: string): string {
|
|
254
|
+
const { entityPascal, pascal, module } = names;
|
|
255
|
+
|
|
256
|
+
return `import { Test } from '@nestjs/testing';
|
|
257
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
258
|
+
import { ${entityPascal}Service } from '${module}-service';
|
|
259
|
+
import { ${pascal}Controller } from './${names.module}.controller';
|
|
260
|
+
|
|
261
|
+
async function controllerWith(service: Partial<${entityPascal}Service>) {
|
|
262
|
+
const moduleRef = await Test.createTestingModule({
|
|
263
|
+
controllers: [${pascal}Controller],
|
|
264
|
+
providers: [{ provide: ${entityPascal}Service, useValue: service }],
|
|
265
|
+
}).compile();
|
|
266
|
+
|
|
267
|
+
return moduleRef.get(${pascal}Controller);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
describe('${pascal}Controller', () => {
|
|
271
|
+
it('serves the contract without a base path of its own', () => {
|
|
272
|
+
const path = Reflect.getMetadata('path', ${pascal}Controller);
|
|
273
|
+
|
|
274
|
+
expect(path).toBe('/');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('answers list with 200 and the rows the service returned', async () => {
|
|
278
|
+
const rows = [{ id: 'one' }];
|
|
279
|
+
const controller = await controllerWith({
|
|
280
|
+
list: vi.fn(async () => rows as never),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const handler = await controller.handler();
|
|
284
|
+
const response = await handler.list({} as never);
|
|
285
|
+
|
|
286
|
+
expect(response).toMatchObject({ status: 200, body: rows });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('maps a not-found domain error to 404 rather than letting it escape', async () => {
|
|
290
|
+
const notFound = Object.assign(new Error('absent'), {
|
|
291
|
+
name: '${entityPascal}NotFoundError',
|
|
292
|
+
});
|
|
293
|
+
const controller = await controllerWith({
|
|
294
|
+
getById: vi.fn(async () => {
|
|
295
|
+
throw notFound;
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const handler = await controller.handler();
|
|
300
|
+
const response = await handler.get({
|
|
301
|
+
params: { id: 'missing' },
|
|
302
|
+
} as never);
|
|
303
|
+
|
|
304
|
+
expect(response).toMatchObject({ status: 404 });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('lets an error it has no mapping for propagate', async () => {
|
|
308
|
+
const boom = new Error('database is on fire');
|
|
309
|
+
const controller = await controllerWith({
|
|
310
|
+
getById: vi.fn(async () => {
|
|
311
|
+
throw boom;
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const handler = await controller.handler();
|
|
316
|
+
|
|
317
|
+
await expect(
|
|
318
|
+
handler.get({ params: { id: 'any' } } as never),
|
|
319
|
+
).rejects.toBe(boom);
|
|
320
|
+
});
|
|
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
|
+
}});
|
|
356
|
+
`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* apps/api reaches the module's service and repository libs, plus the
|
|
361
|
+
* contract package, by construction — declared here so the first module
|
|
362
|
+
* through this layer does not leave them unlinked for a later layer to
|
|
363
|
+
* trip over.
|
|
364
|
+
*/
|
|
365
|
+
function addApiDependencies(tree: Tree, names: ModuleNames) {
|
|
366
|
+
updateJson(tree, `${API_ROOT}/package.json`, (json) => {
|
|
367
|
+
json.dependencies = {
|
|
368
|
+
...json.dependencies,
|
|
369
|
+
contracts: 'workspace:*',
|
|
370
|
+
[`${names.module}-repository`]: 'workspace:*',
|
|
371
|
+
[`${names.module}-service`]: 'workspace:*',
|
|
372
|
+
};
|
|
373
|
+
return json;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
updateJson(tree, `${API_ROOT}/tsconfig.app.json`, (json) => {
|
|
377
|
+
const references: { path: string }[] = json.references ?? [];
|
|
378
|
+
for (const path of [
|
|
379
|
+
'../../packages/contracts/tsconfig.lib.json',
|
|
380
|
+
`../../libs/${names.module}/repository/tsconfig.lib.json`,
|
|
381
|
+
`../../libs/${names.module}/service/tsconfig.lib.json`,
|
|
382
|
+
]) {
|
|
383
|
+
if (!references.some((entry) => entry.path === path)) {
|
|
384
|
+
references.push({ path });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
json.references = references;
|
|
388
|
+
return json;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "HedgehogControllerLayer",
|
|
4
|
+
"title": "NestJS module and ts-rest controller for one domain module",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"module": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "Domain module name, plural kebab-case (e.g. tasks, order-items).",
|
|
10
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
11
|
+
"x-prompt": "Domain module name (plural kebab-case)?"
|
|
12
|
+
},
|
|
13
|
+
"fields": {
|
|
14
|
+
"type": "string",
|
|
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
|
+
"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."
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"required": ["module", "fields"]
|
|
24
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The field DSL every layer generator shares, plus the one mapping from a
|
|
3
|
+
* field type to its Drizzle column, its TypeScript type, and its Zod
|
|
4
|
+
* schema. Owned here so the seven generators cannot drift from each other
|
|
5
|
+
* on what `dueDate:timestamp?` means.
|
|
6
|
+
*
|
|
7
|
+
* Wire format (the `--fields` option): a comma-separated list of
|
|
8
|
+
* `name:type` pairs, with a trailing `?` on the type marking the column
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type FieldType =
|
|
20
|
+
| 'string'
|
|
21
|
+
| 'text'
|
|
22
|
+
| 'boolean'
|
|
23
|
+
| 'integer'
|
|
24
|
+
| 'timestamp';
|
|
25
|
+
|
|
26
|
+
export interface Field {
|
|
27
|
+
name: string;
|
|
28
|
+
type: FieldType;
|
|
29
|
+
nullable: boolean;
|
|
30
|
+
/** Max characters for a `string`; undefined on every other type. */
|
|
31
|
+
length?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `varchar` bound a `string` gets when the field list names none. */
|
|
35
|
+
export const DEFAULT_STRING_LENGTH = 255;
|
|
36
|
+
|
|
37
|
+
const FIELD_TYPES: readonly FieldType[] = [
|
|
38
|
+
'string',
|
|
39
|
+
'text',
|
|
40
|
+
'boolean',
|
|
41
|
+
'integer',
|
|
42
|
+
'timestamp',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function parseFields(raw: string): Field[] {
|
|
46
|
+
const fields = raw
|
|
47
|
+
.split(',')
|
|
48
|
+
.map((entry) => entry.trim())
|
|
49
|
+
.filter((entry) => entry.length > 0)
|
|
50
|
+
.map((entry) => {
|
|
51
|
+
const [name, rawType] = entry.split(':').map((part) => part.trim());
|
|
52
|
+
if (!name || !rawType) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Field "${entry}" is not in name:type form (e.g. title:string, dueDate:timestamp?).`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const nullable = rawType.endsWith('?');
|
|
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
|
+
|
|
64
|
+
if (!FIELD_TYPES.includes(type)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Field "${entry}" has unknown type "${type}". Known types: ${FIELD_TYPES.join(', ')}.`,
|
|
67
|
+
);
|
|
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
|
+
}
|
|
81
|
+
if (RESERVED_FIELD_NAMES.includes(name)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Field "${name}" collides with a column every table already carries (${RESERVED_FIELD_NAMES.join(', ')}).`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { name, type, nullable, length };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (fields.length === 0) {
|
|
90
|
+
throw new Error('At least one field is required.');
|
|
91
|
+
}
|
|
92
|
+
return fields;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Columns the schema generator writes on every table — a field list may
|
|
96
|
+
// not redeclare them.
|
|
97
|
+
const RESERVED_FIELD_NAMES = ['id', 'createdAt', 'updatedAt'];
|
|
98
|
+
|
|
99
|
+
/** snake_case, the database column name for a camelCase field. */
|
|
100
|
+
export function columnName(name: string): string {
|
|
101
|
+
return name.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function drizzleImports(fields: Field[]): string[] {
|
|
105
|
+
const imports = new Set<string>(['pgTable', 'uuid', 'timestamp']);
|
|
106
|
+
for (const field of fields) {
|
|
107
|
+
imports.add(DRIZZLE_IMPORT[field.type]);
|
|
108
|
+
}
|
|
109
|
+
return [...imports].sort();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DRIZZLE_IMPORT: Record<FieldType, string> = {
|
|
113
|
+
string: 'varchar',
|
|
114
|
+
text: 'text',
|
|
115
|
+
boolean: 'boolean',
|
|
116
|
+
integer: 'integer',
|
|
117
|
+
timestamp: 'timestamp',
|
|
118
|
+
};
|
|
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
|
+
|
|
125
|
+
export function drizzleColumn(field: Field): string {
|
|
126
|
+
const column = `'${columnName(field.name)}'`;
|
|
127
|
+
const base: Record<FieldType, string> = {
|
|
128
|
+
string: `varchar(${column}, { length: ${stringLength(field)} })`,
|
|
129
|
+
text: `text(${column})`,
|
|
130
|
+
boolean: `boolean(${column})`,
|
|
131
|
+
integer: `integer(${column})`,
|
|
132
|
+
// `mode: 'date'` keeps the column a real `Date` on the server; the
|
|
133
|
+
// contract layer's timestamp union is what reconciles that with the
|
|
134
|
+
// ISO string the same field arrives as over JSON.
|
|
135
|
+
timestamp: `timestamp(${column}, { mode: 'date', withTimezone: true })`,
|
|
136
|
+
};
|
|
137
|
+
const modifiers = field.nullable
|
|
138
|
+
? ''
|
|
139
|
+
: field.type === 'boolean'
|
|
140
|
+
? '.notNull().default(false)'
|
|
141
|
+
: '.notNull()';
|
|
142
|
+
return `${field.name}: ${base[field.type]}${modifiers},`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when a field reflects through drizzle-zod as a `z.date()`. */
|
|
146
|
+
export function isDateField(field: Field): boolean {
|
|
147
|
+
return field.type === 'timestamp';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The Zod schema for a field as it appears in a hand-written override. */
|
|
151
|
+
export function zodSchema(field: Field): string {
|
|
152
|
+
const base: Record<FieldType, string> = {
|
|
153
|
+
string: `z.string().min(1).max(${stringLength(field)})`,
|
|
154
|
+
text: 'z.string()',
|
|
155
|
+
boolean: 'z.boolean()',
|
|
156
|
+
integer: 'z.number().int()',
|
|
157
|
+
timestamp: 'timestampSchema',
|
|
158
|
+
};
|
|
159
|
+
return field.nullable ? `${base[field.type]}.nullable()` : base[field.type];
|
|
160
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"name": "hedgehog",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"generators": {
|
|
6
|
+
"schema": {
|
|
7
|
+
"factory": "./schema/generator",
|
|
8
|
+
"schema": "./schema/schema.json",
|
|
9
|
+
"description": "Drizzle table + drizzle-zod pair for one domain module (core.yaml's schema layer)."
|
|
10
|
+
},
|
|
11
|
+
"contract": {
|
|
12
|
+
"factory": "./contract/generator",
|
|
13
|
+
"schema": "./contract/schema.json",
|
|
14
|
+
"description": "ts-rest router + error schemas for one domain module (core.yaml's contract layer)."
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"factory": "./repository/generator",
|
|
18
|
+
"schema": "./repository/schema.json",
|
|
19
|
+
"description": "Port interface, DI token, and Drizzle adapter lib for one domain module (core.yaml's repository layer)."
|
|
20
|
+
},
|
|
21
|
+
"service": {
|
|
22
|
+
"factory": "./service/generator",
|
|
23
|
+
"schema": "./service/schema.json",
|
|
24
|
+
"description": "Domain service lib with typed domain errors for one domain module (core.yaml's service layer)."
|
|
25
|
+
},
|
|
26
|
+
"controller": {
|
|
27
|
+
"factory": "./controller/generator",
|
|
28
|
+
"schema": "./controller/schema.json",
|
|
29
|
+
"description": "NestJS module + ts-rest controller for one domain module (core.yaml's controller layer)."
|
|
30
|
+
},
|
|
31
|
+
"hook": {
|
|
32
|
+
"factory": "./hook/generator",
|
|
33
|
+
"schema": "./hook/schema.json",
|
|
34
|
+
"description": "TanStack Query hook set for one domain module (core.yaml's hook layer)."
|
|
35
|
+
},
|
|
36
|
+
"screen": {
|
|
37
|
+
"factory": "./screen/generator",
|
|
38
|
+
"schema": "./screen/schema.json",
|
|
39
|
+
"description": "Screen skeleton wired to the hook layer for one domain module (core.yaml's screen layer)."
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|