@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,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Regenerates apps/api/src/app/feature-modules.ts — the barrel that lists
|
|
4
|
+
* every domain module's NestJS `@Module` class for AppModule to import.
|
|
5
|
+
*
|
|
6
|
+
* Written as CommonJS (`.cjs`) deliberately: root `package.json` sets
|
|
7
|
+
* `"type": "module"`, so a plain `.js` file here would be parsed as ESM
|
|
8
|
+
* and `require`/`module.exports` below would fail with "require is not
|
|
9
|
+
* defined".
|
|
10
|
+
*
|
|
11
|
+
* Why generated rather than hand-edited: the controller layer's scope is
|
|
12
|
+
* `apps/api/src/app/{module}/**` (core.yaml) — module-disjoint by
|
|
13
|
+
* construction, so two modules' controller tasks never touch the same
|
|
14
|
+
* file. AppModule itself is exclusive: true / once (see core.yaml), so it
|
|
15
|
+
* can't carry a per-module edit either. A file that every module's
|
|
16
|
+
* controller task hand-edited (even one line each) would sit outside
|
|
17
|
+
* every task's declared scope, invisible to the scheduler's conflict
|
|
18
|
+
* check (src/db/conflict.mjs only compares declared scope globs) — two
|
|
19
|
+
* concurrent module builds editing it would look conflict-free to the
|
|
20
|
+
* scheduler while actually racing on one file. Generating it instead
|
|
21
|
+
* removes the shared edit entirely: every module writes only inside its
|
|
22
|
+
* own `apps/api/src/app/{module}/` directory (a `{module}.module.ts`
|
|
23
|
+
* file, discovered here by naming convention), and this script is the
|
|
24
|
+
* only thing that ever writes feature-modules.ts.
|
|
25
|
+
*
|
|
26
|
+
* Convention: any file matching apps/api/src/app/*\/*.module.ts (module
|
|
27
|
+
* directories only, one level deep — not app.module.ts itself) is treated
|
|
28
|
+
* as a domain module and must have a named export ending in "Module".
|
|
29
|
+
*
|
|
30
|
+
* Run via the `generate-feature-modules` Nx target, which `test` and
|
|
31
|
+
* `build` depend on (nx.json targetDefaults) — never run by hand as part
|
|
32
|
+
* of normal development.
|
|
33
|
+
*/
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
|
|
37
|
+
const APP_DIR = path.join(__dirname, '..', 'apps', 'api', 'src', 'app');
|
|
38
|
+
const OUTPUT_FILE = path.join(APP_DIR, 'feature-modules.ts');
|
|
39
|
+
const HEADER =
|
|
40
|
+
'// GENERATED FILE — do not hand-edit. Regenerated by\n' +
|
|
41
|
+
'// tools/generate-feature-modules.cjs (the generate-feature-modules Nx\n' +
|
|
42
|
+
'// target, which test and build depend on) from every\n' +
|
|
43
|
+
"// apps/api/src/app/*/*.module.ts file on disk. A module directory's\n" +
|
|
44
|
+
'// controller layer only ever creates its own *.module.ts inside its own\n' +
|
|
45
|
+
'// directory — never edits this file.\n';
|
|
46
|
+
|
|
47
|
+
function findModuleFiles() {
|
|
48
|
+
if (!fs.existsSync(APP_DIR)) return [];
|
|
49
|
+
const entries = fs.readdirSync(APP_DIR, { withFileTypes: true });
|
|
50
|
+
const files = [];
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!entry.isDirectory()) continue;
|
|
53
|
+
const moduleDir = path.join(APP_DIR, entry.name);
|
|
54
|
+
for (const file of fs.readdirSync(moduleDir)) {
|
|
55
|
+
if (file.endsWith('.module.ts')) {
|
|
56
|
+
files.push(path.join(entry.name, file.replace(/\.ts$/, '')));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return files.sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function exportNameFor(relativePath) {
|
|
64
|
+
// "tasks/tasks.module" -> "TasksModule". Every domain module generator
|
|
65
|
+
// (Nx's @nx/nest:module or hand-authored) names its class
|
|
66
|
+
// `<PascalCase>Module` to match the file's own `<kebab>.module.ts` name
|
|
67
|
+
// — the same convention @nx/nest itself uses.
|
|
68
|
+
const base = path.basename(relativePath).replace(/\.module$/, '');
|
|
69
|
+
const pascal = base
|
|
70
|
+
.split('-')
|
|
71
|
+
.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1))
|
|
72
|
+
.join('');
|
|
73
|
+
return `${pascal}Module`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function main() {
|
|
77
|
+
const moduleFiles = findModuleFiles();
|
|
78
|
+
const imports = [];
|
|
79
|
+
const names = [];
|
|
80
|
+
|
|
81
|
+
for (const relativePath of moduleFiles) {
|
|
82
|
+
const importName = exportNameFor(relativePath);
|
|
83
|
+
imports.push(`import { ${importName} } from './${relativePath}';`);
|
|
84
|
+
names.push(importName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const body =
|
|
88
|
+
names.length > 0
|
|
89
|
+
? `export const featureModules = [${names.join(', ')}];\n`
|
|
90
|
+
: 'export const featureModules = [];\n';
|
|
91
|
+
|
|
92
|
+
const content =
|
|
93
|
+
HEADER +
|
|
94
|
+
(imports.length > 0 ? '\n' + imports.join('\n') + '\n' : '') +
|
|
95
|
+
'\n' +
|
|
96
|
+
body;
|
|
97
|
+
|
|
98
|
+
fs.writeFileSync(OUTPUT_FILE, content);
|
|
99
|
+
console.log(
|
|
100
|
+
`Generated ${path.relative(process.cwd(), OUTPUT_FILE)} with ${names.length} feature module(s).`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main();
|
|
@@ -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();
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { formatFiles, Tree } from '@nx/devkit';
|
|
2
|
+
import { Field, isDateField, parseFields, zodSchema } from '../fields';
|
|
3
|
+
import { appendBarrelExport, generateLibShell } from '../lib-shell';
|
|
4
|
+
import { moduleNames, ModuleNames } from '../naming';
|
|
5
|
+
|
|
6
|
+
interface ContractGeneratorOptions {
|
|
7
|
+
module: string;
|
|
8
|
+
fields: string;
|
|
9
|
+
toggleField?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PACKAGE_ROOT = 'packages/contracts';
|
|
13
|
+
|
|
14
|
+
export default async function contractGenerator(
|
|
15
|
+
tree: Tree,
|
|
16
|
+
options: ContractGeneratorOptions,
|
|
17
|
+
) {
|
|
18
|
+
const names = moduleNames(options.module);
|
|
19
|
+
const fields = parseFields(options.fields);
|
|
20
|
+
|
|
21
|
+
await generateLibShell(tree, {
|
|
22
|
+
directory: PACKAGE_ROOT,
|
|
23
|
+
importName: 'contracts',
|
|
24
|
+
tags: ['scope:contracts', 'type:contract'],
|
|
25
|
+
dependencies: {
|
|
26
|
+
'@ts-rest/core': '3.53.0-rc.1',
|
|
27
|
+
zod: '^4.4.3',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
writeTimestampSchema(tree);
|
|
32
|
+
|
|
33
|
+
const dir = `${PACKAGE_ROOT}/src/${names.module}`;
|
|
34
|
+
tree.write(`${dir}/${names.module}.schema.ts`, entitySchemaFile(names, fields));
|
|
35
|
+
tree.write(`${dir}/${names.module}.errors.ts`, errorSchemaFile(names));
|
|
36
|
+
tree.write(
|
|
37
|
+
`${dir}/${names.module}.contract.ts`,
|
|
38
|
+
contractFile(names, options.toggleField),
|
|
39
|
+
);
|
|
40
|
+
tree.write(`${dir}/index.ts`, barrelFile(names));
|
|
41
|
+
tree.write(
|
|
42
|
+
`${dir}/${names.module}.spec.ts`,
|
|
43
|
+
specFile(names, fields, options.toggleField),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, `./${names.module}/index`);
|
|
47
|
+
|
|
48
|
+
await formatFiles(tree);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The shared half of the date-mode rule: a `timestamp` column declared
|
|
53
|
+
* `mode: 'date'` reflects through `createSelectSchema` as `z.date()`, which
|
|
54
|
+
* the server satisfies with a real `Date` and the browser never can — JSON
|
|
55
|
+
* carries it as an ISO string. Neither side alone is right, and no
|
|
56
|
+
* `.transform()` satisfies both, so the contract accepts the union.
|
|
57
|
+
*/
|
|
58
|
+
function writeTimestampSchema(tree: Tree) {
|
|
59
|
+
const path = `${PACKAGE_ROOT}/src/timestamp.ts`;
|
|
60
|
+
if (tree.exists(path)) return;
|
|
61
|
+
|
|
62
|
+
tree.write(
|
|
63
|
+
path,
|
|
64
|
+
`import { z } from 'zod';
|
|
65
|
+
|
|
66
|
+
export const timestampSchema = z.union([z.date(), z.iso.datetime()]);
|
|
67
|
+
|
|
68
|
+
export type Timestamp = z.infer<typeof timestampSchema>;
|
|
69
|
+
`,
|
|
70
|
+
);
|
|
71
|
+
appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, './timestamp');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function entitySchemaFile(names: ModuleNames, fields: Field[]): string {
|
|
75
|
+
const bodyFields = fields
|
|
76
|
+
.map((field) => ` ${field.name}: ${zodSchema(field)},`)
|
|
77
|
+
.join('\n');
|
|
78
|
+
const createFields = fields
|
|
79
|
+
.map(
|
|
80
|
+
(field) =>
|
|
81
|
+
` ${field.name}: ${zodSchema(field)}${field.nullable ? '.optional()' : ''},`,
|
|
82
|
+
)
|
|
83
|
+
.join('\n');
|
|
84
|
+
|
|
85
|
+
return `import { z } from 'zod';
|
|
86
|
+
import { timestampSchema } from '../timestamp';
|
|
87
|
+
|
|
88
|
+
export const ${names.entityCamel}Schema = z.object({
|
|
89
|
+
id: z.uuid(),
|
|
90
|
+
${bodyFields}
|
|
91
|
+
createdAt: timestampSchema,
|
|
92
|
+
updatedAt: timestampSchema,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const create${names.entityPascal}Schema = z.object({
|
|
96
|
+
${createFields}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const update${names.entityPascal}Schema = create${names.entityPascal}Schema.partial();
|
|
100
|
+
|
|
101
|
+
export type ${names.entityPascal} = z.infer<typeof ${names.entityCamel}Schema>;
|
|
102
|
+
export type Create${names.entityPascal} = z.infer<typeof create${names.entityPascal}Schema>;
|
|
103
|
+
export type Update${names.entityPascal} = z.infer<typeof update${names.entityPascal}Schema>;
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function errorSchemaFile(names: ModuleNames): string {
|
|
108
|
+
return `import { z } from 'zod';
|
|
109
|
+
|
|
110
|
+
export const ${names.entityCamel}NotFoundSchema = z.object({
|
|
111
|
+
error: z.literal('${names.entityPascal}NotFound'),
|
|
112
|
+
message: z.string(),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const ${names.entityCamel}BadRequestSchema = z.object({
|
|
116
|
+
error: z.literal('${names.entityPascal}BadRequest'),
|
|
117
|
+
message: z.string(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export type ${names.entityPascal}NotFound = z.infer<typeof ${names.entityCamel}NotFoundSchema>;
|
|
121
|
+
export type ${names.entityPascal}BadRequest = z.infer<typeof ${names.entityCamel}BadRequestSchema>;
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
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 {
|
|
153
|
+
return `import { initContract } from '@ts-rest/core';
|
|
154
|
+
import { z } from 'zod';
|
|
155
|
+
import {
|
|
156
|
+
${names.entityCamel}Schema,
|
|
157
|
+
create${names.entityPascal}Schema,
|
|
158
|
+
update${names.entityPascal}Schema,
|
|
159
|
+
} from './${names.module}.schema';
|
|
160
|
+
import {
|
|
161
|
+
${names.entityCamel}BadRequestSchema,
|
|
162
|
+
${names.entityCamel}NotFoundSchema,
|
|
163
|
+
} from './${names.module}.errors';
|
|
164
|
+
|
|
165
|
+
const c = initContract();
|
|
166
|
+
|
|
167
|
+
export const ${names.camel}Contract = c.router(
|
|
168
|
+
{
|
|
169
|
+
list: {
|
|
170
|
+
method: 'GET',
|
|
171
|
+
path: '/${names.module}',
|
|
172
|
+
responses: {
|
|
173
|
+
200: z.array(${names.entityCamel}Schema),
|
|
174
|
+
},
|
|
175
|
+
summary: 'List every ${names.entityCamel}',
|
|
176
|
+
},
|
|
177
|
+
get: {
|
|
178
|
+
method: 'GET',
|
|
179
|
+
path: '/${names.module}/:id',
|
|
180
|
+
pathParams: z.object({ id: z.uuid() }),
|
|
181
|
+
responses: {
|
|
182
|
+
200: ${names.entityCamel}Schema,
|
|
183
|
+
404: ${names.entityCamel}NotFoundSchema,
|
|
184
|
+
},
|
|
185
|
+
summary: 'Fetch one ${names.entityCamel} by id',
|
|
186
|
+
},
|
|
187
|
+
create: {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
path: '/${names.module}',
|
|
190
|
+
body: create${names.entityPascal}Schema,
|
|
191
|
+
responses: {
|
|
192
|
+
201: ${names.entityCamel}Schema,
|
|
193
|
+
400: ${names.entityCamel}BadRequestSchema,
|
|
194
|
+
},
|
|
195
|
+
summary: 'Create a ${names.entityCamel}',
|
|
196
|
+
},
|
|
197
|
+
update: {
|
|
198
|
+
method: 'PATCH',
|
|
199
|
+
path: '/${names.module}/:id',
|
|
200
|
+
pathParams: z.object({ id: z.uuid() }),
|
|
201
|
+
body: update${names.entityPascal}Schema,
|
|
202
|
+
responses: {
|
|
203
|
+
200: ${names.entityCamel}Schema,
|
|
204
|
+
400: ${names.entityCamel}BadRequestSchema,
|
|
205
|
+
404: ${names.entityCamel}NotFoundSchema,
|
|
206
|
+
},
|
|
207
|
+
summary: 'Update a ${names.entityCamel}',
|
|
208
|
+
},
|
|
209
|
+
remove: {
|
|
210
|
+
method: 'DELETE',
|
|
211
|
+
path: '/${names.module}/:id',
|
|
212
|
+
pathParams: z.object({ id: z.uuid() }),
|
|
213
|
+
responses: {
|
|
214
|
+
204: c.noBody(),
|
|
215
|
+
404: ${names.entityCamel}NotFoundSchema,
|
|
216
|
+
},
|
|
217
|
+
summary: 'Delete a ${names.entityCamel}',
|
|
218
|
+
},
|
|
219
|
+
${toggleField ? toggleRoute(names) : ''} },
|
|
220
|
+
{
|
|
221
|
+
// apps/api sets /api as a global prefix at runtime (apps/api/src/main.ts),
|
|
222
|
+
// so the paths above stay prefix-free and the client adds the base URL.
|
|
223
|
+
strictStatusCodes: true,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function barrelFile(names: ModuleNames): string {
|
|
230
|
+
return `export * from './${names.module}.contract';
|
|
231
|
+
export * from './${names.module}.errors';
|
|
232
|
+
export * from './${names.module}.schema';
|
|
233
|
+
`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function specFile(
|
|
237
|
+
names: ModuleNames,
|
|
238
|
+
fields: Field[],
|
|
239
|
+
toggleField?: string,
|
|
240
|
+
): string {
|
|
241
|
+
const dateField = fields.find(isDateField);
|
|
242
|
+
const requiredField = fields.find((field) => !field.nullable);
|
|
243
|
+
|
|
244
|
+
return `import { describe, expect, it } from 'vitest';
|
|
245
|
+
import {
|
|
246
|
+
${names.camel}Contract,
|
|
247
|
+
${names.entityCamel}Schema,
|
|
248
|
+
create${names.entityPascal}Schema,
|
|
249
|
+
} from './index';
|
|
250
|
+
|
|
251
|
+
const sample = ${sampleLiteral(names, fields)};
|
|
252
|
+
|
|
253
|
+
describe('${names.camel} contract', () => {
|
|
254
|
+
it('exposes the full CRUD route set under /${names.module}', () => {
|
|
255
|
+
expect(${names.camel}Contract.list.method).toBe('GET');
|
|
256
|
+
expect(${names.camel}Contract.list.path).toBe('/${names.module}');
|
|
257
|
+
expect(${names.camel}Contract.get.path).toBe('/${names.module}/:id');
|
|
258
|
+
expect(${names.camel}Contract.create.method).toBe('POST');
|
|
259
|
+
expect(${names.camel}Contract.update.method).toBe('PATCH');
|
|
260
|
+
expect(${names.camel}Contract.remove.method).toBe('DELETE');
|
|
261
|
+
});
|
|
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
|
+
}
|
|
278
|
+
it('accepts a server-side row, where every timestamp is a Date', () => {
|
|
279
|
+
expect(${names.entityCamel}Schema.parse(sample)).toBeDefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('accepts the same row as JSON, where every timestamp is an ISO string', () => {
|
|
283
|
+
const overTheWire = {
|
|
284
|
+
...sample,${dateField ? `\n ${dateField.name}: sample.${dateField.name}.toISOString(),` : ''}
|
|
285
|
+
createdAt: sample.createdAt.toISOString(),
|
|
286
|
+
updatedAt: sample.updatedAt.toISOString(),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
expect(${names.entityCamel}Schema.parse(overTheWire)).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
${
|
|
292
|
+
requiredField
|
|
293
|
+
? `
|
|
294
|
+
it('rejects a create body missing ${requiredField.name}', () => {
|
|
295
|
+
const rest = { ...createInput } as Partial<typeof createInput>;
|
|
296
|
+
delete rest.${requiredField.name};
|
|
297
|
+
|
|
298
|
+
expect(create${names.entityPascal}Schema.safeParse(rest).success).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
`
|
|
301
|
+
: ''
|
|
302
|
+
}});
|
|
303
|
+
|
|
304
|
+
const createInput = ${createLiteral(fields)};
|
|
305
|
+
`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const SAMPLE_UUID = '00000000-0000-4000-8000-000000000000';
|
|
309
|
+
|
|
310
|
+
function sampleLiteral(names: ModuleNames, fields: Field[]): string {
|
|
311
|
+
const entries = [
|
|
312
|
+
`id: '${SAMPLE_UUID}'`,
|
|
313
|
+
...fields.map((field) => `${field.name}: ${sampleValue(field)}`),
|
|
314
|
+
'createdAt: new Date()',
|
|
315
|
+
'updatedAt: new Date()',
|
|
316
|
+
];
|
|
317
|
+
return `{\n ${entries.join(',\n ')},\n}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function createLiteral(fields: Field[]): string {
|
|
321
|
+
const entries = fields
|
|
322
|
+
.filter((field) => !field.nullable)
|
|
323
|
+
.map((field) => `${field.name}: ${sampleValue(field)}`);
|
|
324
|
+
return `{\n ${entries.join(',\n ')},\n}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function sampleValue(field: Field): string {
|
|
328
|
+
const byType: Record<string, string> = {
|
|
329
|
+
string: `'sample'`,
|
|
330
|
+
text: `'sample'`,
|
|
331
|
+
boolean: 'false',
|
|
332
|
+
integer: '1',
|
|
333
|
+
timestamp: 'new Date()',
|
|
334
|
+
};
|
|
335
|
+
return byType[field.type] ?? 'null';
|
|
336
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "HedgehogContractLayer",
|
|
4
|
+
"title": "ts-rest contract 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
|
+
}
|