feat(F-002): database foundation with migrations and dev compose
- node-pg-migrate + pg: baseline migration (extensions, app_meta) with working down - src/infrastructure/db fail-fast pool and typed query helper - docker-compose: postgres:16-alpine + redis:7-alpine with one-command up - table naming convention <module>_<table> documented in README - integration tests (6) against real PostgreSQL; strict identifier validation for test DDL after security-gate hardening round - deps justified in spec/tech.md; all gates approved; verify.sh green
This commit is contained in:
@@ -87,12 +87,12 @@
|
||||
"Dev PostgreSQL and Redis start with one command",
|
||||
"verify.sh green"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-14",
|
||||
"gates": {
|
||||
"review": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
"review": true,
|
||||
"security": true,
|
||||
"qa": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
4
project/.env.example
Normal file
4
project/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copy to .env for local development. Real .env is gitignored.
|
||||
# Matches docker-compose.yml dev credentials (dev-only, never reuse elsewhere).
|
||||
DATABASE_URL=postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida
|
||||
TEST_DATABASE_URL=postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida_test
|
||||
1
project/.gitignore
vendored
1
project/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
|
||||
@@ -13,12 +13,35 @@ TypeScript + Fastify modular monolith. Simple code, clear modules, small changes
|
||||
npm install # install dependencies
|
||||
npm run build # compile to dist/
|
||||
npm start # run compiled server (PORT, HOST env vars)
|
||||
npm test # vitest unit/integration tests
|
||||
npm test # vitest unit tests (no database needed)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint + prettier check
|
||||
npm run lint:boundaries # module boundary check
|
||||
```
|
||||
|
||||
## Database (local dev)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # once
|
||||
npm run docker:up # start PostgreSQL 16 + Redis 7
|
||||
npm run db:up # apply migrations
|
||||
npm run db:status # list applied migrations
|
||||
npm run db:down # revert last migration
|
||||
npm run test:integration # integration tests (need TEST_DATABASE_URL from .env)
|
||||
npm run docker:down # stop services (add -v to wipe volumes)
|
||||
```
|
||||
|
||||
### Table naming convention
|
||||
|
||||
```text
|
||||
<module>_<table> e.g. catalog_products, inventory_stock, orders_orders
|
||||
```
|
||||
|
||||
- Every table is prefixed with its owning module.
|
||||
- A module never queries tables without its own prefix; data flows through module interfaces.
|
||||
- Migrations are immutable once merged: fixes ship as new migrations.
|
||||
- No schema change without migration.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
|
||||
30
project/docker-compose.yml
Normal file
30
project/docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: mdv-dev-postgres
|
||||
environment:
|
||||
# Dev-only credentials. Public by design, never reuse them outside local dev.
|
||||
POSTGRES_USER: mdv
|
||||
POSTGRES_PASSWORD: mdv_dev_only
|
||||
POSTGRES_DB: mercadodevida
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- mdv_pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U mdv -d mercadodevida']
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mdv-dev-redis
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- mdv_redis_data:/data
|
||||
|
||||
volumes:
|
||||
mdv_pg_data:
|
||||
mdv_redis_data:
|
||||
25
project/migrations/001_baseline.js
Normal file
25
project/migrations/001_baseline.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Baseline migration: foundation objects only.
|
||||
* Business tables arrive with their owning modules using the
|
||||
* <module>_<table> naming convention.
|
||||
*/
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
export const up = (pgm) => {
|
||||
pgm.sql('CREATE EXTENSION IF NOT EXISTS citext');
|
||||
pgm.sql('CREATE EXTENSION IF NOT EXISTS pgcrypto');
|
||||
pgm.sql(`
|
||||
CREATE TABLE app_meta (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
export const down = (pgm) => {
|
||||
pgm.sql('DROP TABLE IF EXISTS app_meta');
|
||||
pgm.sql('DROP EXTENSION IF EXISTS pgcrypto');
|
||||
pgm.sql('DROP EXTENSION IF EXISTS citext');
|
||||
};
|
||||
451
project/package-lock.json
generated
451
project/package-lock.json
generated
@@ -8,10 +8,13 @@
|
||||
"name": "mercadodevida-backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"fastify": "^5.2.0"
|
||||
"fastify": "^5.2.0",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
"pg": "^8.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/pg": "^8.21.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
@@ -1236,6 +1239,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz",
|
||||
"integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
||||
@@ -1734,6 +1759,18 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -1878,6 +1915,20 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1977,6 +2028,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
@@ -2026,6 +2083,15 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -2482,6 +2548,44 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -2495,6 +2599,42 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
@@ -2594,6 +2734,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
@@ -2755,6 +2904,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2778,6 +2936,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2811,6 +2978,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pg-migrate": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-9.0.0.tgz",
|
||||
"integrity": "sha512-lp5+UZx1KKgOz/y0h6BYGPuJ5wVlZTgiBPGXCGoX6Bs6X9LtiIhrI4V2yW4mPwnJBfptVq0KtLQtXHcoEKOyig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glob": "~13.0.0",
|
||||
"jiti": "~2.7.0",
|
||||
"yargs": "~18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-pg-migrate": "bin/node-pg-migrate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/pg": ">=6.0.0 <9.0.0",
|
||||
"pg": ">=4.3.0 <9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/pg": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
@@ -2903,6 +3096,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -2920,6 +3129,95 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3006,6 +3304,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -3300,6 +3637,38 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -3491,6 +3860,13 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@@ -3715,6 +4091,79 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -13,13 +13,22 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"lint:boundaries": "node scripts/check-module-boundaries.mjs src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:integration": "node --env-file-if-exists=.env node_modules/vitest/vitest.mjs run itest",
|
||||
"docker:up": "docker compose up -d --wait",
|
||||
"docker:down": "docker compose down",
|
||||
"db:up": "node --env-file-if-exists=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js up --migrations-dir migrations",
|
||||
"db:down": "node --env-file-if-exists=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js down --migrations-dir migrations",
|
||||
"db:status": "node --env-file-if-exists=.env scripts/db-status.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^5.2.0"
|
||||
"fastify": "^5.2.0",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
"pg": "^8.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/pg": "^8.21.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
|
||||
31
project/scripts/db-status.mjs
Normal file
31
project/scripts/db-status.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* db:status — list applied migrations from the pg_migrations tracking table.
|
||||
* Usage: DATABASE_URL=... node scripts/db-status.mjs
|
||||
*/
|
||||
|
||||
import pg from 'pg';
|
||||
import process from 'node:process';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
console.error('DATABASE_URL is required');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({ connectionString, max: 1 });
|
||||
try {
|
||||
const result = await pool.query('SELECT id, name, run_on FROM pgmigrations ORDER BY id');
|
||||
if (result.rows.length === 0) {
|
||||
console.log('No migrations applied.');
|
||||
} else {
|
||||
for (const row of result.rows) {
|
||||
console.log(`${row.id}\t${row.name}\t(applied ${row.run_on})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to read migration status', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
24
project/src/infrastructure/db/pool.ts
Normal file
24
project/src/infrastructure/db/pool.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import pg from 'pg';
|
||||
|
||||
/**
|
||||
* Create a connection pool from environment.
|
||||
* Fail fast and loud when configuration is missing: no silent defaults.
|
||||
*/
|
||||
export function createPoolFromEnv(env: NodeJS.ProcessEnv = process.env): pg.Pool {
|
||||
const connectionString = env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
'DATABASE_URL is required. Copy .env.example to .env and start docker compose.',
|
||||
);
|
||||
}
|
||||
return new pg.Pool({ connectionString, max: 10 });
|
||||
}
|
||||
|
||||
/** Thin typed query helper. Modules get data through repositories, not raw pools. */
|
||||
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
pool: pg.Pool,
|
||||
text: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
return pool.query<T>(text, params);
|
||||
}
|
||||
29
project/src/infrastructure/db/tests/db-test-support.test.ts
Normal file
29
project/src/infrastructure/db/tests/db-test-support.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { adminUrlFrom, dbNameFromUrl } from './db-test-support.js';
|
||||
|
||||
describe('db-test-support pure helpers', () => {
|
||||
it('extracts the database name from a url', () => {
|
||||
expect(dbNameFromUrl('postgres://user:pass@localhost:5432/mdv_test')).toBe('mdv_test');
|
||||
});
|
||||
|
||||
it('rejects database names that are not safe identifiers', () => {
|
||||
expect(() => dbNameFromUrl('postgres://u:p@localhost:5432/bad"name')).toThrowError(
|
||||
/not a safe identifier/,
|
||||
);
|
||||
expect(() => dbNameFromUrl('postgres://u:p@localhost:5432/semi;colon')).toThrowError(
|
||||
/not a safe identifier/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects urls without database name', () => {
|
||||
expect(() => dbNameFromUrl('postgres://u:p@localhost:5432/')).toThrowError(
|
||||
/must include a database name/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites a url to the admin postgres database', () => {
|
||||
expect(adminUrlFrom('postgres://u:p@localhost:5432/mdv_test')).toBe(
|
||||
'postgres://u:p@localhost:5432/postgres',
|
||||
);
|
||||
});
|
||||
});
|
||||
66
project/src/infrastructure/db/tests/db-test-support.ts
Normal file
66
project/src/infrastructure/db/tests/db-test-support.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Test support for database integration tests.
|
||||
* Explicit helpers only: no magic fixtures, no hidden state.
|
||||
*/
|
||||
import pg from 'pg';
|
||||
import { runner } from 'node-pg-migrate';
|
||||
|
||||
/** Run project migrations programmatically with explicit, boring defaults. */
|
||||
export async function runMigrations(databaseUrl: string, direction: 'up' | 'down'): Promise<void> {
|
||||
await runner({
|
||||
databaseUrl,
|
||||
dir: 'migrations',
|
||||
direction,
|
||||
migrationsTable: 'pgmigrations',
|
||||
verbose: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTestDbUrl(): string {
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error('TEST_DATABASE_URL is required for integration tests');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function dbNameFromUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
const name = parsed.pathname.replace(/^\//, '');
|
||||
if (!name) {
|
||||
throw new Error(`TEST_DATABASE_URL must include a database name: ${url}`);
|
||||
}
|
||||
// Strict identifier validation: the name is interpolated into DDL below.
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
throw new Error(`TEST_DATABASE_URL database name is not a safe identifier: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function adminUrlFrom(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
parsed.pathname = '/postgres';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/** Drop and recreate the test database so every run starts fresh. */
|
||||
export async function recreateDatabase(url: string): Promise<void> {
|
||||
const dbName = dbNameFromUrl(url);
|
||||
const admin = new pg.Client({ connectionString: adminUrlFrom(url) });
|
||||
await admin.connect();
|
||||
try {
|
||||
// dbName is validated as a strict identifier in dbNameFromUrl before any DDL use.
|
||||
await admin.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
|
||||
await admin.query(`CREATE DATABASE "${dbName}"`);
|
||||
} finally {
|
||||
await admin.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function tableExists(pool: pg.Pool, table: string): Promise<boolean> {
|
||||
const result = await pool.query(
|
||||
'SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2',
|
||||
['public', table],
|
||||
);
|
||||
return result.rowCount !== null && result.rowCount > 0;
|
||||
}
|
||||
37
project/src/infrastructure/db/tests/migrations.itest.ts
Normal file
37
project/src/infrastructure/db/tests/migrations.itest.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getTestDbUrl, recreateDatabase, runMigrations, tableExists } from './db-test-support.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
describe.skipIf(!hasDb)('migrations', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
pool = new pg.Pool({ connectionString: url, max: 2 });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('fresh up creates the baseline schema', async () => {
|
||||
await runMigrations(url, 'up');
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(true);
|
||||
});
|
||||
|
||||
it('second up is a no-op', async () => {
|
||||
const before = await pool.query('SELECT count(*)::int AS n FROM pgmigrations');
|
||||
await runMigrations(url, 'up');
|
||||
const after = await pool.query('SELECT count(*)::int AS n FROM pgmigrations');
|
||||
expect(after.rows[0]?.n).toBe(before.rows[0]?.n);
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(true);
|
||||
});
|
||||
|
||||
it('down rolls back the baseline schema cleanly', async () => {
|
||||
await runMigrations(url, 'down');
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(false);
|
||||
});
|
||||
});
|
||||
41
project/src/infrastructure/db/tests/pool.itest.ts
Normal file
41
project/src/infrastructure/db/tests/pool.itest.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createPoolFromEnv, query } from '../pool.js';
|
||||
import { getTestDbUrl, recreateDatabase, runMigrations } from './db-test-support.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
describe.skipIf(!hasDb)('db pool', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPoolFromEnv({ DATABASE_URL: url } as NodeJS.ProcessEnv);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('connects and runs a trivial query', async () => {
|
||||
const result = await query(pool, 'SELECT 1 AS ok');
|
||||
expect(result.rows[0]?.ok).toBe(1);
|
||||
});
|
||||
|
||||
it('supports a full roundtrip on app_meta via the query helper', async () => {
|
||||
await query(pool, 'INSERT INTO app_meta (key, value) VALUES ($1, $2)', ['k1', 'v1']);
|
||||
const read = await query(pool, 'SELECT value FROM app_meta WHERE key = $1', ['k1']);
|
||||
expect(read.rows[0]?.value).toBe('v1');
|
||||
await query(pool, 'DELETE FROM app_meta WHERE key = $1', ['k1']);
|
||||
const gone = await query(pool, 'SELECT value FROM app_meta WHERE key = $1', ['k1']);
|
||||
expect(gone.rowCount).toBe(0);
|
||||
});
|
||||
|
||||
it('fails fast when DATABASE_URL is missing', () => {
|
||||
expect(() => createPoolFromEnv({} as NodeJS.ProcessEnv)).toThrowError(
|
||||
/DATABASE_URL is required/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'scripts/tests/**/*.test.ts'],
|
||||
// Integration tests recreate a shared test database; parallel files would race.
|
||||
fileParallelism: false,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.itest.ts', 'scripts/tests/**/*.test.ts'],
|
||||
exclude: ['node_modules/**', 'dist/**', 'scripts/tests/fixtures/**'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
| vitest | ^3 | Tests unit/integración TS-native (dev) |
|
||||
| eslint + @eslint/js + typescript-eslint + eslint-config-prettier | ^9 / ^8 / ^10 | Linting estándar (dev) |
|
||||
| prettier | ^3 | Formato consistente (dev) |
|
||||
| pg | ^8 | Driver PostgreSQL estándar; única forma de hablar con la DB (F-002) |
|
||||
| node-pg-migrate | ^8 | Migraciones SQL up/down trackeadas en DB; elegida sobre runner propio (no reinvención) y sobre Flyway/golang-migrate (toolchains ajenos a Node) (F-002) |
|
||||
| @types/pg | ^8 | Tipos para pg (dev) (F-002) |
|
||||
|
||||
Regla: toda dependencia nueva debe agregarse a esta tabla con justificación en el ticket que la introduce.
|
||||
|
||||
|
||||
64
specs/F-002-database-foundation/DESIGN.md
Normal file
64
specs/F-002-database-foundation/DESIGN.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# DESIGN — F-002 Database foundation with module-owned schemas
|
||||
|
||||
## Affected modules
|
||||
- `src/infrastructure/db` (new): pool factory + migration runner entry.
|
||||
|
||||
## Modules touched
|
||||
- `src/infrastructure/db`
|
||||
- `project/migrations/` (new folder, SQL files)
|
||||
- `project/docker-compose.yml` (new)
|
||||
|
||||
## Modules NOT touched
|
||||
- All business modules. No HTTP API change. No shared/ change.
|
||||
|
||||
## New interfaces
|
||||
- `src/infrastructure/db/pool.ts`: `createPoolFromEnv(): Pool` (fail fast if `DATABASE_URL` missing), `query(text, params)` helper typed over pg.
|
||||
- npm scripts: `db:up`, `db:down`, `db:status`, `docker:up`, `docker:down`, `test:integration`.
|
||||
|
||||
## API changes
|
||||
- None.
|
||||
|
||||
## Database changes
|
||||
- Baseline migration `001_baseline.js` (node-pg-migrate, pgm.sql):
|
||||
- `CREATE EXTENSION IF NOT EXISTS citext;`
|
||||
- `CREATE EXTENSION IF NOT EXISTS pgcrypto;`
|
||||
- `CREATE TABLE app_meta (key text PRIMARY KEY, value text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now());`
|
||||
- Down: drop table, drop extensions.
|
||||
- Migration version tracking table owned by the migration tool.
|
||||
|
||||
## Naming convention (documented rule)
|
||||
```text
|
||||
<module>_<table> e.g. catalog_products, inventory_stock, orders_orders
|
||||
<module>_<table>_id_seq sequences owned by their table
|
||||
```
|
||||
- A module never queries tables without its own prefix.
|
||||
- Migrations are immutable once merged; fixes ship as new migrations.
|
||||
|
||||
## Events
|
||||
- None.
|
||||
|
||||
## External integrations
|
||||
- Docker Compose: postgres:16-alpine (port 5432), redis:7-alpine (port 6379), named volumes.
|
||||
|
||||
## Cache changes
|
||||
- None (Redis present but unused until cache ticket).
|
||||
|
||||
## Security considerations
|
||||
- Compose credentials are dev-only and public by design; documented as such.
|
||||
- `.env.example` committed, real `.env` gitignored.
|
||||
- No secrets in migration files.
|
||||
|
||||
## Toolchain decisions
|
||||
- Migration tool: **node-pg-migrate** (pure npm dependency, battle-tested, SQL-first via `pgm.sql`, supports up/down and dry-run). Rejected: hand-rolled runner (reinvention), Flyway/golang-migrate (foreign toolchains for a Node monolith).
|
||||
- Driver: **pg** (standard).
|
||||
- Env loading: Node 22 `--env-file` flag; no dotenv dependency.
|
||||
|
||||
## Test strategy
|
||||
- Integration tests (`*.itest.ts`) run only when `TEST_DATABASE_URL` is set: `describe.skipIf` — explicit, no magic.
|
||||
- They verify: fresh up creates schema; second up is no-op; down reverts cleanly; `app_meta` usable via pool helper.
|
||||
|
||||
## Migration strategy
|
||||
- Greenfield; compose starts empty volume, migrations run from zero.
|
||||
|
||||
## Rollback strategy
|
||||
- `db:down` reverts last migration; compose volumes can be removed with `docker compose down -v`.
|
||||
58
specs/F-002-database-foundation/SPEC.md
Normal file
58
specs/F-002-database-foundation/SPEC.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# SPEC — F-002 Database foundation with module-owned schemas
|
||||
|
||||
## Problem
|
||||
Modules need PostgreSQL with clear ownership and safe migrations. No schema tooling exists yet.
|
||||
|
||||
## Goal
|
||||
Migration tooling, table naming convention per module, and a local dev database (PostgreSQL + Redis) that starts with one command.
|
||||
|
||||
## Non-goals
|
||||
- No business tables yet (they arrive with their modules).
|
||||
- No Redis usage beyond making the service available for future tickets.
|
||||
- No production deployment concerns.
|
||||
|
||||
## User story
|
||||
As a developer, I can run one command to get PostgreSQL + Redis locally, apply migrations forward and backward deterministically, and every future module knows exactly how to name and own its tables.
|
||||
|
||||
## Functional requirements
|
||||
1. SQL migrations run through a deterministic tool, tracked in the database, ordered, idempotent per version.
|
||||
2. Migrations support up and down.
|
||||
3. Naming convention `<module>_<table>` is documented and visible in the baseline migration.
|
||||
4. `docker-compose.yml` provides PostgreSQL 16 and Redis 7 with one command.
|
||||
5. A typed DB access point lives in `src/infrastructure/db/` (pool creation from env, fail fast on missing config).
|
||||
6. Baseline migration: enable extensions + `app_meta` key/value table (foundation-only, not business).
|
||||
|
||||
## Business rules
|
||||
- No schema change without migration.
|
||||
- Modules own tables by prefix; cross-module table access is forbidden (enforced later at module API level, documented now).
|
||||
|
||||
## Inputs
|
||||
- `DATABASE_URL` (runtime), `TEST_DATABASE_URL` (integration tests).
|
||||
|
||||
## Outputs
|
||||
- Migration CLI exit codes 0/1, log lines per applied/reverted migration.
|
||||
|
||||
## Edge cases
|
||||
- Re-running `migrate up` on an up-to-date DB is a no-op.
|
||||
- `migrate down` reverts exactly the last applied migration.
|
||||
- Missing `DATABASE_URL` → clear error, non-zero exit, no partial state.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Given a fresh database, When migrations run, Then schema is created and repeating the run is a no-op.
|
||||
2. Given applied migrations, When down runs, Then schema rolls back cleanly.
|
||||
3. Table naming convention documented and enforced (documented rule + exemplar).
|
||||
4. Dev PostgreSQL and Redis start with one command.
|
||||
5. `verify.sh` green.
|
||||
|
||||
## Dependencies
|
||||
- F-001 (project skeleton).
|
||||
|
||||
## Security implications
|
||||
- Dev credentials live only in docker-compose dev file and `.env.example`; never real secrets in repo.
|
||||
- DB user for tests should be dedicated (documented).
|
||||
|
||||
## SEO implications
|
||||
- None.
|
||||
|
||||
## Performance implications
|
||||
- Pool defaults conservative (max 10); no caching layer yet.
|
||||
10
specs/F-002-database-foundation/TASKS.md
Normal file
10
specs/F-002-database-foundation/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# TASKS — F-002 Database foundation with module-owned schemas
|
||||
|
||||
- TASK-001 Add deps: pg, node-pg-migrate (+ @types/pg dev); justify in spec/tech.md
|
||||
- TASK-002 Add `project/docker-compose.yml` (postgres:16-alpine + redis:7-alpine, volumes, dev creds) and `.env.example`
|
||||
- TASK-003 Add baseline migration `migrations/001_baseline.js` (node-pg-migrate; extensions + app_meta via pgm.sql, with down)
|
||||
- TASK-004 Add `src/infrastructure/db/pool.ts` (createPoolFromEnv fail-fast + query helper)
|
||||
- TASK-005 Wire npm scripts: db:up, db:down, db:status, docker:up, docker:down, test:integration
|
||||
- TASK-006 Integration tests: fresh up / no-op rerun / down rollback / pool helper roundtrip (skipIf no TEST_DATABASE_URL)
|
||||
- TASK-007 Document naming convention in project/README.md
|
||||
- TASK-008 Run full verification and write implementer evidence
|
||||
25
specs/F-002-database-foundation/TESTS.md
Normal file
25
specs/F-002-database-foundation/TESTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# TESTS — F-002 Database foundation with module-owned schemas
|
||||
|
||||
## Integration (Vitest, `*.itest.ts`, run with TEST_DATABASE_URL set)
|
||||
- `migrations.itest.ts`:
|
||||
- fresh database + `migrate up` → `app_meta` exists
|
||||
- second `migrate up` → no-op (no errors, same state)
|
||||
- `migrate down` → `app_meta` gone
|
||||
- `pool.itest.ts`:
|
||||
- `createPoolFromEnv` connects and `SELECT 1` roundtrip
|
||||
- insert/read/delete row in `app_meta` via query helper
|
||||
|
||||
## Manual/CI commands
|
||||
- `npm run docker:up` starts PostgreSQL + Redis with one command
|
||||
- `npm run db:up` on fresh DB exits 0; second run exits 0 with nothing applied
|
||||
- `npm run db:down` exits 0 and drops baseline objects
|
||||
- `npm test` stays green even without TEST_DATABASE_URL (integration tests skip explicitly)
|
||||
|
||||
## Acceptance traceability
|
||||
| Criterion | Evidence |
|
||||
|---|---|
|
||||
| fresh up creates schema, rerun is no-op | migrations.itest.ts + db:up twice in implementer.md |
|
||||
| down rolls back cleanly | migrations.itest.ts + db:down output |
|
||||
| naming convention documented/enforced | README section + baseline migration exemplar |
|
||||
| dev DBs start with one command | docker compose up output |
|
||||
| verify.sh green | qa.json evidence |
|
||||
26
work/artifacts/F-002/architect.md
Normal file
26
work/artifacts/F-002/architect.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Architect — F-002 Database foundation with module-owned schemas
|
||||
|
||||
done -> work/artifacts/F-002/architect.md
|
||||
|
||||
## Decision summary
|
||||
- Migration tool: node-pg-migrate + pg driver. Pure npm deps, boring, up/down support, version table owned by the tool.
|
||||
- Single PostgreSQL instance, module ownership by table prefix (`<module>_<table>`). No per-module PG schemas — simplicity first; prefix rule documented and exemplified.
|
||||
- Baseline migration only ships foundation objects (extensions + `app_meta`). Business tables arrive with their modules.
|
||||
- Dev environment: docker-compose with postgres:16-alpine + redis:7-alpine, one command up.
|
||||
- Env via Node 22 `--env-file`; fail fast when `DATABASE_URL` missing. Integration tests skip explicitly when `TEST_DATABASE_URL` absent — no silent magic.
|
||||
|
||||
## Expected blast radius
|
||||
|
||||
```text
|
||||
EXPECTED BLAST RADIUS
|
||||
|
||||
Modules modified: src/infrastructure/db (new)
|
||||
Modules indirectly affected: none (no HTTP API, no business modules)
|
||||
Database changes: baseline migration (extensions, app_meta) - greenfield
|
||||
API changes: none
|
||||
Events added/changed: none
|
||||
Risk level: low
|
||||
```
|
||||
|
||||
## Handoff to implementer
|
||||
Follow specs/F-002-database-foundation/TASKS.md. Docker daemon is available (verified at intake). Definition of done for build stage: compose up green, migrations up/down/up demonstrated against real PostgreSQL, integration tests passing with TEST_DATABASE_URL, and evidence in implementer.md.
|
||||
20
work/artifacts/F-002/documenter.md
Normal file
20
work/artifacts/F-002/documenter.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Documenter — F-002 Database foundation with module-owned schemas
|
||||
|
||||
done -> work/artifacts/F-002/documenter.md
|
||||
|
||||
## Why documentation applies
|
||||
F-002 adds developer-facing contracts: database commands, migration workflow, and the table naming convention.
|
||||
|
||||
## Documents reviewed/produced
|
||||
- `project/README.md` (updated at build stage, verified accurate):
|
||||
- Database section lists every new script: docker:up, docker:down, db:up, db:down, db:status, test:integration — all present in package.json (cross-checked programmatically)
|
||||
- `.env.example` copy step documented
|
||||
- Table naming convention section: `<module>_<table>` rule, no cross-prefix queries, migrations immutable
|
||||
- `spec/tech.md`: dependency justification table extended with pg, node-pg-migrate, @types/pg (security policy requirement)
|
||||
- `specs/F-002-database-foundation/` SPEC/DESIGN/TASKS/TESTS consistent with implementation (migration filename corrected to .js at design stage)
|
||||
|
||||
## Verification
|
||||
- package.json script keys vs README commands cross-checked: all db:/docker: commands documented; remaining scripts (build, start, test, typecheck, lint, lint:boundaries) already documented in F-001 section.
|
||||
|
||||
## Pending docs (not this ticket)
|
||||
- Per-module data access contracts documented as each module lands (F-005+).
|
||||
63
work/artifacts/F-002/implementer.md
Normal file
63
work/artifacts/F-002/implementer.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Implementer — F-002 Database foundation with module-owned schemas
|
||||
|
||||
done -> work/artifacts/F-002/implementer.md
|
||||
|
||||
## Files created (all under project/ unless noted)
|
||||
- docker-compose.yml (postgres:16-alpine + redis:7-alpine, named volumes, healthchecks, dev-only creds)
|
||||
- .env.example
|
||||
- migrations/001_baseline.js (node-pg-migrate; citext + pgcrypto extensions, app_meta table, with down)
|
||||
- src/infrastructure/db/pool.ts (createPoolFromEnv fail-fast, typed query helper)
|
||||
- src/infrastructure/db/tests/db-test-support.ts (recreateDatabase, runMigrations, tableExists helpers; strict identifier validation for DDL-interpolated db name)
|
||||
- src/infrastructure/db/tests/db-test-support.test.ts (pure helper tests incl. identifier rejection)
|
||||
- src/infrastructure/db/tests/migrations.itest.ts
|
||||
- src/infrastructure/db/tests/pool.itest.ts
|
||||
- scripts/db-status.mjs
|
||||
|
||||
## Files modified
|
||||
- package.json: deps (pg, node-pg-migrate; @types/pg dev) + scripts (test:integration, docker:up/down, db:up/down/status)
|
||||
- vitest.config.ts: include *.itest.ts; fileParallelism false (integration files share one test DB)
|
||||
- .gitignore: .env
|
||||
- README.md: Database section + table naming convention
|
||||
- spec/tech.md: dependency justification rows for pg / node-pg-migrate / @types/pg
|
||||
- specs/F-002-database-foundation/DESIGN.md + TASKS.md: migration filename corrected to .js (node-pg-migrate native format)
|
||||
|
||||
## Database migrations
|
||||
- 001_baseline: extensions citext + pgcrypto, table app_meta(key, value, updated_at). Down fully reverts.
|
||||
- Tracking table: pgmigrations (owned by node-pg-migrate).
|
||||
|
||||
## API changes
|
||||
- None.
|
||||
|
||||
## Tests added
|
||||
- migrations.itest.ts: fresh up creates schema / second up is no-op / down rolls back cleanly
|
||||
- pool.itest.ts: SELECT 1 roundtrip / app_meta insert-read-delete via query helper / fail fast without DATABASE_URL
|
||||
|
||||
## Tests passed (evidence)
|
||||
```
|
||||
npm run docker:up -> mdv-dev-postgres Healthy, mdv-dev-redis Healthy
|
||||
npm run db:up (1st) -> MIGRATION 001_baseline (UP), Migrations complete!
|
||||
npm run db:up (2nd) -> No migrations to run! (no-op)
|
||||
npm run db:status -> 001_baseline listed as applied
|
||||
npm run db:down -> MIGRATION 001_baseline (DOWN); psql to_regclass('public.app_meta') -> empty
|
||||
npm run db:up (again) -> re-applies cleanly
|
||||
npm run lint -> OK
|
||||
npm run lint:boundaries -> Boundary check OK: 11 file(s) checked
|
||||
npm run typecheck -> exit 0
|
||||
npm run build -> exit 0
|
||||
npm test -> 4 files passed, 2 integration files skipped explicitly without TEST_DATABASE_URL (11 passed | 6 skipped)
|
||||
npm run test:integration -> Test Files 2 passed (2), Tests 6 passed (6) against real PostgreSQL 16
|
||||
docker exec mdv-dev-redis redis-cli ping -> PONG
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
- node-pg-migrate programmatic runner requires explicit migrationsTable (passed 'pgmigrations' in the test helper to match CLI default).
|
||||
- Integration tests drop/create the shared test database; vitest fileParallelism disabled to avoid the race (documented in vitest.config.ts).
|
||||
- No connection retry/backoff yet; not needed while only dev scripts connect.
|
||||
|
||||
## Follow-up work
|
||||
- F-004 will centralize env/config (current fail-fast reader stays until then).
|
||||
- First real module tables arrive with F-005+ following <module>_<table> convention.
|
||||
|
||||
## Security hardening round (requested by security gate)
|
||||
- db-test-support.ts now validates the database name from TEST_DATABASE_URL against /^[a-zA-Z_][a-zA-Z0-9_]*$/ before interpolating it into DROP/CREATE DATABASE DDL; rejection covered by db-test-support.test.ts.
|
||||
- Re-verified: lint, typecheck, npm test (11 passed), test:integration (6 passed).
|
||||
34
work/artifacts/F-002/leader-close.json
Normal file
34
work/artifacts/F-002/leader-close.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"feature_id": "F-002",
|
||||
"agent": "leader",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-002 closed. Database foundation implemented and proven against real PostgreSQL 16 + Redis 7: migrations up/down/no-op, fail-fast pool, one-command dev environment, naming convention documented. All gates APPROVED, verify.sh exit 0.",
|
||||
"gates": {
|
||||
"reviewer": "APPROVED (reviewer.json)",
|
||||
"security": "APPROVED (security.json, after identifier-validation hardening round)",
|
||||
"qa": "APPROVED (qa.json)",
|
||||
"verify_sh": "exit 0"
|
||||
},
|
||||
"deliverables": [
|
||||
"project/migrations/001_baseline.js with working down migration",
|
||||
"project/src/infrastructure/db/pool.ts (fail-fast pool + typed query helper)",
|
||||
"project/docker-compose.yml (postgres:16-alpine + redis:7-alpine)",
|
||||
"npm scripts: db:up/down/status, docker:up/down, test:integration",
|
||||
"6 integration tests passing against real PostgreSQL; helper identifier validation regression-tested",
|
||||
"specs/F-002-database-foundation complete; spec/tech.md dependency justifications"
|
||||
],
|
||||
"process_notes": [
|
||||
"Security gate bounced one low finding (DDL identifier interpolation in test support) back to build; fixed with strict validation + tests, then re-approved. The gate worked as designed."
|
||||
],
|
||||
"next_feature_hint": "F-003 (HTTP foundation/request context) and F-004 (config/flags) only depend on F-001; F-005 identity now unblocked by F-002",
|
||||
"evidence": [
|
||||
"work/artifacts/F-002/architect.md",
|
||||
"work/artifacts/F-002/implementer.md",
|
||||
"work/artifacts/F-002/reviewer.json",
|
||||
"work/artifacts/F-002/security.json",
|
||||
"work/artifacts/F-002/qa.json",
|
||||
"work/artifacts/F-002/documenter.md",
|
||||
"./scripts/verify.sh exit 0 at close"
|
||||
],
|
||||
"timestamp": "2026-08-14T20:04:00Z"
|
||||
}
|
||||
42
work/artifacts/F-002/qa.json
Normal file
42
work/artifacts/F-002/qa.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"feature_id": "F-002",
|
||||
"agent": "qa",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "All 5 acceptance criteria verified with fresh executions against real PostgreSQL 16 + Redis 7 in docker.",
|
||||
"traceability": [
|
||||
{
|
||||
"criterion": "AC1: fresh database + migrations up creates schema; rerun is no-op",
|
||||
"test": "migrations.itest.ts (recreates DB fresh each run) + CLI: npm run db:up on applied DB",
|
||||
"result": "PASS (fresh up -> app_meta exists; second up -> 'No migrations to run!')"
|
||||
},
|
||||
{
|
||||
"criterion": "AC2: down rolls back cleanly",
|
||||
"test": "migrations.itest.ts down case + reviewer CLI roundtrip (db:down verified app_meta gone, db:up re-applied)",
|
||||
"result": "PASS"
|
||||
},
|
||||
{
|
||||
"criterion": "AC3: table naming convention documented and enforced",
|
||||
"test": "README.md 'Table naming convention' section + baseline exemplar app_meta + immutability rule",
|
||||
"result": "PASS"
|
||||
},
|
||||
{
|
||||
"criterion": "AC4: dev PostgreSQL and Redis start with one command",
|
||||
"test": "full docker:down then npm run docker:up",
|
||||
"result": "PASS (both containers Healthy; pg_isready accepting connections; redis-cli ping -> PONG)"
|
||||
},
|
||||
{
|
||||
"criterion": "AC5: verify.sh green",
|
||||
"test": "./scripts/verify.sh",
|
||||
"result": "PASS (exit 0)"
|
||||
}
|
||||
],
|
||||
"regressions": "PASS - F-001 suite re-run green: lint, typecheck, build, unit tests (11 passed | 6 skipped without DB), /health unaffected (no HTTP change)",
|
||||
"evidence": [
|
||||
"npm run test:integration -> Test Files 2 passed (2), Tests 6 passed (6)",
|
||||
"npm run db:up (applied DB) -> No migrations to run!",
|
||||
"docker:down + docker:up -> mdv-dev-postgres Healthy, mdv-dev-redis Healthy, pg_isready OK, redis PONG",
|
||||
"npm run lint/typecheck/build/test -> all exit 0",
|
||||
"./scripts/verify.sh -> exit 0"
|
||||
],
|
||||
"timestamp": "2026-08-14T20:02:00Z"
|
||||
}
|
||||
31
work/artifacts/F-002/reviewer.json
Normal file
31
work/artifacts/F-002/reviewer.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"feature_id": "F-002",
|
||||
"agent": "reviewer",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Database foundation matches specs/F-002 DESIGN.md. Migration tooling demonstrated against real PostgreSQL 16, pool helper fail-fast, compose one-command dev environment, naming convention documented. No blockers.",
|
||||
"checks": {
|
||||
"design_conformance": "PASS: src/infrastructure/db/pool.ts, migrations/001_baseline.js, docker-compose.yml, npm scripts all present as designed; --env-file-if-exists used instead of strict --env-file (safer, equivalent intent)",
|
||||
"migration_lifecycle": "PASS (re-executed by reviewer): db:up applies 001_baseline; db:down reverts; db:up re-applies; second up is no-op",
|
||||
"naming_convention": "PASS: <module>_<table> documented in README with rules; baseline table app_meta follows prefix rule; no business tables introduced",
|
||||
"test_hygiene": "PASS: integration tests skip explicitly without TEST_DATABASE_URL (npm test green without DB); shared-DB race resolved by disabling fileParallelism with inline justification",
|
||||
"boundary_rules": "PASS: lint:boundaries clean over 11 files; db code lives in infrastructure, no module touched"
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "info",
|
||||
"note": "node-pg-migrate programmatic runner needs explicit migrationsTable; test helper pins 'pgmigrations' to match CLI default. Documented in implementer.md known limitations."
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"note": "F-004 will centralize config; current minimal fail-fast env reader in pool.ts is acceptable and called out as follow-up."
|
||||
}
|
||||
],
|
||||
"evidence": [
|
||||
"npm run lint / typecheck / lint:boundaries / test -> all exit 0",
|
||||
"npm run test:integration -> Test Files 2 passed (2), Tests 6 passed (6) against PostgreSQL 16 in docker",
|
||||
"reviewer re-run: db:down then db:up -> 001_baseline DOWN then UP, Migrations complete",
|
||||
"docker exec mdv-dev-redis redis-cli ping -> PONG (from build stage)",
|
||||
"files reviewed: project/migrations/001_baseline.js, src/infrastructure/db/**, docker-compose.yml, scripts/db-status.mjs, README.md, spec/tech.md"
|
||||
],
|
||||
"timestamp": "2026-08-14T19:57:00Z"
|
||||
}
|
||||
28
work/artifacts/F-002/security.json
Normal file
28
work/artifacts/F-002/security.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"feature_id": "F-002",
|
||||
"agent": "security",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Security gate passed. Zero audit vulnerabilities with new deps (pg, node-pg-migrate), no secrets in code, .env gitignored, parameterized queries everywhere, and the one DDL interpolation point is now guarded by strict identifier validation with regression tests.",
|
||||
"checks": {
|
||||
"secrets": "PASS: no hardcoded secrets in src/scripts/migrations; dev compose credentials are explicitly documented as dev-only; .env confirmed gitignored (git check-ignore .env)",
|
||||
"dependencies": "PASS: npm audit -> 0 vulnerabilities; pg/node-pg-migrate/@types/pg justified in spec/tech.md",
|
||||
"sast_basic": "PASS: no eval/new Function; template-literal SQL limited to 2 DDL statements in test support, guarded by /^[a-zA-Z_][a-zA-Z0-9_]*$/ validation in dbNameFromUrl (rejection covered by db-test-support.test.ts)",
|
||||
"input_validation": "PASS: pool helper uses $n parameterized queries exclusively for data access",
|
||||
"exposure": "INFO accepted: compose binds 5432/6379 on host for local dev only; documented in README"
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "low",
|
||||
"note": "DDL identifier interpolation in test support",
|
||||
"resolution": "MITIGATED this gate round: strict identifier validation added before any DDL use + unit tests rejecting bad\"name and semi;colon cases"
|
||||
}
|
||||
],
|
||||
"evidence": [
|
||||
"npm audit -> found 0 vulnerabilities",
|
||||
"git check-ignore .env -> ignored",
|
||||
"grep secret scan over src/scripts/migrations -> none",
|
||||
"grep for template-literal queries -> only the 2 guarded DDL statements remain",
|
||||
"npm test after hardening -> 11 passed | 6 skipped; test:integration -> 6 passed"
|
||||
],
|
||||
"timestamp": "2026-08-14T20:00:00Z"
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
# Sesión actual
|
||||
|
||||
- Feature en curso: _ninguna_ (F-001 cerrada DONE el 2026-08-14)
|
||||
- Feature en curso: _ninguna_ (F-002 cerrada DONE el 2026-08-14)
|
||||
- Inicio: —
|
||||
- Orquestador: —
|
||||
|
||||
## Plan
|
||||
- Próxima feature según dependencias: F-002 (Database foundation) o F-003 (HTTP foundation) o F-004 (config/flags) — todas dependen solo de F-001.
|
||||
- Features ahora desbloqueadas: F-003 (HTTP foundation), F-004 (config/flags) — dependen solo de F-001. F-005 (identity) tiene sus dos dependencias (F-002, F-003): falta F-003.
|
||||
- Sugerencia de orden: F-003 → F-004 → F-005.
|
||||
|
||||
## Bitácora
|
||||
- 2026-08-14: F-001 completada con todos los gates APPROVED y verify.sh verde.
|
||||
- 2026-08-14: F-001 DONE y F-002 DONE, todos los gates APPROVED, verify.sh verde.
|
||||
- Dev PostgreSQL + Redis siguen corriendo (mdv-dev-postgres, mdv-dev-redis) para los próximos tickets.
|
||||
|
||||
## Próximo paso
|
||||
- intake de la siguiente feature (sugerida: F-002).
|
||||
- intake de F-003 (HTTP foundation and request context).
|
||||
|
||||
@@ -7,3 +7,9 @@
|
||||
- Entregable: skeleton TypeScript + Fastify en project/ con boundary checker testeado; specs/F-001-scaffold completos; spec/tech.md con justificación de dependencias
|
||||
- Artefactos: work/artifacts/F-001/ (architect.md, implementer.md, reviewer.json, security.json, qa.json, documenter.md, leader-close.json)
|
||||
- Nota: el boundary checker detectó una violación real durante build (test escapando del módulo) y se corrigió moviendo los tests de composición a src/app
|
||||
|
||||
## 2026-08-14 — F-002 Database foundation with module-owned schemas — DONE
|
||||
- Gates: reviewer APPROVED, security APPROVED (con ronda de hardening), qa APPROVED, verify.sh exit 0
|
||||
- Entregable: migraciones node-pg-migrate (up/down/no-op probados contra PostgreSQL 16 real), pool fail-fast, docker-compose (Postgres + Redis), convención <module>_<table> documentada
|
||||
- Nota: security devolvió un hallazgo bajo (interpolación de identificador en DDL de tests); se mitigó con validación estricta + tests de regresión
|
||||
- Artefactos: work/artifacts/F-002/
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"state": "waiting",
|
||||
"next_agent": "leader",
|
||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||
"updated_at": "2026-08-14T19:46:48Z",
|
||||
"updated_at": "2026-08-14T20:00:16Z",
|
||||
"timeline": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user