diff --git a/backlog/features.json b/backlog/features.json index c362b5b..10d700a 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -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 } }, { diff --git a/project/.env.example b/project/.env.example new file mode 100644 index 0000000..e473888 --- /dev/null +++ b/project/.env.example @@ -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 diff --git a/project/.gitignore b/project/.gitignore index e6d5efa..c2721ae 100644 --- a/project/.gitignore +++ b/project/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ coverage/ *.log +.env diff --git a/project/README.md b/project/README.md index 96a3959..269f632 100644 --- a/project/README.md +++ b/project/README.md @@ -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 +_ 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 diff --git a/project/docker-compose.yml b/project/docker-compose.yml new file mode 100644 index 0000000..d7dcca7 --- /dev/null +++ b/project/docker-compose.yml @@ -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: diff --git a/project/migrations/001_baseline.js b/project/migrations/001_baseline.js new file mode 100644 index 0000000..d611b3d --- /dev/null +++ b/project/migrations/001_baseline.js @@ -0,0 +1,25 @@ +/** + * Baseline migration: foundation objects only. + * Business tables arrive with their owning modules using the + * _
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'); +}; diff --git a/project/package-lock.json b/project/package-lock.json index cfea9cd..e18454f 100644 --- a/project/package-lock.json +++ b/project/package-lock.json @@ -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", diff --git a/project/package.json b/project/package.json index dce0cd0..8471b14 100644 --- a/project/package.json +++ b/project/package.json @@ -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", diff --git a/project/scripts/db-status.mjs b/project/scripts/db-status.mjs new file mode 100644 index 0000000..6770ae5 --- /dev/null +++ b/project/scripts/db-status.mjs @@ -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(); +} diff --git a/project/src/infrastructure/db/pool.ts b/project/src/infrastructure/db/pool.ts new file mode 100644 index 0000000..eeba1ff --- /dev/null +++ b/project/src/infrastructure/db/pool.ts @@ -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( + pool: pg.Pool, + text: string, + params: unknown[] = [], +): Promise> { + return pool.query(text, params); +} diff --git a/project/src/infrastructure/db/tests/db-test-support.test.ts b/project/src/infrastructure/db/tests/db-test-support.test.ts new file mode 100644 index 0000000..f2cafa7 --- /dev/null +++ b/project/src/infrastructure/db/tests/db-test-support.test.ts @@ -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', + ); + }); +}); diff --git a/project/src/infrastructure/db/tests/db-test-support.ts b/project/src/infrastructure/db/tests/db-test-support.ts new file mode 100644 index 0000000..cd52fb9 --- /dev/null +++ b/project/src/infrastructure/db/tests/db-test-support.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/project/src/infrastructure/db/tests/migrations.itest.ts b/project/src/infrastructure/db/tests/migrations.itest.ts new file mode 100644 index 0000000..dbb7083 --- /dev/null +++ b/project/src/infrastructure/db/tests/migrations.itest.ts @@ -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); + }); +}); diff --git a/project/src/infrastructure/db/tests/pool.itest.ts b/project/src/infrastructure/db/tests/pool.itest.ts new file mode 100644 index 0000000..2eb5899 --- /dev/null +++ b/project/src/infrastructure/db/tests/pool.itest.ts @@ -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/, + ); + }); +}); diff --git a/project/vitest.config.ts b/project/vitest.config.ts index f0648d5..422b591 100644 --- a/project/vitest.config.ts +++ b/project/vitest.config.ts @@ -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/**'], }, }); diff --git a/spec/tech.md b/spec/tech.md index 6ddc087..f83cb32 100644 --- a/spec/tech.md +++ b/spec/tech.md @@ -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. diff --git a/specs/F-002-database-foundation/DESIGN.md b/specs/F-002-database-foundation/DESIGN.md new file mode 100644 index 0000000..bb4e1a5 --- /dev/null +++ b/specs/F-002-database-foundation/DESIGN.md @@ -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 +_
e.g. catalog_products, inventory_stock, orders_orders +_
_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`. diff --git a/specs/F-002-database-foundation/SPEC.md b/specs/F-002-database-foundation/SPEC.md new file mode 100644 index 0000000..453dc79 --- /dev/null +++ b/specs/F-002-database-foundation/SPEC.md @@ -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 `_
` 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. diff --git a/specs/F-002-database-foundation/TASKS.md b/specs/F-002-database-foundation/TASKS.md new file mode 100644 index 0000000..b2558eb --- /dev/null +++ b/specs/F-002-database-foundation/TASKS.md @@ -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 diff --git a/specs/F-002-database-foundation/TESTS.md b/specs/F-002-database-foundation/TESTS.md new file mode 100644 index 0000000..2073f41 --- /dev/null +++ b/specs/F-002-database-foundation/TESTS.md @@ -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 | diff --git a/work/artifacts/F-002/architect.md b/work/artifacts/F-002/architect.md new file mode 100644 index 0000000..0e34ede --- /dev/null +++ b/work/artifacts/F-002/architect.md @@ -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 (`_
`). 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. diff --git a/work/artifacts/F-002/documenter.md b/work/artifacts/F-002/documenter.md new file mode 100644 index 0000000..a5f9520 --- /dev/null +++ b/work/artifacts/F-002/documenter.md @@ -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: `_
` 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+). diff --git a/work/artifacts/F-002/implementer.md b/work/artifacts/F-002/implementer.md new file mode 100644 index 0000000..22bee99 --- /dev/null +++ b/work/artifacts/F-002/implementer.md @@ -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 _
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). diff --git a/work/artifacts/F-002/leader-close.json b/work/artifacts/F-002/leader-close.json new file mode 100644 index 0000000..81f613a --- /dev/null +++ b/work/artifacts/F-002/leader-close.json @@ -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" +} diff --git a/work/artifacts/F-002/qa.json b/work/artifacts/F-002/qa.json new file mode 100644 index 0000000..b3cbb3e --- /dev/null +++ b/work/artifacts/F-002/qa.json @@ -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" +} diff --git a/work/artifacts/F-002/reviewer.json b/work/artifacts/F-002/reviewer.json new file mode 100644 index 0000000..1d66a4b --- /dev/null +++ b/work/artifacts/F-002/reviewer.json @@ -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: _
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" +} diff --git a/work/artifacts/F-002/security.json b/work/artifacts/F-002/security.json new file mode 100644 index 0000000..78c9b95 --- /dev/null +++ b/work/artifacts/F-002/security.json @@ -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" +} diff --git a/work/current.md b/work/current.md index e065b77..89fb88a 100644 --- a/work/current.md +++ b/work/current.md @@ -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). diff --git a/work/history.md b/work/history.md index 8d72971..284ae1d 100644 --- a/work/history.md +++ b/work/history.md @@ -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 _
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/ diff --git a/work/runtime-status.json b/work/runtime-status.json index b31d536..e70eaaf 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -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": [] }