Add node-pg-migrate for versioned schema changes + better mobile UA labels

Infrastructure only — no existing data or tables modified.

  src/db/migrate.js           — programmatic runner, fires at boot after
                                 the existing idempotent initDatabase()
  migrations/1744600000000...  — intentionally empty example, documents
                                 the file shape. Registered in the new
                                 pgmigrations tracking table so it won't
                                 rerun.
  .node-pg-migraterc.json     — CLI config (migrations-dir, utc naming)
  docs/migrations.md          — workflow + conventions
  package.json                — migrate:up/down/new/status npm scripts
                                 (status is a direct pgmigrations query
                                 since node-pg-migrate v7 lacks a status
                                 subcommand)

src/utils/sessions.js:
  - parseUserAgent now recognizes the Capacitor wrapper (UA suffix
    "PedScribe-Android" / "PedScribe-iOS") and labels sessions
    "PedScribe (Android)" instead of "Chrome on Android".

Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
This commit is contained in:
Daniel 2026-04-14 05:06:19 +02:00
parent 040218a7bf
commit 64ac4ff6bb
7 changed files with 226 additions and 2 deletions

8
.node-pg-migraterc.json Normal file
View file

@ -0,0 +1,8 @@
{
"migrations-dir": "migrations",
"migration-filename-format": "utc",
"migration-file-language": "js",
"migrations-table": "pgmigrations",
"schema": "public",
"verbose": true
}

115
docs/migrations.md Normal file
View file

