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:
rikrdo
2026-08-14 22:00:16 +02:00
parent 1d4eebca54
commit 425fedd13e
30 changed files with 1197 additions and 14 deletions

4
project/.env.example Normal file
View 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
View File

@@ -2,3 +2,4 @@ node_modules/
dist/
coverage/
*.log
.env

View File

@@ -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

View 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:

View 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');
};

View File

@@ -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",

View File

@@ -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",

View 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();
}

View 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);
}

View 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',
);
});
});

View 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;
}

View 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);
});
});

View 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/,
);
});
});

View File

@@ -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/**'],
},
});