diff --git a/Dockerfile b/Dockerfile index aa09189..50335db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ COPY --from=build /workspace/package.json /app/package.json COPY --from=build /workspace/node_modules /app/node_modules COPY --from=build /workspace/apps/api/package.json /app/apps/api/package.json COPY --from=build /workspace/apps/api/dist /app/apps/api/dist +COPY --from=build /workspace/migrations /app/migrations COPY --from=build /workspace/packages/contracts/package.json /app/packages/contracts/package.json COPY --from=build /workspace/packages/contracts/dist /app/packages/contracts/dist COPY --from=build /workspace/apps/web/dist /app/web diff --git a/README.md b/README.md index c840c9f..cb5665b 100644 --- a/README.md +++ b/README.md @@ -32,4 +32,4 @@ npm run verify docker build -t fieldlens . ``` -Production migrations are applied explicitly before application deployment. The API runtime account is not expected to perform schema DDL on startup. +For Arrowstack `database-per-tenant` deployments, the restricted application runtime identity owns DDL inside its dedicated database. The API applies versioned migrations at startup under a PostgreSQL advisory lock before it begins listening; applied migration filenames and checksums are recorded so startup is idempotent and changed historical migrations fail closed. diff --git a/apps/api/src/migrations.ts b/apps/api/src/migrations.ts new file mode 100644 index 0000000..cae3701 --- /dev/null +++ b/apps/api/src/migrations.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { pool } from './db.js' +import { log } from './log.js' + +const MIGRATION_LOCK_ID = 884251031 +const migrationsDir = process.env.MIGRATIONS_DIR?.trim() || path.resolve(process.cwd(), 'migrations') + +function checksum(sql: string) { + return createHash('sha256').update(sql).digest('hex') +} + +export async function runMigrations() { + const client = await pool.connect() + try { + await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK_ID]) + await client.query(` + CREATE TABLE IF NOT EXISTS fieldlens_schema_migrations ( + filename text PRIMARY KEY, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `) + + const files = (await readdir(migrationsDir)) + .filter((name) => /^\d{3}_.+\.sql$/.test(name)) + .sort() + + if (files.length === 0) throw new Error('No FieldLens migrations found') + + for (const filename of files) { + const sql = await readFile(path.join(migrationsDir, filename), 'utf8') + const digest = checksum(sql) + const applied = await client.query<{ checksum: string }>( + 'SELECT checksum FROM fieldlens_schema_migrations WHERE filename = $1', + [filename], + ) + + if (applied.rows[0]) { + if (applied.rows[0].checksum !== digest) { + throw new Error(`Applied migration checksum mismatch: ${filename}`) + } + continue + } + + await client.query('BEGIN') + try { + await client.query(sql) + await client.query( + 'INSERT INTO fieldlens_schema_migrations (filename, checksum) VALUES ($1, $2)', + [filename, digest], + ) + await client.query('COMMIT') + log('info', 'migration_applied', { filename }) + } catch (error) { + await client.query('ROLLBACK') + throw error + } + } + + log('info', 'migrations_ready', { count: files.length }) + } finally { + try { + await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_ID]) + } finally { + client.release() + } + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index cbaf3db..c5afd98 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -8,6 +8,7 @@ import { config } from './config.js' import { loadDeviceContext } from './context.js' import { databaseReady, pool } from './db.js' import { log } from './log.js' +import { runMigrations } from './migrations.js' import { processSyncEvent } from './sync.js' const app = express() @@ -259,6 +260,8 @@ if (config.staticDir) { }) } +await runMigrations() + const server = app.listen(config.port, '0.0.0.0', () => { log('info', 'server_started', { port: config.port,