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:
parent
040218a7bf
commit
64ac4ff6bb
7 changed files with 226 additions and 2 deletions
8
.node-pg-migraterc.json
Normal file
8
.node-pg-migraterc.json
Normal 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
115
docs/migrations.md
Normal 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.
|
||||
29
migrations/1744600000000_example-no-op.js
Normal file
29
migrations/1744600000000_example-no-op.js
Normal 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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
43
src/db/migrate.js
Normal 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 };
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue