@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,205 @@
|
|
|
1
|
+
import nx from '@nx/eslint-plugin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Locked, shared ESLint flat config — extended by every app/lib in the
|
|
5
|
+
* workspace (via root `eslint.config.mjs`). Encodes Nx module boundaries
|
|
6
|
+
* (tags + depConstraints) as a build-time (`nx lint`) failure. See
|
|
7
|
+
* `.claude/skills/hedgehog-bootstrap` and `.claude/skills/hedgehog-loop`
|
|
8
|
+
* for the discipline this enforces.
|
|
9
|
+
*
|
|
10
|
+
* The constraints below describe the project shape `core.yaml`'s compiled
|
|
11
|
+
* layer sequence actually produces. One layer, `repository`, lands a
|
|
12
|
+
* module's port interface AND its Drizzle adapter in the same lib
|
|
13
|
+
* (`libs/<module>/repository`, tagged `type:adapter`) — there is no
|
|
14
|
+
* separate `type:port` project, so a tag constraint alone cannot say
|
|
15
|
+
* "import the interface, not the concrete class." The
|
|
16
|
+
* `no-restricted-imports` block below carries that half of port
|
|
17
|
+
* discipline; the tag graph carries the rest.
|
|
18
|
+
*
|
|
19
|
+
* Tag reference:
|
|
20
|
+
* apps/api -> scope:api
|
|
21
|
+
* apps/worker -> scope:worker
|
|
22
|
+
* apps/web -> scope:web
|
|
23
|
+
* apps/mobile -> scope:mobile
|
|
24
|
+
* packages/db -> scope:db, type:adapter
|
|
25
|
+
* packages/contracts -> scope:contracts, type:contract
|
|
26
|
+
* packages/hooks -> scope:hooks, type:hook
|
|
27
|
+
* packages/auth -> scope:auth, type:adapter
|
|
28
|
+
* packages/config -> scope:config, type:util
|
|
29
|
+
* packages/shared -> scope:shared, type:util
|
|
30
|
+
* libs/<module>/repository -> scope:<module>, type:adapter
|
|
31
|
+
* libs/<module>/service -> scope:<module>, type:service
|
|
32
|
+
*/
|
|
33
|
+
export default [
|
|
34
|
+
...nx.configs['flat/base'],
|
|
35
|
+
...nx.configs['flat/typescript'],
|
|
36
|
+
...nx.configs['flat/javascript'],
|
|
37
|
+
{
|
|
38
|
+
ignores: ['**/dist', '**/out-tsc'],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
files: [
|
|
42
|
+
'**/*.ts',
|
|
43
|
+
'**/*.tsx',
|
|
44
|
+
'**/*.js',
|
|
45
|
+
'**/*.jsx',
|
|
46
|
+
'**/*.cjs',
|
|
47
|
+
'**/*.mjs',
|
|
48
|
+
],
|
|
49
|
+
rules: {
|
|
50
|
+
'@nx/enforce-module-boundaries': [
|
|
51
|
+
'error',
|
|
52
|
+
{
|
|
53
|
+
// `false`, not `true`: `packages/contracts` and `packages/hooks`
|
|
54
|
+
// are source-only by this core's design — they have no `build`
|
|
55
|
+
// target and are consumed as TypeScript sources. With this on,
|
|
56
|
+
// `apps/web` (a real Next build target) importing either one
|
|
57
|
+
// fails lint purely for that structural choice, not for any
|
|
58
|
+
// dependency that's actually wrong.
|
|
59
|
+
enforceBuildableLibDependency: false,
|
|
60
|
+
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
|
61
|
+
depConstraints: [
|
|
62
|
+
// domain services reach storage through their own module's
|
|
63
|
+
// repository lib, which holds the port interface alongside the
|
|
64
|
+
// Drizzle adapter (`type:adapter`). Which symbol a service may
|
|
65
|
+
// import out of that lib is the no-restricted-imports rule
|
|
66
|
+
// below, not something a tag can express.
|
|
67
|
+
{
|
|
68
|
+
sourceTag: 'type:service',
|
|
69
|
+
onlyDependOnLibsWithTags: [
|
|
70
|
+
'type:adapter',
|
|
71
|
+
'type:contract',
|
|
72
|
+
'type:util',
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
// adapters (repositories, db client, auth config) depend on
|
|
76
|
+
// packages/db and shared utils — never reach across into
|
|
77
|
+
// another module's service
|
|
78
|
+
{
|
|
79
|
+
sourceTag: 'type:adapter',
|
|
80
|
+
onlyDependOnLibsWithTags: [
|
|
81
|
+
'type:adapter',
|
|
82
|
+
'type:contract',
|
|
83
|
+
'type:util',
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
// the ts-rest/Zod contract is derived from the Drizzle schema
|
|
87
|
+
// (`drizzle-zod`), so it reaches packages/db and nothing else
|
|
88
|
+
{
|
|
89
|
+
sourceTag: 'type:contract',
|
|
90
|
+
onlyDependOnLibsWithTags: ['type:adapter', 'type:util'],
|
|
91
|
+
},
|
|
92
|
+
// TanStack Query hooks are typed by the contract they call
|
|
93
|
+
{
|
|
94
|
+
sourceTag: 'type:hook',
|
|
95
|
+
onlyDependOnLibsWithTags: ['type:contract', 'type:util'],
|
|
96
|
+
},
|
|
97
|
+
// shared utils (packages/config, packages/shared) depend on
|
|
98
|
+
// nothing else in the workspace — they're the floor
|
|
99
|
+
{
|
|
100
|
+
sourceTag: 'type:util',
|
|
101
|
+
onlyDependOnLibsWithTags: ['type:util'],
|
|
102
|
+
},
|
|
103
|
+
// web/mobile never import db or api internals — only contracts +
|
|
104
|
+
// hooks (+ shared util packages like packages/config, same as
|
|
105
|
+
// every other app in the workspace)
|
|
106
|
+
{
|
|
107
|
+
sourceTag: 'scope:web',
|
|
108
|
+
onlyDependOnLibsWithTags: [
|
|
109
|
+
'scope:contracts',
|
|
110
|
+
'scope:hooks',
|
|
111
|
+
'scope:shared',
|
|
112
|
+
'type:util',
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
sourceTag: 'scope:mobile',
|
|
117
|
+
onlyDependOnLibsWithTags: [
|
|
118
|
+
'scope:contracts',
|
|
119
|
+
'scope:hooks',
|
|
120
|
+
'scope:shared',
|
|
121
|
+
'type:util',
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
// worker reaches domain logic the same way api does
|
|
125
|
+
{
|
|
126
|
+
sourceTag: 'scope:worker',
|
|
127
|
+
onlyDependOnLibsWithTags: [
|
|
128
|
+
'type:adapter',
|
|
129
|
+
'type:service',
|
|
130
|
+
'type:contract',
|
|
131
|
+
'type:util',
|
|
132
|
+
'scope:shared',
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
// api is the composition root: its NestJS module is the only
|
|
136
|
+
// place that constructs a module's concrete adapter and binds
|
|
137
|
+
// it to the port's DI token, so it reaches `type:adapter` by
|
|
138
|
+
// necessity. It also serves the ts-rest contract, hence
|
|
139
|
+
// `type:contract`. What it may NOT do is inject an adapter
|
|
140
|
+
// into a controller — that's the no-restricted-imports rule.
|
|
141
|
+
{
|
|
142
|
+
sourceTag: 'scope:api',
|
|
143
|
+
onlyDependOnLibsWithTags: [
|
|
144
|
+
'type:adapter',
|
|
145
|
+
'type:service',
|
|
146
|
+
'type:contract',
|
|
147
|
+
'type:util',
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
// Port discipline, the half tags can't express. A module's port
|
|
157
|
+
// interface and its Drizzle adapter live in the same lib, so
|
|
158
|
+
// `type:service -> type:adapter` has to be allowed above; this is what
|
|
159
|
+
// stops a service from importing the concrete adapter through it.
|
|
160
|
+
// Services see the port only. Deep paths are named rather than the
|
|
161
|
+
// package entry point, because the entry point is also how the port
|
|
162
|
+
// itself is imported.
|
|
163
|
+
files: ['libs/*/service/**/*.ts'],
|
|
164
|
+
rules: {
|
|
165
|
+
'no-restricted-imports': [
|
|
166
|
+
'error',
|
|
167
|
+
{
|
|
168
|
+
patterns: [
|
|
169
|
+
{
|
|
170
|
+
group: ['**/*.adapter', '**/*.adapter.js', '**/*.adapter.ts'],
|
|
171
|
+
message:
|
|
172
|
+
'A service depends on its module\'s port interface, never the concrete adapter. Import the port from the repository lib\'s entry point and let apps/api bind the adapter to its DI token.',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
group: ['@app/db', '@app/db/*', 'db', 'db/*', 'drizzle-orm', 'drizzle-orm/*'],
|
|
176
|
+
message:
|
|
177
|
+
'A service never talks to the database directly — that is the repository adapter\'s job, reached through the port.',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
// The same rule one layer up, minus the composition root. A NestJS
|
|
186
|
+
// module file (`*.module.ts`) is where the adapter is constructed and
|
|
187
|
+
// bound, so it is exempt; a controller injecting an adapter is not.
|
|
188
|
+
files: ['apps/api/**/*.ts'],
|
|
189
|
+
ignores: ['apps/api/**/*.module.ts'],
|
|
190
|
+
rules: {
|
|
191
|
+
'no-restricted-imports': [
|
|
192
|
+
'error',
|
|
193
|
+
{
|
|
194
|
+
patterns: [
|
|
195
|
+
{
|
|
196
|
+
group: ['**/*.adapter', '**/*.adapter.js', '**/*.adapter.ts'],
|
|
197
|
+
message:
|
|
198
|
+
'Only a NestJS module (*.module.ts) constructs a concrete adapter. A controller depends on the service, or on the port token the module bound.',
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "config",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./prettier": "./prettier.js",
|
|
15
|
+
"./eslint-base": "./eslint-base.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"zod": "^4.0.0",
|
|
20
|
+
"@ts-rest/core": "3.53.0-rc.1"
|
|
21
|
+
},
|
|
22
|
+
"nx": {
|
|
23
|
+
"tags": [
|
|
24
|
+
"scope:config",
|
|
25
|
+
"type:util"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locked, shared Prettier config — extended by every app/lib.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately does NOT include `prettier-plugin-tailwindcss`: the plugin
|
|
5
|
+
* parses every file Prettier touches (not just files with Tailwind
|
|
6
|
+
* classes) and throws on any file when no Tailwind config is resolvable
|
|
7
|
+
* yet — true for every project from this step through `apps/web`'s own
|
|
8
|
+
* setup. Add the plugin to `apps/web`'s own Prettier config (extending
|
|
9
|
+
* this base) once Tailwind exists there, not here.
|
|
10
|
+
*/
|
|
11
|
+
export default {
|
|
12
|
+
singleQuote: true,
|
|
13
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { envSchema } from './env.schema';
|
|
2
|
+
|
|
3
|
+
describe('envSchema', () => {
|
|
4
|
+
const valid = {
|
|
5
|
+
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/app',
|
|
6
|
+
NODE_ENV: 'development',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
it('accepts a fully valid env', () => {
|
|
10
|
+
const result = envSchema.safeParse(valid);
|
|
11
|
+
expect(result.success).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('rejects a missing DATABASE_URL', () => {
|
|
15
|
+
const rest: Record<string, string> = { ...valid };
|
|
16
|
+
delete rest['DATABASE_URL'];
|
|
17
|
+
const result = envSchema.safeParse(rest);
|
|
18
|
+
expect(result.success).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects an invalid NODE_ENV value', () => {
|
|
22
|
+
const result = envSchema.safeParse({ ...valid, NODE_ENV: 'staging' });
|
|
23
|
+
expect(result.success).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('defaults WEB_ORIGIN when absent', () => {
|
|
27
|
+
const result = envSchema.safeParse(valid);
|
|
28
|
+
expect(result.success).toBe(true);
|
|
29
|
+
if (result.success) {
|
|
30
|
+
expect(result.data.WEB_ORIGIN).toBe('http://localhost:3000');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects a non-URL WEB_ORIGIN', () => {
|
|
35
|
+
const result = envSchema.safeParse({ ...valid, WEB_ORIGIN: 'not-a-url' });
|
|
36
|
+
expect(result.success).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const envSchema = z.object({
|
|
4
|
+
DATABASE_URL: z.string().url(),
|
|
5
|
+
NODE_ENV: z.enum(['development', 'test', 'production']),
|
|
6
|
+
WEB_ORIGIN: z.string().url().default('http://localhost:3000'),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type Env = z.infer<typeof envSchema>;
|
|
10
|
+
|
|
11
|
+
export function loadEnv(): Env {
|
|
12
|
+
const parsed = envSchema.safeParse(process.env);
|
|
13
|
+
if (!parsed.success) {
|
|
14
|
+
console.error(parsed.error.format());
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
return parsed.data;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './env.schema';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
|
|
7
|
+
"emitDeclarationOnly": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"types": ["node"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"references": [],
|
|
13
|
+
"exclude": [
|
|
14
|
+
"vite.config.ts",
|
|
15
|
+
"vite.config.mts",
|
|
16
|
+
"vitest.config.ts",
|
|
17
|
+
"vitest.config.mts",
|
|
18
|
+
"src/**/*.test.ts",
|
|
19
|
+
"src/**/*.spec.ts",
|
|
20
|
+
"src/**/*.test.tsx",
|
|
21
|
+
"src/**/*.spec.tsx",
|
|
22
|
+
"src/**/*.test.js",
|
|
23
|
+
"src/**/*.spec.js",
|
|
24
|
+
"src/**/*.test.jsx",
|
|
25
|
+
"src/**/*.spec.jsx"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./out-tsc/vitest",
|
|
5
|
+
"types": [
|
|
6
|
+
"vitest/globals",
|
|
7
|
+
"vitest/importMeta",
|
|
8
|
+
"vite/client",
|
|
9
|
+
"node",
|
|
10
|
+
"vitest"
|
|
11
|
+
],
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": [
|
|
15
|
+
"vite.config.ts",
|
|
16
|
+
"vite.config.mts",
|
|
17
|
+
"vitest.config.ts",
|
|
18
|
+
"vitest.config.mts",
|
|
19
|
+
"src/**/*.test.ts",
|
|
20
|
+
"src/**/*.spec.ts",
|
|
21
|
+
"src/**/*.test.tsx",
|
|
22
|
+
"src/**/*.spec.tsx",
|
|
23
|
+
"src/**/*.test.js",
|
|
24
|
+
"src/**/*.spec.js",
|
|
25
|
+
"src/**/*.test.jsx",
|
|
26
|
+
"src/**/*.spec.jsx",
|
|
27
|
+
"src/**/*.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"references": [
|
|
30
|
+
{
|
|
31
|
+
"path": "./tsconfig.lib.json"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig(() => ({
|
|
4
|
+
root: __dirname,
|
|
5
|
+
cacheDir: '../../node_modules/.vite/packages/config',
|
|
6
|
+
test: {
|
|
7
|
+
name: 'config',
|
|
8
|
+
watch: false,
|
|
9
|
+
globals: true,
|
|
10
|
+
environment: 'node',
|
|
11
|
+
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
|
12
|
+
reporters: ['default'],
|
|
13
|
+
coverage: {
|
|
14
|
+
reportsDirectory: './test-output/vitest/coverage',
|
|
15
|
+
provider: 'v8' as const,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "db",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"drizzle-orm": "^0.45.2",
|
|
18
|
+
"pg": "^8.22.0",
|
|
19
|
+
"config": "workspace:*"
|
|
20
|
+
},
|
|
21
|
+
"nx": {
|
|
22
|
+
"tags": [
|
|
23
|
+
"scope:db",
|
|
24
|
+
"type:adapter"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('db', () => {
|
|
4
|
+
const originalEnv = { ...process.env };
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
process.env.DATABASE_URL =
|
|
9
|
+
'postgres://postgres:postgres@localhost:5432/app';
|
|
10
|
+
process.env.NODE_ENV = 'test';
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
process.env = { ...originalEnv };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('lazily constructs a single shared pool', async () => {
|
|
18
|
+
const { getPool, closeDb } = await import('./db');
|
|
19
|
+
const first = getPool();
|
|
20
|
+
const second = getPool();
|
|
21
|
+
expect(first).toBe(second);
|
|
22
|
+
await closeDb();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns a drizzle client bound to the shared pool', async () => {
|
|
26
|
+
const { getDb, closeDb } = await import('./db');
|
|
27
|
+
const db = getDb();
|
|
28
|
+
expect(db).toBeDefined();
|
|
29
|
+
await closeDb();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('exits fast when DATABASE_URL is missing or malformed', async () => {
|
|
33
|
+
process.env.DATABASE_URL = 'not-a-url';
|
|
34
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
35
|
+
throw new Error('process.exit called');
|
|
36
|
+
});
|
|
37
|
+
const errorSpy = vi
|
|
38
|
+
.spyOn(console, 'error')
|
|
39
|
+
.mockImplementation(() => undefined);
|
|
40
|
+
|
|
41
|
+
const { getPool } = await import('./db');
|
|
42
|
+
expect(() => getPool()).toThrow('process.exit called');
|
|
43
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
44
|
+
|
|
45
|
+
exitSpy.mockRestore();
|
|
46
|
+
errorSpy.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
|
+
import { loadEnv } from 'config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared Drizzle client (Postgres via `pg`). Reads `DATABASE_URL` through
|
|
7
|
+
* `loadEnv()` — boot fails fast on a missing/malformed connection string
|
|
8
|
+
* rather than surfacing as a runtime connection error.
|
|
9
|
+
*
|
|
10
|
+
* No domain schema is registered here — each module's own schema lives in
|
|
11
|
+
* `libs/<module>/repository` (Phase A) and is passed in via `withSchema`
|
|
12
|
+
* (or attached at the call site), keeping this package schema-agnostic
|
|
13
|
+
* infra rather than a place domain modules reach into directly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let pool: Pool | undefined;
|
|
17
|
+
let client: NodePgDatabase | undefined;
|
|
18
|
+
|
|
19
|
+
export function getPool(): Pool {
|
|
20
|
+
if (!pool) {
|
|
21
|
+
const env = loadEnv();
|
|
22
|
+
pool = new Pool({
|
|
23
|
+
connectionString: env.DATABASE_URL,
|
|
24
|
+
// A wedged connection surfaces as a query error rather than a hang.
|
|
25
|
+
connectionTimeoutMillis: 10_000,
|
|
26
|
+
idleTimeoutMillis: 30_000,
|
|
27
|
+
});
|
|
28
|
+
// `pg` emits 'error' on the pool when an *idle* client's connection dies
|
|
29
|
+
// (Postgres restart, failover, laptop sleep). Node terminates the process
|
|
30
|
+
// on an unhandled 'error' event, so without this listener a routine
|
|
31
|
+
// database blip crashes the API. `pg` has already discarded the bad
|
|
32
|
+
// client by the time this fires: the next query opens a fresh connection,
|
|
33
|
+
// so logging and swallowing is the recovery.
|
|
34
|
+
pool.on('error', (err) => {
|
|
35
|
+
console.error('idle postgres client error', err);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return pool;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getDb(): NodePgDatabase {
|
|
42
|
+
if (!client) {
|
|
43
|
+
client = drizzle(getPool());
|
|
44
|
+
}
|
|
45
|
+
return client;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function closeDb(): Promise<void> {
|
|
49
|
+
if (pool) {
|
|
50
|
+
await pool.end();
|
|
51
|
+
pool = undefined;
|
|
52
|
+
client = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
|
|
7
|
+
"emitDeclarationOnly": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"types": ["node"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"references": [{ "path": "../config/tsconfig.lib.json" }],
|
|
13
|
+
"exclude": [
|
|
14
|
+
"vite.config.ts",
|
|
15
|
+
"vite.config.mts",
|
|
16
|
+
"vitest.config.ts",
|
|
17
|
+
"vitest.config.mts",
|
|
18
|
+
"src/**/*.test.ts",
|
|
19
|
+
"src/**/*.spec.ts",
|
|
20
|
+
"src/**/*.test.tsx",
|
|
21
|
+
"src/**/*.spec.tsx",
|
|
22
|
+
"src/**/*.test.js",
|
|
23
|
+
"src/**/*.spec.js",
|
|
24
|
+
"src/**/*.test.jsx",
|
|
25
|
+
"src/**/*.spec.jsx"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./out-tsc/vitest",
|
|
5
|
+
"types": [
|
|
6
|
+
"vitest/globals",
|
|
7
|
+
"vitest/importMeta",
|
|
8
|
+
"vite/client",
|
|
9
|
+
"node",
|
|
10
|
+
"vitest"
|
|
11
|
+
],
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": [
|
|
15
|
+
"vite.config.ts",
|
|
16
|
+
"vite.config.mts",
|
|
17
|
+
"vitest.config.ts",
|
|
18
|
+
"vitest.config.mts",
|
|
19
|
+
"src/**/*.test.ts",
|
|
20
|
+
"src/**/*.spec.ts",
|
|
21
|
+
"src/**/*.test.tsx",
|
|
22
|
+
"src/**/*.spec.tsx",
|
|
23
|
+
"src/**/*.test.js",
|
|
24
|
+
"src/**/*.spec.js",
|
|
25
|
+
"src/**/*.test.jsx",
|
|
26
|
+
"src/**/*.spec.jsx",
|
|
27
|
+
"src/**/*.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"references": [
|
|
30
|
+
{
|
|
31
|
+
"path": "./tsconfig.lib.json"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig(() => ({
|
|
4
|
+
root: __dirname,
|
|
5
|
+
cacheDir: '../../node_modules/.vite/packages/db',
|
|
6
|
+
test: {
|
|
7
|
+
name: 'db',
|
|
8
|
+
watch: false,
|
|
9
|
+
globals: true,
|
|
10
|
+
environment: 'node',
|
|
11
|
+
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
|
12
|
+
reporters: ['default'],
|
|
13
|
+
coverage: {
|
|
14
|
+
reportsDirectory: './test-output/vitest/coverage',
|
|
15
|
+
provider: 'v8' as const,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
}));
|