@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.
Files changed (137) hide show
  1. package/CLAUDE.core.md +147 -0
  2. package/LICENSE +21 -0
  3. package/README.md +41 -0
  4. package/agents/backend-eng.md +189 -0
  5. package/agents/front-end-eng.md +176 -0
  6. package/agents/ux-planner.md +174 -0
  7. package/hedgehog-core.yaml +20 -0
  8. package/package.json +41 -0
  9. package/skills/hedgehog-bootstrap/SKILL.md +324 -0
  10. package/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +411 -0
  11. package/skills/hedgehog-loop/SKILL.md +643 -0
  12. package/skills/link-workspace-packages/SKILL.md +41 -0
  13. package/skills/nx-generate/SKILL.md +165 -0
  14. package/skills/nx-run-tasks/SKILL.md +52 -0
  15. package/skills/nx-workspace/SKILL.md +261 -0
  16. package/skills/nx-workspace/references/AFFECTED.md +27 -0
  17. package/vendor-skills/GSAP/ATTRIBUTION.md +30 -0
  18. package/vendor-skills/GSAP/LICENSE +21 -0
  19. package/vendor-skills/GSAP/gsap-core/SKILL.md +254 -0
  20. package/vendor-skills/GSAP/gsap-frameworks/SKILL.md +266 -0
  21. package/vendor-skills/GSAP/gsap-performance/SKILL.md +79 -0
  22. package/vendor-skills/GSAP/gsap-plugins/SKILL.md +433 -0
  23. package/vendor-skills/GSAP/gsap-react/SKILL.md +136 -0
  24. package/vendor-skills/GSAP/gsap-scrolltrigger/SKILL.md +296 -0
  25. package/vendor-skills/GSAP/gsap-timeline/SKILL.md +107 -0
  26. package/vendor-skills/GSAP/gsap-utils/SKILL.md +284 -0
  27. package/vendor-skills/GSAP/llms.txt +39 -0
  28. package/workspace/.env.example +5 -0
  29. package/workspace/.github/workflows/phase-gate.yml +17 -0
  30. package/workspace/.prettierignore +5 -0
  31. package/workspace/.prettierrc +3 -0
  32. package/workspace/.vscode/extensions.json +3 -0
  33. package/workspace/apps/api/eslint.config.mjs +3 -0
  34. package/workspace/apps/api/package.json +117 -0
  35. package/workspace/apps/api/src/app/app.module.spec.ts +15 -0
  36. package/workspace/apps/api/src/app/app.module.ts +15 -0
  37. package/workspace/apps/api/src/app/feature-modules.ts +8 -0
  38. package/workspace/apps/api/src/app/health.controller.ts +9 -0
  39. package/workspace/apps/api/src/assets/.gitkeep +0 -0
  40. package/workspace/apps/api/src/main.ts +32 -0
  41. package/workspace/apps/api/tsconfig.app.json +22 -0
  42. package/workspace/apps/api/tsconfig.json +13 -0
  43. package/workspace/apps/api/tsconfig.spec.json +36 -0
  44. package/workspace/apps/api/vitest.config.mts +18 -0
  45. package/workspace/apps/api/webpack.config.cjs +25 -0
  46. package/workspace/apps/api-e2e/eslint.config.mjs +3 -0
  47. package/workspace/apps/api-e2e/package.json +28 -0
  48. package/workspace/apps/api-e2e/src/api/api.spec.ts +11 -0
  49. package/workspace/apps/api-e2e/src/support/global-setup.ts +17 -0
  50. package/workspace/apps/api-e2e/src/support/test-setup.ts +6 -0
  51. package/workspace/apps/api-e2e/tsconfig.json +10 -0
  52. package/workspace/apps/api-e2e/tsconfig.spec.json +28 -0
  53. package/workspace/apps/api-e2e/vitest.config.mts +20 -0
  54. package/workspace/apps/web/.env.example +12 -0
  55. package/workspace/apps/web/.prettierrc.js +10 -0
  56. package/workspace/apps/web/.swcrc +30 -0
  57. package/workspace/apps/web/components.json +21 -0
  58. package/workspace/apps/web/eslint.config.mjs +12 -0
  59. package/workspace/apps/web/index.d.ts +7 -0
  60. package/workspace/apps/web/next-env.d.ts +7 -0
  61. package/workspace/apps/web/next.config.js +9 -0
  62. package/workspace/apps/web/package.json +38 -0
  63. package/workspace/apps/web/postcss.config.mjs +7 -0
  64. package/workspace/apps/web/public/.gitkeep +0 -0
  65. package/workspace/apps/web/public/favicon.ico +0 -0
  66. package/workspace/apps/web/src/app/global.css +83 -0
  67. package/workspace/apps/web/src/app/layout.tsx +34 -0
  68. package/workspace/apps/web/src/app/module-routes.ts +13 -0
  69. package/workspace/apps/web/src/app/page.tsx +34 -0
  70. package/workspace/apps/web/src/app/providers.tsx +12 -0
  71. package/workspace/apps/web/src/components/theme-toggle.spec.tsx +20 -0
  72. package/workspace/apps/web/src/components/theme-toggle.tsx +32 -0
  73. package/workspace/apps/web/src/components/ui/button.tsx +60 -0
  74. package/workspace/apps/web/src/lib/utils.ts +6 -0
  75. package/workspace/apps/web/src/test-setup.ts +1 -0
  76. package/workspace/apps/web/tsconfig.json +62 -0
  77. package/workspace/apps/web/tsconfig.spec.json +37 -0
  78. package/workspace/apps/web/vitest.config.mts +27 -0
  79. package/workspace/apps/web-e2e/eslint.config.mjs +12 -0
  80. package/workspace/apps/web-e2e/package.json +10 -0
  81. package/workspace/apps/web-e2e/playwright.config.mts +75 -0
  82. package/workspace/apps/web-e2e/src/example.spec.ts +7 -0
  83. package/workspace/apps/web-e2e/tsconfig.json +26 -0
  84. package/workspace/commitlint.config.cjs +3 -0
  85. package/workspace/core.yaml +68 -0
  86. package/workspace/docker-compose.yml +14 -0
  87. package/workspace/eslint.config.mjs +15 -0
  88. package/workspace/gitignore.template +24 -0
  89. package/workspace/lefthook.yml +52 -0
  90. package/workspace/nx.json +90 -0
  91. package/workspace/package.json +99 -0
  92. package/workspace/packages/config/eslint-base.js +205 -0
  93. package/workspace/packages/config/package.json +28 -0
  94. package/workspace/packages/config/prettier.js +13 -0
  95. package/workspace/packages/config/src/env.schema.spec.ts +38 -0
  96. package/workspace/packages/config/src/env.schema.ts +18 -0
  97. package/workspace/packages/config/src/index.ts +1 -0
  98. package/workspace/packages/config/tsconfig.json +13 -0
  99. package/workspace/packages/config/tsconfig.lib.json +27 -0
  100. package/workspace/packages/config/tsconfig.spec.json +34 -0
  101. package/workspace/packages/config/vitest.config.mts +18 -0
  102. package/workspace/packages/db/package.json +27 -0
  103. package/workspace/packages/db/src/index.ts +2 -0
  104. package/workspace/packages/db/src/lib/db.spec.ts +48 -0
  105. package/workspace/packages/db/src/lib/db.ts +54 -0
  106. package/workspace/packages/db/src/schema/index.ts +4 -0
  107. package/workspace/packages/db/tsconfig.json +13 -0
  108. package/workspace/packages/db/tsconfig.lib.json +27 -0
  109. package/workspace/packages/db/tsconfig.spec.json +34 -0
  110. package/workspace/packages/db/vitest.config.mts +18 -0
  111. package/workspace/pnpm-lock.yaml +23719 -0
  112. package/workspace/pnpm-workspace.yaml +7 -0
  113. package/workspace/tools/generate-feature-modules.cjs +104 -0
  114. package/workspace/tools/generate-module-routes.cjs +89 -0
  115. package/workspace/tools/generators/contract/generator.ts +336 -0
  116. package/workspace/tools/generators/contract/schema.json +24 -0
  117. package/workspace/tools/generators/controller/generator.ts +390 -0
  118. package/workspace/tools/generators/controller/schema.json +24 -0
  119. package/workspace/tools/generators/fields.ts +160 -0
  120. package/workspace/tools/generators/generators.json +42 -0
  121. package/workspace/tools/generators/hook/generator.ts +291 -0
  122. package/workspace/tools/generators/hook/schema.json +19 -0
  123. package/workspace/tools/generators/lib-shell.ts +124 -0
  124. package/workspace/tools/generators/naming.ts +84 -0
  125. package/workspace/tools/generators/package.json +6 -0
  126. package/workspace/tools/generators/repository/generator.ts +298 -0
  127. package/workspace/tools/generators/repository/schema.json +15 -0
  128. package/workspace/tools/generators/schema/generator.ts +169 -0
  129. package/workspace/tools/generators/schema/schema.json +20 -0
  130. package/workspace/tools/generators/screen/generator.ts +218 -0
  131. package/workspace/tools/generators/screen/schema.json +15 -0
  132. package/workspace/tools/generators/service/generator.ts +270 -0
  133. package/workspace/tools/generators/service/schema.json +19 -0
  134. package/workspace/tools/phase-gate.cjs +77 -0
  135. package/workspace/tsconfig.base.json +21 -0
  136. package/workspace/tsconfig.json +25 -0
  137. package/workspace/vitest.workspace.ts +4 -0
