diff --git a/CHANGELOG.md b/CHANGELOG.md index 33061c9..29d85a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`migration` field on `GET /healthz`** (P4-I). Body now reports the + last applied migration name from `SequelizeMeta` (e.g. + `"20260519000000-idempotency-keys"`). Lets a rolling-deploy caller + verify each pod is at the expected schema version. Null when + SequelizeMeta is missing (fresh DB pre-migration) or unreadable — + the probe never flips to `status: degraded` over a migration-read + failure, since the DB itself is still up. - **Bulk-create endpoints for 5 direct-compId entities** (P3-H). New `POST /v1//bulk` on Worker, BillingType, InventoryItem, InventoryTransaction, and PurchaseOrderVendor. Same shape as the diff --git a/app/config/openapi.js b/app/config/openapi.js index 66abdc8..f1f41e2 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -305,6 +305,11 @@ const spec = { uptime_s: { type: 'integer' }, version: { type: 'string' }, elapsed_ms: { type: 'number' }, + migration: { + type: 'string', + nullable: true, + description: 'Last applied migration name from SequelizeMeta (lex-highest entry, which matches apply order since filenames are timestamp-prefixed). Null when SequelizeMeta is missing or unreadable.', + }, }, }, }, diff --git a/app/controllers/healthcontroller.js b/app/controllers/healthcontroller.js index 31d58fd..e1c2903 100644 --- a/app/controllers/healthcontroller.js +++ b/app/controllers/healthcontroller.js @@ -10,24 +10,38 @@ const db = require('../config/db.config.js'); * Lightweight liveness + DB-readiness probe for orchestrators * (systemd, Docker HEALTHCHECK, Kubernetes liveness/readiness, * uptime monitors). No authKey required — the endpoint reveals - * only "the server is running" and "the DB connection works." + * only "the server is running", "the DB connection works", and + * "the schema is at version X" (informative, not a secret — + * migration filenames are also visible in git history and + * `app/migrations/`). * * Response shape: * { * "status": "ok" | "degraded", * "db": "ok" | "down", * "uptime_s": , - * "version": + * "version": , + * "elapsed_ms": , + * "migration": | null * } * * - 200 when DB ping succeeds. * - 503 when DB ping fails (so orchestrators can take the pod * out of rotation until the dependency recovers). + * + * `migration` lets a caller verify a rolling deploy is at the + * expected schema. When the DB is down it's null; when up but + * SequelizeMeta is missing (fresh DB pre-migration) it's also + * null. The probe itself never fails on the migration read — + * a degraded migration query falls back to `migration: null` + * without flipping `status` to degraded, since the DB is still + * usable for actual API requests. */ exports.healthz = async (req, res) => { const started = process.hrtime.bigint(); let dbOk = false; let dbError; + let migration = null; try { // Cheapest possible DB-roundtrip: `SELECT 1`. Confirms the // pool can hand us a connection AND that Postgres responds. @@ -40,6 +54,26 @@ exports.healthz = async (req, res) => { raw: true, }); dbOk = true; + + // Best-effort schema version read. SequelizeMeta is the + // sequelize-cli convention: a single-column table named + // `name` holding one row per applied migration. We grab + // the lexicographically highest entry — migration filenames + // are timestamp-prefixed (`20260518000000-…`) so lex order + // matches apply order. + try { + const rows = await db.sequelize.query( + 'SELECT "name" FROM "SequelizeMeta" ORDER BY "name" DESC LIMIT 1', + { type: db.sequelize.QueryTypes.SELECT }, + ); + if (rows && rows[0] && rows[0].name) { + migration = String(rows[0].name); + } + } catch (_) { + // SequelizeMeta missing (e.g., fresh DB pre-migration) + // or a read failure. Don't flip the probe to degraded — + // the DB itself is still up. Leave migration: null. + } } catch (error) { dbError = String(error && error.message ? error.message : error); } @@ -51,6 +85,7 @@ exports.healthz = async (req, res) => { uptime_s: Math.round(process.uptime()), version: process.env.npm_package_version || 'unknown', elapsed_ms: Math.round(elapsedMs * 100) / 100, + migration, }; if (dbError) { body.db_error = dbError; diff --git a/tests/api/healthz.test.js b/tests/api/healthz.test.js index bf7923e..dd9d60f 100644 --- a/tests/api/healthz.test.js +++ b/tests/api/healthz.test.js @@ -66,4 +66,16 @@ describe('GET /healthz', () => { expect(res.status).toBe(503); } }); + + test('carries a `migration` key (null OK, string OK)', async () => { + // P4-I: callers can use this to verify a rolling deploy is at + // the expected schema version. The mock returns the same value + // for every query() call so `migration` here may equal `ok` + // (the result of the SELECT 1 probe) or null depending on + // mock specificity — either is fine as long as the key exists. + const res = await request(app).get('/healthz'); + expect(res.body).toHaveProperty('migration'); + const m = res.body.migration; + expect(m === null || typeof m === 'string').toBe(true); + }); });