Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<entity>/bulk` on Worker, BillingType, InventoryItem,
InventoryTransaction, and PurchaseOrderVendor. Same shape as the
Expand Down
5 changes: 5 additions & 0 deletions app/config/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
},
},
},
Expand Down
39 changes: 37 additions & 2 deletions app/controllers/healthcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": <int>,
* "version": <string>
* "version": <string>,
* "elapsed_ms": <number>,
* "migration": <last applied migration name> | 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.
Expand All @@ -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);
}
Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions tests/api/healthz.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading