@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,218 @@
|
|
|
1
|
+
import { formatFiles, Tree, updateJson } from '@nx/devkit';
|
|
2
|
+
import { moduleNames, ModuleNames } from '../naming';
|
|
3
|
+
|
|
4
|
+
interface ScreenGeneratorOptions {
|
|
5
|
+
module: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const WEB_ROOT = 'apps/web';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deliberately a skeleton: it wires the hook layer to a route and leaves
|
|
12
|
+
* placeholders for the list, the filter shell, the empty state, and the
|
|
13
|
+
* create/edit form. Layout, information hierarchy, and every other UX
|
|
14
|
+
* judgment belong to ux-planner's rationale and front-end-eng's build, not
|
|
15
|
+
* to a template that would make every module's screen look identical
|
|
16
|
+
* before anyone decided it should.
|
|
17
|
+
*/
|
|
18
|
+
export default async function screenGenerator(
|
|
19
|
+
tree: Tree,
|
|
20
|
+
options: ScreenGeneratorOptions,
|
|
21
|
+
) {
|
|
22
|
+
const names = moduleNames(options.module);
|
|
23
|
+
const dir = `${WEB_ROOT}/src/app/${names.module}`;
|
|
24
|
+
|
|
25
|
+
tree.write(`${dir}/page.tsx`, pageFile(names));
|
|
26
|
+
tree.write(`${dir}/${names.module}-screen.tsx`, screenFile(names));
|
|
27
|
+
tree.write(`${dir}/${names.module}-form.tsx`, formFile(names));
|
|
28
|
+
tree.write(`${dir}/${names.module}-screen.spec.tsx`, specFile(names));
|
|
29
|
+
|
|
30
|
+
addWebDependencies(tree);
|
|
31
|
+
|
|
32
|
+
await formatFiles(tree);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pageFile(names: ModuleNames): string {
|
|
36
|
+
return `import { ${names.pascal}Screen } from './${names.module}-screen';
|
|
37
|
+
|
|
38
|
+
export default function ${names.pascal}Page() {
|
|
39
|
+
return <${names.pascal}Screen />;
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function screenFile(names: ModuleNames): string {
|
|
45
|
+
const { pascal, camel, entityPascal } = names;
|
|
46
|
+
|
|
47
|
+
return `'use client';
|
|
48
|
+
|
|
49
|
+
import { useState } from 'react';
|
|
50
|
+
import { useRemove${entityPascal}, use${pascal} } from 'hooks';
|
|
51
|
+
import { ${pascal}Form } from './${names.module}-form';
|
|
52
|
+
|
|
53
|
+
export function ${pascal}Screen() {
|
|
54
|
+
const { data, isPending, isError } = use${pascal}();
|
|
55
|
+
const remove${entityPascal} = useRemove${entityPascal}();
|
|
56
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
57
|
+
|
|
58
|
+
if (isPending) {
|
|
59
|
+
return <p>Loading ${names.module}…</p>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isError) {
|
|
63
|
+
return <p>${pascal} could not be loaded.</p>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ${camel} = data ?? [];
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<main>
|
|
70
|
+
<header>
|
|
71
|
+
<h1>${pascal}</h1>
|
|
72
|
+
{/* Filter and tab shell — front-end-eng decides which facets a
|
|
73
|
+
${names.entityCamel} is filtered by, from ux-planner's rationale. */}
|
|
74
|
+
<nav aria-label="${pascal} filters" />
|
|
75
|
+
</header>
|
|
76
|
+
|
|
77
|
+
<${pascal}Form
|
|
78
|
+
editingId={editingId}
|
|
79
|
+
onDone={() => setEditingId(null)}
|
|
80
|
+
/>
|
|
81
|
+
|
|
82
|
+
{${camel}.length === 0 ? (
|
|
83
|
+
<p>No ${names.module} yet.</p>
|
|
84
|
+
) : (
|
|
85
|
+
<ul>
|
|
86
|
+
{${camel}.map((${names.entityCamel}) => (
|
|
87
|
+
<li key={${names.entityCamel}.id}>
|
|
88
|
+
<button type="button" onClick={() => setEditingId(${names.entityCamel}.id)}>
|
|
89
|
+
Edit
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => remove${entityPascal}.mutate(${names.entityCamel}.id)}
|
|
94
|
+
>
|
|
95
|
+
Delete
|
|
96
|
+
</button>
|
|
97
|
+
</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
)}
|
|
101
|
+
</main>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formFile(names: ModuleNames): string {
|
|
108
|
+
const { pascal, entityPascal, entityCamel } = names;
|
|
109
|
+
|
|
110
|
+
return `'use client';
|
|
111
|
+
|
|
112
|
+
import { useCreate${entityPascal} } from 'hooks';
|
|
113
|
+
|
|
114
|
+
interface ${pascal}FormProps {
|
|
115
|
+
editingId: string | null;
|
|
116
|
+
onDone: () => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function ${pascal}Form({ editingId, onDone }: ${pascal}FormProps) {
|
|
120
|
+
const create${entityPascal} = useCreate${entityPascal}();
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<form
|
|
124
|
+
aria-label={editingId ? 'Edit ${entityCamel}' : 'Create ${entityCamel}'}
|
|
125
|
+
onSubmit={(event) => {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
onDone();
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{/* Fields, validation feedback, and submit affordance are
|
|
131
|
+
front-end-eng's, built against the contract's own create/update
|
|
132
|
+
schema rather than duplicated here. */}
|
|
133
|
+
<button type="submit" disabled={create${entityPascal}.isPending}>
|
|
134
|
+
Save
|
|
135
|
+
</button>
|
|
136
|
+
</form>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function specFile(names: ModuleNames): string {
|
|
143
|
+
const { pascal, entityPascal } = names;
|
|
144
|
+
|
|
145
|
+
return `import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
146
|
+
import { render, screen } from '@testing-library/react';
|
|
147
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
148
|
+
import { ${pascal}Screen } from './${names.module}-screen';
|
|
149
|
+
|
|
150
|
+
// Hoisted above the import above by Vitest, which is what lets the screen
|
|
151
|
+
// render against stubbed hooks instead of a live API.
|
|
152
|
+
vi.mock('hooks', () => ({
|
|
153
|
+
use${pascal}: vi.fn(() => ({ data: [], isPending: false, isError: false })),
|
|
154
|
+
useCreate${entityPascal}: vi.fn(() => ({ isPending: false, mutate: vi.fn() })),
|
|
155
|
+
useRemove${entityPascal}: vi.fn(() => ({ isPending: false, mutate: vi.fn() })),
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
function renderScreen() {
|
|
159
|
+
const queryClient = new QueryClient({
|
|
160
|
+
defaultOptions: { queries: { retry: false } },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return render(
|
|
164
|
+
<QueryClientProvider client={queryClient}>
|
|
165
|
+
<${pascal}Screen />
|
|
166
|
+
</QueryClientProvider>,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe('${pascal}Screen', () => {
|
|
171
|
+
it('renders the empty state when the hook returns no rows', () => {
|
|
172
|
+
renderScreen();
|
|
173
|
+
|
|
174
|
+
expect(screen.getByText(/no ${names.module} yet/i)).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('renders the filter shell and the create form', () => {
|
|
178
|
+
renderScreen();
|
|
179
|
+
|
|
180
|
+
expect(
|
|
181
|
+
screen.getByRole('navigation', { name: /${names.module} filters/i }),
|
|
182
|
+
).toBeInTheDocument();
|
|
183
|
+
expect(
|
|
184
|
+
screen.getByRole('form', { name: /create /i }),
|
|
185
|
+
).toBeInTheDocument();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* apps/web consumes the hook package as TypeScript source, so the
|
|
193
|
+
* dependency and the project reference both have to exist before the
|
|
194
|
+
* screen's own import resolves.
|
|
195
|
+
*/
|
|
196
|
+
function addWebDependencies(tree: Tree) {
|
|
197
|
+
updateJson(tree, `${WEB_ROOT}/package.json`, (json) => {
|
|
198
|
+
json.dependencies = {
|
|
199
|
+
...json.dependencies,
|
|
200
|
+
contracts: 'workspace:*',
|
|
201
|
+
hooks: 'workspace:*',
|
|
202
|
+
};
|
|
203
|
+
return json;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
for (const config of ['tsconfig.json', 'tsconfig.spec.json']) {
|
|
207
|
+
updateJson(tree, `${WEB_ROOT}/${config}`, (json) => {
|
|
208
|
+
const references: { path: string }[] = json.references ?? [];
|
|
209
|
+
for (const path of ['../../packages/contracts', '../../packages/hooks']) {
|
|
210
|
+
if (!references.some((entry) => entry.path === path)) {
|
|
211
|
+
references.push({ path });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
json.references = references;
|
|
215
|
+
return json;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "HedgehogScreenLayer",
|
|
4
|
+
"title": "Screen skeleton 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
|
+
},
|
|
14
|
+
"required": ["module"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { formatFiles, Tree } from '@nx/devkit';
|
|
2
|
+
import { generateLibShell } from '../lib-shell';
|
|
3
|
+
import { moduleNames, ModuleNames } from '../naming';
|
|
4
|
+
|
|
5
|
+
interface ServiceGeneratorOptions {
|
|
6
|
+
module: string;
|
|
7
|
+
toggleField?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default async function serviceGenerator(
|
|
11
|
+
tree: Tree,
|
|
12
|
+
options: ServiceGeneratorOptions,
|
|
13
|
+
) {
|
|
14
|
+
const names = moduleNames(options.module);
|
|
15
|
+
const root = `libs/${names.module}/service`;
|
|
16
|
+
|
|
17
|
+
await generateLibShell(tree, {
|
|
18
|
+
directory: root,
|
|
19
|
+
importName: `${names.module}-service`,
|
|
20
|
+
tags: [`scope:${names.module}`, 'type:service'],
|
|
21
|
+
dependencies: { [`${names.module}-repository`]: 'workspace:*' },
|
|
22
|
+
references: ['../repository/tsconfig.lib.json'],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
tree.write(`${root}/src/lib/${names.entityKebab}.errors.ts`, errorsFile(names));
|
|
26
|
+
tree.write(
|
|
27
|
+
`${root}/src/lib/${names.entityKebab}.service.ts`,
|
|
28
|
+
serviceFile(names, options.toggleField),
|
|
29
|
+
);
|
|
30
|
+
tree.write(
|
|
31
|
+
`${root}/src/lib/${names.entityKebab}.service.spec.ts`,
|
|
32
|
+
serviceSpecFile(names, options.toggleField),
|
|
33
|
+
);
|
|
34
|
+
tree.write(`${root}/src/index.ts`, barrelFile(names));
|
|
35
|
+
|
|
36
|
+
await formatFiles(tree);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A distinct `name` on each error is what the controller layer switches on
|
|
41
|
+
* to pick a status code — `instanceof` alone does not survive the class
|
|
42
|
+
* identity being duplicated across a bundling boundary.
|
|
43
|
+
*/
|
|
44
|
+
function errorsFile(names: ModuleNames): string {
|
|
45
|
+
const { entityPascal, entityCamel } = names;
|
|
46
|
+
|
|
47
|
+
return `export class ${entityPascal}NotFoundError extends Error {
|
|
48
|
+
override readonly name = '${entityPascal}NotFoundError';
|
|
49
|
+
|
|
50
|
+
constructor(readonly id: string) {
|
|
51
|
+
super(\`No ${entityCamel} exists with id \${id}.\`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ${entityPascal}InvalidStateError extends Error {
|
|
56
|
+
override readonly name = '${entityPascal}InvalidStateError';
|
|
57
|
+
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The flip is computed from the row this transaction just read, never
|
|
67
|
+
* from a value the caller supplied: the client has no way to send a
|
|
68
|
+
* stale one, so two toggles racing against each other serialize into two
|
|
69
|
+
* flips instead of both writing the same value and losing one.
|
|
70
|
+
*/
|
|
71
|
+
function toggleMethod(names: ModuleNames, toggleField: string): string {
|
|
72
|
+
const { entityPascal, entityCamel } = names;
|
|
73
|
+
|
|
74
|
+
return `
|
|
75
|
+
|
|
76
|
+
async toggle${entityPascal}(id: string): Promise<${entityPascal}> {
|
|
77
|
+
return this.${entityCamel}s.transaction(async (repository) => {
|
|
78
|
+
const existing = await repository.findById(id);
|
|
79
|
+
if (!existing) {
|
|
80
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
81
|
+
}
|
|
82
|
+
const updated = await repository.update(id, {
|
|
83
|
+
${toggleField}: !existing.${toggleField},
|
|
84
|
+
} as ${entityPascal}Patch);
|
|
85
|
+
if (!updated) {
|
|
86
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
87
|
+
}
|
|
88
|
+
return updated;
|
|
89
|
+
});
|
|
90
|
+
}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function serviceFile(names: ModuleNames, toggleField?: string): string {
|
|
94
|
+
const { entityPascal, entityCamel, entityKebab, module } = names;
|
|
95
|
+
|
|
96
|
+
return `import type {
|
|
97
|
+
${entityPascal},
|
|
98
|
+
${entityPascal}Draft,
|
|
99
|
+
${entityPascal}Patch,
|
|
100
|
+
${entityPascal}Repository,
|
|
101
|
+
} from '${module}-repository';
|
|
102
|
+
import { ${entityPascal}NotFoundError } from './${entityKebab}.errors';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Domain logic only — the ts-rest contract has already validated every
|
|
106
|
+
* input by the time a method here runs, so nothing re-parses.
|
|
107
|
+
*/
|
|
108
|
+
export class ${entityPascal}Service {
|
|
109
|
+
constructor(private readonly ${entityCamel}s: ${entityPascal}Repository) {}
|
|
110
|
+
|
|
111
|
+
async list(): Promise<${entityPascal}[]> {
|
|
112
|
+
return this.${entityCamel}s.findAll();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getById(id: string): Promise<${entityPascal}> {
|
|
116
|
+
const ${entityCamel} = await this.${entityCamel}s.findById(id);
|
|
117
|
+
if (!${entityCamel}) {
|
|
118
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
119
|
+
}
|
|
120
|
+
return ${entityCamel};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async create(draft: ${entityPascal}Draft): Promise<${entityPascal}> {
|
|
124
|
+
return this.${entityCamel}s.create(draft);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async update(id: string, patch: ${entityPascal}Patch): Promise<${entityPascal}> {
|
|
128
|
+
// Read-then-write is two operations against one row, so it runs in one
|
|
129
|
+
// transaction: without it a concurrent delete between the two leaves
|
|
130
|
+
// update() returning undefined for a row this method already proved
|
|
131
|
+
// present.
|
|
132
|
+
return this.${entityCamel}s.transaction(async (repository) => {
|
|
133
|
+
const existing = await repository.findById(id);
|
|
134
|
+
if (!existing) {
|
|
135
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
136
|
+
}
|
|
137
|
+
const updated = await repository.update(id, patch);
|
|
138
|
+
if (!updated) {
|
|
139
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
140
|
+
}
|
|
141
|
+
return updated;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async remove(id: string): Promise<void> {
|
|
146
|
+
const removed = await this.${entityCamel}s.remove(id);
|
|
147
|
+
if (!removed) {
|
|
148
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
149
|
+
}
|
|
150
|
+
}${toggleField ? toggleMethod(names, toggleField) : ''}
|
|
151
|
+
}
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function serviceSpecFile(names: ModuleNames, toggleField?: string): string {
|
|
156
|
+
const { entityPascal, entityCamel, entityKebab } = names;
|
|
157
|
+
|
|
158
|
+
return `import { describe, expect, it, vi } from 'vitest';
|
|
159
|
+
import type { ${entityPascal}Repository } from '${names.module}-repository';
|
|
160
|
+
import { ${entityPascal}NotFoundError } from './${entityKebab}.errors';
|
|
161
|
+
import { ${entityPascal}Service } from './${entityKebab}.service';
|
|
162
|
+
|
|
163
|
+
function repositoryDouble(
|
|
164
|
+
overrides: Partial<${entityPascal}Repository> = {},
|
|
165
|
+
): ${entityPascal}Repository {
|
|
166
|
+
const repository: ${entityPascal}Repository = {
|
|
167
|
+
findAll: vi.fn(async () => []),
|
|
168
|
+
findById: vi.fn(async () => undefined),
|
|
169
|
+
create: vi.fn(async (draft) => ({ id: 'created', ...draft }) as never),
|
|
170
|
+
update: vi.fn(async () => undefined),
|
|
171
|
+
remove: vi.fn(async () => false),
|
|
172
|
+
transaction: vi.fn(async (work) => work(repository)),
|
|
173
|
+
...overrides,
|
|
174
|
+
};
|
|
175
|
+
return repository;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe('${entityPascal}Service', () => {
|
|
179
|
+
it('throws a named domain error when a ${entityCamel} is absent', async () => {
|
|
180
|
+
const service = new ${entityPascal}Service(repositoryDouble());
|
|
181
|
+
|
|
182
|
+
await expect(service.getById('missing')).rejects.toBeInstanceOf(
|
|
183
|
+
${entityPascal}NotFoundError,
|
|
184
|
+
);
|
|
185
|
+
await expect(service.getById('missing')).rejects.toMatchObject({
|
|
186
|
+
name: '${entityPascal}NotFoundError',
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns the row the repository found, unparsed', async () => {
|
|
191
|
+
const row = { id: 'present' } as never;
|
|
192
|
+
const service = new ${entityPascal}Service(
|
|
193
|
+
repositoryDouble({ findById: vi.fn(async () => row) }),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
await expect(service.getById('present')).resolves.toBe(row);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('runs a read-then-write update inside one transaction', async () => {
|
|
200
|
+
const repository = repositoryDouble({
|
|
201
|
+
findById: vi.fn(async () => ({ id: 'present' }) as never),
|
|
202
|
+
update: vi.fn(async () => ({ id: 'present' }) as never),
|
|
203
|
+
});
|
|
204
|
+
const service = new ${entityPascal}Service(repository);
|
|
205
|
+
|
|
206
|
+
await service.update('present', {} as never);
|
|
207
|
+
|
|
208
|
+
expect(repository.transaction).toHaveBeenCalledOnce();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('turns a delete that matched nothing into a not-found error', async () => {
|
|
212
|
+
const service = new ${entityPascal}Service(repositoryDouble());
|
|
213
|
+
|
|
214
|
+
await expect(service.remove('missing')).rejects.toBeInstanceOf(
|
|
215
|
+
${entityPascal}NotFoundError,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
${
|
|
219
|
+
toggleField
|
|
220
|
+
? `
|
|
221
|
+
it('derives the new ${toggleField} from stored state, not from the caller', async () => {
|
|
222
|
+
const repository = repositoryDouble({
|
|
223
|
+
findById: vi.fn(async () => ({ id: 'present', ${toggleField}: true }) as never),
|
|
224
|
+
update: vi.fn(async (_id, patch) => ({ id: 'present', ...patch }) as never),
|
|
225
|
+
});
|
|
226
|
+
const service = new ${entityPascal}Service(repository);
|
|
227
|
+
|
|
228
|
+
await service.toggle${entityPascal}('present');
|
|
229
|
+
|
|
230
|
+
expect(repository.update).toHaveBeenCalledWith('present', {
|
|
231
|
+
${toggleField}: false,
|
|
232
|
+
});
|
|
233
|
+
expect(repository.transaction).toHaveBeenCalledOnce();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('flips twice back to the original value, so no flip is lost', async () => {
|
|
237
|
+
let stored = false;
|
|
238
|
+
const repository = repositoryDouble({
|
|
239
|
+
findById: vi.fn(async () => ({ id: 'present', ${toggleField}: stored }) as never),
|
|
240
|
+
update: vi.fn(async (_id, patch) => {
|
|
241
|
+
stored = (patch as { ${toggleField}: boolean }).${toggleField};
|
|
242
|
+
return { id: 'present', ${toggleField}: stored } as never;
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
const service = new ${entityPascal}Service(repository);
|
|
246
|
+
|
|
247
|
+
await service.toggle${entityPascal}('present');
|
|
248
|
+
expect(stored).toBe(true);
|
|
249
|
+
await service.toggle${entityPascal}('present');
|
|
250
|
+
expect(stored).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('rejects a toggle against a ${entityCamel} that is not there', async () => {
|
|
254
|
+
const service = new ${entityPascal}Service(repositoryDouble());
|
|
255
|
+
|
|
256
|
+
await expect(service.toggle${entityPascal}('missing')).rejects.toBeInstanceOf(
|
|
257
|
+
${entityPascal}NotFoundError,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
`
|
|
261
|
+
: ''
|
|
262
|
+
}});
|
|
263
|
+
`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function barrelFile(names: ModuleNames): string {
|
|
267
|
+
return `export * from './lib/${names.entityKebab}.errors';
|
|
268
|
+
export * from './lib/${names.entityKebab}.service';
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "HedgehogServiceLayer",
|
|
4
|
+
"title": "Domain service 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
|
+
"toggleField": {
|
|
14
|
+
"type": "string",
|
|
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
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"required": ["module"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Phase gate: "Phase A closes before Phase B opens", enforced in CI.
|
|
4
|
+
*
|
|
5
|
+
* Blocks a PR that introduces a `feat(<module>): hooks` or
|
|
6
|
+
* `feat(<module>): screen-*` commit for a module with no prior
|
|
7
|
+
* `feat(<module>): api` commit already on the branch (or on the base
|
|
8
|
+
* branch it diverged from).
|
|
9
|
+
*
|
|
10
|
+
* Written as CommonJS (`.cjs`) deliberately: root `package.json` sets
|
|
11
|
+
* `"type": "module"`, so a plain `.js` file here would be parsed as ESM
|
|
12
|
+
* and `require`/`module.exports` below would fail with "require is not
|
|
13
|
+
* defined".
|
|
14
|
+
*/
|
|
15
|
+
const { execFileSync } = require('node:child_process');
|
|
16
|
+
|
|
17
|
+
// git without a shell: `base` comes from the environment
|
|
18
|
+
// (GITHUB_BASE_REF is set from the pull request's branch name, which a
|
|
19
|
+
// fork controls), so the range reaches git as one literal argument
|
|
20
|
+
// rather than as text a shell reparses.
|
|
21
|
+
function getCommitSubjects(base) {
|
|
22
|
+
const range = base ? `${base}..HEAD` : 'HEAD';
|
|
23
|
+
const out = execFileSync('git', ['log', '--format=%s', range], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
});
|
|
26
|
+
return out.split('\n').filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function main() {
|
|
30
|
+
const base =
|
|
31
|
+
process.env.PHASE_GATE_BASE || process.env.GITHUB_BASE_REF || 'origin/main';
|
|
32
|
+
|
|
33
|
+
let subjects;
|
|
34
|
+
try {
|
|
35
|
+
subjects = getCommitSubjects(base);
|
|
36
|
+
} catch {
|
|
37
|
+
// Base ref not resolvable (e.g. shallow clone, no such ref) — fall
|
|
38
|
+
// back to the full local history rather than failing the check for
|
|
39
|
+
// an unrelated git plumbing reason.
|
|
40
|
+
subjects = getCommitSubjects(null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const phaseBRe = /^feat\(([a-z0-9-]+)\): (hooks|screen-[\w-]+)/;
|
|
44
|
+
const phaseARe = (mod) => new RegExp(`^feat\\(${mod}\\): api\\b`);
|
|
45
|
+
|
|
46
|
+
const violations = [];
|
|
47
|
+
|
|
48
|
+
for (const subject of subjects) {
|
|
49
|
+
const match = subject.match(phaseBRe);
|
|
50
|
+
if (!match) continue;
|
|
51
|
+
|
|
52
|
+
const mod = match[1];
|
|
53
|
+
const hasPhaseACommit = subjects.some((s) => phaseARe(mod).test(s));
|
|
54
|
+
|
|
55
|
+
if (!hasPhaseACommit) {
|
|
56
|
+
violations.push({ subject, mod });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (violations.length > 0) {
|
|
61
|
+
console.error(
|
|
62
|
+
'Phase gate failed: Phase B commit(s) with no prior Phase A "api" commit:',
|
|
63
|
+
);
|
|
64
|
+
for (const { subject, mod } of violations) {
|
|
65
|
+
console.error(
|
|
66
|
+
` - "${subject}" (module: ${mod}) — expected a prior "feat(${mod}): api" commit`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(
|
|
73
|
+
'Phase gate passed: every Phase B commit has a prior Phase A "api" commit.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"composite": true,
|
|
4
|
+
"declarationMap": true,
|
|
5
|
+
"emitDeclarationOnly": true,
|
|
6
|
+
"importHelpers": true,
|
|
7
|
+
"isolatedModules": true,
|
|
8
|
+
"lib": ["es2022"],
|
|
9
|
+
"module": "esnext",
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"noEmitOnError": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noImplicitOverride": true,
|
|
14
|
+
"noImplicitReturns": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"strict": true,
|
|
18
|
+
"target": "es2022",
|
|
19
|
+
"customConditions": ["app"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.base.json",
|
|
3
|
+
"compileOnSave": false,
|
|
4
|
+
"files": [],
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "./packages/config"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "./packages/db"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"path": "./apps/api-e2e"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "./apps/api"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "./apps/web"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "./apps/web-e2e"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|