@ -0,0 +1,115 @@
# Database Migrations
The app uses [node-pg-migrate](https://github.com/salsita/node-pg-migrate)
for versioned, reversible schema changes.
## How it works
Boot sequence:
1. **Baseline init**`src/db/database.js` runs `CREATE TABLE IF NOT EXISTS`
and `ALTER TABLE ADD COLUMN IF NOT EXISTS` for the existing schema. This
is the implicit baseline — everything that was in place before
migrations were introduced. Idempotent on every boot.
2. **Migrations**`src/db/migrate.js` runs every file in `/app/migrations/`
that hasn't already been recorded in the `pgmigrations` table, in
filename order. Each applied migration is inserted into `pgmigrations`
so it only runs once.
New schema changes should go in versioned migration files, not in the
inline `database.js` init.
## Creating a migration
```bash
docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_avatar_url
```
Creates a file like `migrations/1744601234567_add_avatar_url.js` with
empty `up()` and `down()` functions. Edit it:
```js
exports.up = (pgm) => {
pgm.addColumn('users', {
avatar_url: { type: 'text', notNull: false }
});
pgm.createIndex('users', 'avatar_url');
};
exports.down = (pgm) => {
pgm.dropIndex('users', 'avatar_url');
pgm.dropColumn('users', 'avatar_url');
};
```
Full API: https://salsita.github.io/node-pg-migrate/
## Running migrations
Migrations apply automatically on app boot. To run them manually (e.g.
before a restart):
```bash
docker exec -w /app pediatric-ai-scribe npm run migrate:up
```
## Rolling back
Roll back the most recent migration:
```bash
docker exec -w /app pediatric-ai-scribe npm run migrate:down
```
This calls the file's `down()`. If `down()` is empty or missing, the
rollback is a no-op but the migration is removed from `pgmigrations`
— meaning the next `up` will reapply it.
## Viewing state
Which migrations have been applied:
```bash
docker exec -w /app pediatric-ai-scribe npm run migrate:status
```
Or directly:
```bash
docker exec pedscribe-db psql -U pedscribe -d pedscribe \
-c "SELECT id, name, run_on FROM pgmigrations ORDER BY id;"
```
## Raw SQL migrations
If pgm's JS helpers are limiting, drop to SQL:
```js
exports.up = (pgm) => {
pgm.sql(`
CREATE INDEX CONCURRENTLY idx_audit_log_action
ON audit_log (action)
WHERE action IN ('login', 'login_failed', 'session_idle_timeout');
`);
};
```
Note: `CREATE INDEX CONCURRENTLY` cannot run inside a transaction. For
that you need `exports.disableTransaction = true;` in the migration file.
## Conventions
- One logical change per file. Don't bundle unrelated alters.
- Always write `down()` unless rollback is fundamentally impossible
(e.g., dropping a column that had unique data).
- Name files by what the change does (`add_foo`, `backfill_bar`), not
the ticket number.
- Migrations run in filename order — the timestamp prefix ensures order
across checkouts from different devs.
- Never edit an already-applied migration. Write a new one to fix it.
## Relationship to the inline init
`src/db/database.js` still runs on every boot and handles the pre-migration
baseline. Do not add new schema changes there — use migrations. The inline
init will gradually shrink as old CREATE TABLE statements age out.

View file

@ -0,0 +1,29 @@
/**
* Example migration demonstrates the shape.
* This one is a NO-OP so the tooling can boot cleanly without
* interfering with the existing baseline in src/db/database.js.
*
* For a real change, replace the body with:
* exports.up = (pgm) => {
* pgm.addColumn('users', {
* avatar_url: { type: 'text' }
* });
* };
* exports.down = (pgm) => {
* pgm.dropColumn('users', 'avatar_url');
* };
*
* Full API: https://salsita.github.io/node-pg-migrate/
*/
exports.up = async () => {
// intentionally empty
};
exports.down = async () => {
// intentionally empty
};
// Tell node-pg-migrate this migration doesn't need a transaction —
// lets future migrations that need CREATE INDEX CONCURRENTLY etc run.
exports.shorthands = undefined;

View file

@ -6,7 +6,12 @@
"scripts": {
"start": "node server.js",
"maint:check": "node scripts/maintenance.js check",
"maint:reindex": "node scripts/maintenance.js reindex"
"maint:reindex": "node scripts/maintenance.js reindex",
"migrate": "node-pg-migrate",
"migrate:up": "node-pg-migrate up",
"migrate:down": "node-pg-migrate down 1",
"migrate:status": "node -e \"const{Pool}=require('pg');const p=new Pool({connectionString:process.env.DATABASE_URL});p.query('SELECT id,name,run_on FROM pgmigrations ORDER BY id').then(r=>{console.table(r.rows);p.end();}).catch(e=>{console.error(e.message);process.exit(1);})\"",
"migrate:new": "node-pg-migrate create"
},
"dependencies": {
"@marp-team/marp-cli": "^4.3.1",
@ -29,6 +34,7 @@
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0",
"nodemailer": "^8.0.5",
"openai": "^4.73.0",
"openid-client": "^6.8.2",

View file

@ -417,7 +417,20 @@ async function initDatabase() {
}
}
initDatabase();
// Boot sequence: inline init (idempotent baseline) → node-pg-migrate for
// incremental changes. Migrations live in /app/migrations/.
(async function() {
await initDatabase();
try {
var { runMigrations } = require('./migrate');
await runMigrations();
} catch (e) {
// runMigrations already logs the fatal; don't hide it but don't crash
// the whole process here either — server.js health check catches a
// broken DB separately.
console.error('[DB] Migration runner failed:', e.message);
}
})();
// Clean up expired saved encounters and audio backups
async function cleanupExpired() {

43
src/db/migrate.js Normal file
View file

@ -0,0 +1,43 @@
// ============================================================
// Run pending migrations at boot. Uses node-pg-migrate
// programmatically — same engine the CLI uses, no shell-out.
// ============================================================
//
// Design notes:
// - `src/db/database.js` runs its inline, idempotent CREATE TABLE IF
// NOT EXISTS init on every boot. That is the implicit "baseline"
// schema. node-pg-migrate tracks incremental changes layered on top.
// - Migration files go in /app/migrations/{timestamp}_name.js with
// an up(pgm) and optional down(pgm).
// - Migration state persists in the `pgmigrations` table so each
// file runs exactly once.
// ============================================================
var runner = require('node-pg-migrate').default;
var path = require('path');
async function runMigrations() {
var databaseUrl = process.env.DATABASE_URL
|| 'postgresql://pedscribe:pedscribe_secret_change_me@localhost:5432/pedscribe';
try {
var applied = await runner({
databaseUrl: databaseUrl,
dir: path.join(__dirname, '..', '..', 'migrations'),
migrationsTable: 'pgmigrations',
direction: 'up',
count: Infinity,
verbose: false,
singleTransaction: true
});
if (applied && applied.length) {
console.log('✅ Migrations applied: ' + applied.map(function(m) { return m.name; }).join(', '));
} else {
console.log('✅ Migrations: up-to-date');
}
} catch (err) {
console.error('[FATAL] Migration failed:', err.message);
throw err;
}
}
module.exports = { runMigrations: runMigrations };

View file

@ -6,6 +6,16 @@ function hashToken(token) {
function parseUserAgent(ua) {
if (!ua) return 'Unknown device';
// Native PedScribe app (Capacitor appends "PedScribe-Android" / "PedScribe-iOS")
if (/PedScribe-Android/i.test(ua)) return 'PedScribe (Android)';
if (/PedScribe-iOS/i.test(ua)) return 'PedScribe (iOS)';
if (/Capacitor/i.test(ua)) {
if (/Android/i.test(ua)) return 'PedScribe (Android)';
if (/iPhone|iPad|iOS/i.test(ua)) return 'PedScribe (iOS)';
return 'PedScribe app';
}
var browser = 'Unknown browser';
var os = 'Unknown OS';