@@ -0,0 +1,298 @@
1
+ import { formatFiles, Tree } from '@nx/devkit';
2
+ import { generateLibShell } from '../lib-shell';
3
+ import { moduleNames, ModuleNames } from '../naming';
4
+
5
+ interface RepositoryGeneratorOptions {
6
+ module: string;
7
+ }
8
+
9
+ export default async function repositoryGenerator(
10
+ tree: Tree,
11
+ options: RepositoryGeneratorOptions,
12
+ ) {
13
+ const names = moduleNames(options.module);
14
+ const root = `libs/${names.module}/repository`;
15
+
16
+ await generateLibShell(tree, {
17
+ directory: root,
18
+ importName: `${names.module}-repository`,
19
+ // The pair packages/config/eslint-base.js's depConstraints expect for a
20
+ // repository lib: it holds the port interface and its Drizzle adapter
21
+ // in one project, so `type:adapter` is what a service is allowed to
22
+ // depend on; the no-restricted-imports rule is what stops the service
23
+ // from reaching the adapter through it.
24
+ tags: [`scope:${names.module}`, 'type:adapter'],
25
+ dependencies: { db: 'workspace:*' },
26
+ references: ['../../../packages/db/tsconfig.lib.json'],
27
+ });
28
+
29
+ tree.write(`${root}/src/lib/${names.entityKebab}.port.ts`, portFile(names));
30
+ tree.write(
31
+ `${root}/src/lib/${names.entityKebab}.adapter.ts`,
32
+ adapterFile(names),
33
+ );
34
+ tree.write(
35
+ `${root}/src/lib/${names.entityKebab}.adapter.spec.ts`,
36
+ adapterSpecFile(names),
37
+ );
38
+ tree.write(`${root}/src/index.ts`, barrelFile(names));
39
+
40
+ await formatFiles(tree);
41
+ }
42
+
43
+ /**
44
+ * The port is the only symbol a service may see, so the DI token lives
45
+ * beside it rather than in the adapter file — importing the token out of
46
+ * `*.adapter.ts` would drag the concrete class across the boundary
47
+ * eslint-base.js's no-restricted-imports rule draws.
48
+ */
49
+ function portFile(names: ModuleNames): string {
50
+ const { entityPascal, entityConstant } = names;
51
+
52
+ return `import type { ${entityPascal}, New${entityPascal} } from 'db';
53
+
54
+ // Re-exported so a service can name the entity type without importing
55
+ // packages/db itself — eslint-base.js's no-restricted-imports rule bans a
56
+ // service from reaching the database package at all, type-only or not.
57
+ export type { ${entityPascal} };
58
+
59
+ export type ${entityPascal}Draft = Omit<
60
+ New${entityPascal},
61
+ 'id' | 'createdAt' | 'updatedAt'
62
+ >;
63
+ export type ${entityPascal}Patch = Partial<${entityPascal}Draft>;
64
+
65
+ export interface ${entityPascal}Repository {
66
+ findAll(): Promise<${entityPascal}[]>;
67
+ /** A miss is a plain absence — the service decides what it means. */
68
+ findById(id: string): Promise<${entityPascal} | undefined>;
69
+ create(draft: ${entityPascal}Draft): Promise<${entityPascal}>;
70
+ update(id: string, patch: ${entityPascal}Patch): Promise<${entityPascal} | undefined>;
71
+ remove(id: string): Promise<boolean>;
72
+ /**
73
+ * Runs \`work\` inside one database transaction, handing it a repository
74
+ * bound to that transaction. A throw rolls every write back, which is how
75
+ * a multi-write service method stays all-or-nothing without the service
76
+ * itself knowing about Drizzle.
77
+ */
78
+ transaction<T>(
79
+ work: (repository: ${entityPascal}Repository) => Promise<T>,
80
+ ): Promise<T>;
81
+ }
82
+
83
+ export const ${entityConstant}_REPOSITORY = Symbol('${entityPascal}Repository');
84
+ `;
85
+ }
86
+
87
+ function adapterFile(names: ModuleNames): string {
88
+ const { entityPascal, camel } = names;
89
+
90
+ return `import { eq } from 'drizzle-orm';
91
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
92
+ import { ${camel}, type ${entityPascal} } from 'db';
93
+ import type {
94
+ ${entityPascal}Draft,
95
+ ${entityPascal}Patch,
96
+ ${entityPascal}Repository,
97
+ } from './${names.entityKebab}.port';
98
+
99
+ /**
100
+ * Takes a thunk rather than a database instance so that constructing the
101
+ * adapter — which apps/api's module does at DI-container build time —
102
+ * opens no connection. getDb() reads DATABASE_URL and exits the process
103
+ * when it is absent, which would make merely compiling AppModule in a test
104
+ * require a live Postgres.
105
+ */
106
+ export class Drizzle${entityPascal}Repository implements ${entityPascal}Repository {
107
+ constructor(private readonly db: () => NodePgDatabase) {}
108
+
109
+ async findAll(): Promise<${entityPascal}[]> {
110
+ return this.db().select().from(${camel});
111
+ }
112
+
113
+ async findById(id: string): Promise<${entityPascal} | undefined> {
114
+ const [row] = await this.db()
115
+ .select()
116
+ .from(${camel})
117
+ .where(eq(${camel}.id, id))
118
+ .limit(1);
119
+ return row;
120
+ }
121
+
122
+ async create(draft: ${entityPascal}Draft): Promise<${entityPascal}> {
123
+ const [row] = await this.db().insert(${camel}).values(draft).returning();
124
+ return row;
125
+ }
126
+
127
+ async update(
128
+ id: string,
129
+ patch: ${entityPascal}Patch,
130
+ ): Promise<${entityPascal} | undefined> {
131
+ const [row] = await this.db()
132
+ .update(${camel})
133
+ .set({ ...patch, updatedAt: new Date() })
134
+ .where(eq(${camel}.id, id))
135
+ .returning();
136
+ return row;
137
+ }
138
+
139
+ async remove(id: string): Promise<boolean> {
140
+ const rows = await this.db()
141
+ .delete(${camel})
142
+ .where(eq(${camel}.id, id))
143
+ .returning({ id: ${camel}.id });
144
+ return rows.length > 0;
145
+ }
146
+
147
+ async transaction<T>(
148
+ work: (repository: ${entityPascal}Repository) => Promise<T>,
149
+ ): Promise<T> {
150
+ return this.db().transaction((tx) =>
151
+ work(new Drizzle${entityPascal}Repository(() => tx)),
152
+ );
153
+ }
154
+ }
155
+ `;
156
+ }
157
+
158
+ /**
159
+ * The adapter's contract with Drizzle, proved against an in-memory double
160
+ * rather than a live Postgres: the rollback assertion is what a service's
161
+ * multi-write transaction depends on, and `nx test` has no database.
162
+ */
163
+ function adapterSpecFile(names: ModuleNames): string {
164
+ const { entityPascal } = names;
165
+
166
+ return `import { describe, expect, it, vi } from 'vitest';
167
+ import { Drizzle${entityPascal}Repository } from './${names.entityKebab}.adapter';
168
+
169
+ interface FakeRow {
170
+ id: string;
171
+ }
172
+
173
+ /**
174
+ * A Drizzle stand-in, not a live database — nx test has no Postgres. It
175
+ * models exactly the two behaviours the port promises and the service
176
+ * layer relies on: a miss is an empty result set, and a throw inside
177
+ * transaction restores the pre-transaction rows.
178
+ */
179
+ function fakeDb(rows: FakeRow[]) {
180
+ let staged = [...rows];
181
+ let nextId = 0;
182
+
183
+ const select = () => ({
184
+ from: () => {
185
+ const all = Promise.resolve(staged);
186
+ return Object.assign(all, {
187
+ where: () => ({ limit: () => Promise.resolve(staged.slice(0, 1)) }),
188
+ });
189
+ },
190
+ });
191
+
192
+ const insert = () => ({
193
+ values: (draft: Record<string, unknown>) => ({
194
+ returning: () => {
195
+ const row: FakeRow = { ...draft, id: \`generated-\${(nextId += 1)}\` };
196
+ staged = [...staged, row];
197
+ return Promise.resolve([row]);
198
+ },
199
+ }),
200
+ });
201
+
202
+ const update = () => ({
203
+ set: (patch: Record<string, unknown>) => ({
204
+ where: () => ({
205
+ returning: () =>
206
+ Promise.resolve(staged.length ? [{ ...staged[0], ...patch }] : []),
207
+ }),
208
+ }),
209
+ });
210
+
211
+ const remove = () => ({
212
+ where: () => ({
213
+ returning: () => {
214
+ const removed = staged.slice(0, 1);
215
+ staged = staged.slice(1);
216
+ return Promise.resolve(removed);
217
+ },
218
+ }),
219
+ });
220
+
221
+ async function transaction<T>(work: (tx: unknown) => Promise<T>): Promise<T> {
222
+ const snapshot = [...staged];
223
+ try {
224
+ return await work(db);
225
+ } catch (error) {
226
+ staged = snapshot;
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ const db = { select, insert, update, delete: remove, transaction };
232
+
233
+ return {
234
+ db,
235
+ get rows(): FakeRow[] {
236
+ return staged;
237
+ },
238
+ };
239
+ }
240
+
241
+ describe('Drizzle${entityPascal}Repository', () => {
242
+ it('returns undefined rather than throwing when a row is absent', async () => {
243
+ const fake = fakeDb([]);
244
+ const repository = new Drizzle${entityPascal}Repository(
245
+ () => fake.db as never,
246
+ );
247
+
248
+ await expect(repository.findById('missing')).resolves.toBeUndefined();
249
+ });
250
+
251
+ it('reports a delete that matched nothing as false', async () => {
252
+ const fake = fakeDb([]);
253
+ const repository = new Drizzle${entityPascal}Repository(
254
+ () => fake.db as never,
255
+ );
256
+
257
+ await expect(repository.remove('missing')).resolves.toBe(false);
258
+ });
259
+
260
+ it('rolls every write back when the transaction body throws', async () => {
261
+ const fake = fakeDb([{ id: 'existing' }]);
262
+ const repository = new Drizzle${entityPascal}Repository(
263
+ () => fake.db as never,
264
+ );
265
+ const boom = new Error('second write failed');
266
+
267
+ await expect(
268
+ repository.transaction(async (tx) => {
269
+ await tx.create({} as never);
270
+ throw boom;
271
+ }),
272
+ ).rejects.toBe(boom);
273
+
274
+ expect(fake.rows).toHaveLength(1);
275
+ });
276
+
277
+ it('hands the transaction body a repository bound to that transaction', async () => {
278
+ const fake = fakeDb([{ id: 'existing' }]);
279
+ const repository = new Drizzle${entityPascal}Repository(
280
+ () => fake.db as never,
281
+ );
282
+ const seen = vi.fn();
283
+
284
+ await repository.transaction(async (tx) => {
285
+ seen(tx instanceof Drizzle${entityPascal}Repository);
286
+ });
287
+
288
+ expect(seen).toHaveBeenCalledWith(true);
289
+ });
290
+ });
291
+ `;
292
+ }
293
+
294
+ function barrelFile(names: ModuleNames): string {
295
+ return `export * from './lib/${names.entityKebab}.adapter';
296
+ export * from './lib/${names.entityKebab}.port';
297
+ `;
298
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "HedgehogRepositoryLayer",
4
+ "title": "Port, DI token, and Drizzle adapter 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,169 @@
1
+ import { formatFiles, Tree } from '@nx/devkit';
2
+ import {
3
+ drizzleColumn,
4
+ drizzleImports,
5
+ Field,
6
+ isDateField,
7
+ parseFields,
8
+ } from '../fields';
9
+ import { moduleNames, ModuleNames } from '../naming';
10
+
11
+ interface SchemaGeneratorOptions {
12
+ module: string;
13
+ fields: string;
14
+ }
15
+
16
+ export default async function schemaGenerator(
17
+ tree: Tree,
18
+ options: SchemaGeneratorOptions,
19
+ ) {
20
+ const names = moduleNames(options.module);
21
+ const fields = parseFields(options.fields);
22
+ const dir = `packages/db/src/schema/${names.module}`;
23
+
24
+ tree.write(`${dir}/${names.module}.table.ts`, tableFile(names, fields));
25
+ tree.write(`${dir}/${names.module}.zod.ts`, zodFile(names));
26
+ tree.write(`${dir}/index.ts`, barrelFile(names));
27
+ tree.write(`${dir}/${names.module}.spec.ts`, specFile(names, fields));
28
+
29
+ addSchemaBarrelExport(tree, names.module);
30
+
31
+ await formatFiles(tree);
32
+ }
33
+
34
+ function tableFile(names: ModuleNames, fields: Field[]): string {
35
+ const imports = drizzleImports(fields).join(', ');
36
+ const columns = fields.map((field) => ` ${drizzleColumn(field)}`).join('\n');
37
+
38
+ return `import { ${imports} } from 'drizzle-orm/pg-core';
39
+
40
+ export const ${names.camel} = pgTable('${names.tableName}', {
41
+ id: uuid('id').primaryKey().defaultRandom(),
42
+ ${columns}
43
+ createdAt: timestamp('created_at', { mode: 'date', withTimezone: true })
44
+ .notNull()
45
+ .defaultNow(),
46
+ updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true })
47
+ .notNull()
48
+ .defaultNow(),
49
+ });
50
+
51
+ export type ${names.entityPascal} = typeof ${names.camel}.$inferSelect;
52
+ export type New${names.entityPascal} = typeof ${names.camel}.$inferInsert;
53
+ `;
54
+ }
55
+
56
+ function zodFile(names: ModuleNames): string {
57
+ return `import {
58
+ createInsertSchema,
59
+ createSelectSchema,
60
+ createUpdateSchema,
61
+ } from 'drizzle-zod';
62
+ import { ${names.camel} } from './${names.module}.table';
63
+
64
+ export const select${names.entityPascal}Schema = createSelectSchema(${names.camel});
65
+ export const insert${names.entityPascal}Schema = createInsertSchema(${names.camel});
66
+ export const update${names.entityPascal}Schema = createUpdateSchema(${names.camel});
67
+ `;
68
+ }
69
+
70
+ function barrelFile(names: ModuleNames): string {
71
+ return `export * from './${names.module}.table';
72
+ export * from './${names.module}.zod';
73
+ `;
74
+ }
75
+
76
+ function specFile(names: ModuleNames, fields: Field[]): string {
77
+ const columnAssertions = ['id', ...fields.map((field) => field.name)]
78
+ .map((name) => ` expect(columns).toContain('${name}');`)
79
+ .join('\n');
80
+ const dateField = fields.find(isDateField);
81
+
82
+ return `import { getTableColumns, getTableName } from 'drizzle-orm';
83
+ import { describe, expect, it } from 'vitest';
84
+ import {
85
+ ${names.camel},
86
+ insert${names.entityPascal}Schema,
87
+ select${names.entityPascal}Schema,
88
+ } from './index';
89
+
90
+ describe('${names.camel} schema', () => {
91
+ it('maps to the ${names.tableName} table with every declared column', () => {
92
+ const columns = Object.keys(getTableColumns(${names.camel}));
93
+
94
+ expect(getTableName(${names.camel})).toBe('${names.tableName}');
95
+ ${columnAssertions}
96
+ expect(columns).toContain('createdAt');
97
+ expect(columns).toContain('updatedAt');
98
+ });
99
+
100
+ it('derives a select schema that accepts a full row', () => {
101
+ const parsed = select${names.entityPascal}Schema.parse(${rowLiteral(names, fields)});
102
+
103
+ expect(parsed.id).toBe('${SAMPLE_UUID}');
104
+ });
105
+
106
+ it('derives an insert schema that rejects a row missing a required column', () => {
107
+ const result = insert${names.entityPascal}Schema.safeParse({});
108
+
109
+ expect(result.success).toBe(${fields.every((field) => field.nullable) ? 'true' : 'false'});
110
+ });${
111
+ dateField
112
+ ? `
113
+
114
+ it('reflects a timestamp column as a Date on the server side', () => {
115
+ const parsed = select${names.entityPascal}Schema.parse(${rowLiteral(names, fields)});
116
+
117
+ expect(parsed.${dateField.name}).toBeInstanceOf(Date);
118
+ });`
119
+ : ''
120
+ }
121
+ });
122
+ `;
123
+ }
124
+
125
+ const SAMPLE_UUID = '00000000-0000-4000-8000-000000000000';
126
+
127
+ function rowLiteral(names: ModuleNames, fields: Field[]): string {
128
+ const entries = [
129
+ `id: '${SAMPLE_UUID}'`,
130
+ ...fields.map((field) => `${field.name}: ${sampleValue(field)}`),
131
+ 'createdAt: new Date()',
132
+ 'updatedAt: new Date()',
133
+ ];
134
+ return `{\n ${entries.join(',\n ')},\n }`;
135
+ }
136
+
137
+ function sampleValue(field: Field): string {
138
+ const byType: Record<string, string> = {
139
+ string: `'sample'`,
140
+ text: `'sample'`,
141
+ boolean: 'false',
142
+ integer: '1',
143
+ timestamp: 'new Date()',
144
+ };
145
+ return byType[field.type] ?? 'null';
146
+ }
147
+
148
+ /**
149
+ * `packages/db/src/schema/index.ts` is a barrel-of-barrels every module's
150
+ * schema layer appends one line to (in scope — see core.yaml). Its shipped
151
+ * body is `export {};`, which is dropped once a real export exists.
152
+ */
153
+ function addSchemaBarrelExport(tree: Tree, module: string) {
154
+ const barrelPath = 'packages/db/src/schema/index.ts';
155
+ const current = tree.read(barrelPath, 'utf-8') ?? '';
156
+ const line = `export * from './${module}/index';`;
157
+ if (current.includes(line)) return;
158
+
159
+ const lines = current
160
+ .split('\n')
161
+ .filter((entry) => entry.trim() !== 'export {};');
162
+ const exportLines = lines
163
+ .filter((entry) => entry.startsWith('export * from'))
164
+ .concat(line)
165
+ .sort();
166
+ const header = lines.filter((entry) => entry.startsWith('//'));
167
+
168
+ tree.write(barrelPath, `${[...header, '', ...exportLines].join('\n')}\n`);
169
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "HedgehogSchemaLayer",
4
+ "title": "Drizzle schema 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": "Comma-separated name:type pairs; a trailing ? marks the column nullable, and string takes an optional length in parentheses (default 255). Types: string, text, boolean, integer, timestamp. Example: title:string(500),done:boolean,dueDate:timestamp?",
16
+ "x-prompt": "Fields (name:type, comma-separated)?"
17
+ }
18
+ },
19
+ "required": ["module", "fields"]
20
+ }