diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index 0775a192..5aa2ca5e 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -7,6 +7,8 @@ RUN npm ci COPY frontend/src ./src COPY frontend/public ./public COPY frontend/next.config.ts frontend/postcss.config.mjs frontend/tsconfig.json ./ +COPY frontend/scripts ./scripts +COPY frontend/db ./db ARG NEXT_PUBLIC_SUPABASE_URL ARG NEXT_PUBLIC_SUPABASE_ANON_KEY diff --git a/frontend/db/migrations/20260720000100_backend_foundation.sql b/frontend/db/migrations/20260720000100_backend_foundation.sql new file mode 100644 index 00000000..85d67e42 --- /dev/null +++ b/frontend/db/migrations/20260720000100_backend_foundation.sql @@ -0,0 +1,23 @@ +create schema if not exists identity authorization schema_owner; +create schema if not exists audit authorization schema_owner; +revoke all on schema public from public; +revoke all on schema identity from public; +revoke all on schema audit from public; +grant usage on schema identity to identity_runtime, admin_runtime; +grant usage on schema public to app_runtime, admin_runtime; +grant usage on schema audit to admin_runtime; + +alter default privileges for role schema_owner in schema identity + revoke all on tables from public; +alter default privileges for role schema_owner in schema public + revoke all on tables from public; +alter default privileges for role schema_owner in schema audit + revoke all on tables from public; +alter default privileges for role schema_owner in schema identity + grant select, insert, update, delete on tables to identity_runtime; +alter default privileges for role schema_owner in schema identity + grant select on tables to admin_runtime; +alter default privileges for role schema_owner in schema public + grant select, insert, update, delete on tables to app_runtime, admin_runtime; +alter default privileges for role schema_owner in schema audit + grant select, insert on tables to admin_runtime; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 519aab35..27b51bf3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,8 +16,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", "lucide-react": "^1.24.0", "next": "16.2.10", + "pg": "^8.22.0", "react": "19.2.4", "react-day-picker": "^10.0.1", "react-dom": "19.2.4", @@ -30,6 +32,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -3017,12 +3020,24 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -4730,6 +4745,131 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8958,6 +9098,95 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9024,6 +9253,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/posthog-node": { "version": "5.41.0", "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.41.0.tgz", @@ -9863,6 +10131,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10543,7 +10820,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -10933,6 +11210,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e538b24a..25266a37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,8 +7,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "test": "tsx --test tests/*.test.ts", + "test": "tsx --test --test-concurrency=1 tests/*.test.ts", "test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts", + "db:migrate": "node scripts/db-migrate.mjs", + "db:migrate:check": "node scripts/db-migrate.mjs --check", "lint": "eslint", "data:china": "node scripts/pull-china-locations.mjs" }, @@ -21,8 +23,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", "lucide-react": "^1.24.0", "next": "16.2.10", + "pg": "^8.22.0", "react": "19.2.4", "react-day-picker": "^10.0.1", "react-dom": "19.2.4", @@ -35,6 +39,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/frontend/scripts/db-migrate.mjs b/frontend/scripts/db-migrate.mjs new file mode 100644 index 00000000..217e6f8c --- /dev/null +++ b/frontend/scripts/db-migrate.mjs @@ -0,0 +1,192 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import pg from "pg"; + +const { Client } = pg; +const migrationFilenamePattern = /^\d{14}_[a-z0-9_]+\.sql$/; + +class SafeMigrationError extends Error {} + +async function loadMigrationFiles(migrationsDirectory) { + let entries; + try { + entries = await readdir(migrationsDirectory, { withFileTypes: true }); + } catch { + throw new SafeMigrationError("unable to read migrations directory"); + } + + return Promise.all( + entries + .filter( + (entry) => entry.isFile() && migrationFilenamePattern.test(entry.name), + ) + .map((entry) => entry.name) + .sort() + .map(async (filename) => { + const bytes = await readFile(resolve(migrationsDirectory, filename)); + return { + filename, + bytes, + checksum: createHash("sha256").update(bytes).digest("hex"), + }; + }), + ); +} + +async function readLedger(client) { + const ledgerResult = await client.query( + "select to_regclass('migration.schema_migrations') as ledger", + ); + if (ledgerResult.rows[0]?.ledger === null) return new Map(); + + const result = await client.query( + "select filename, checksum from migration.schema_migrations", + ); + return new Map(result.rows.map((row) => [row.filename, row.checksum])); +} + +export async function runMigrations({ + connectionString, + migrationsDirectory, + logger = console, + check = false, +}) { + const files = await loadMigrationFiles(migrationsDirectory); + const client = new Client({ connectionString }); + let locked = false; + + try { + await client.connect(); + await client.query( + "select pg_advisory_lock(hashtext('jyotisha_schema_migrations'))", + ); + locked = true; + + if (check) { + const ledger = await readLedger(client); + const reviewedFilenames = new Set(files.map((file) => file.filename)); + const pending = []; + + for (const filename of ledger.keys()) { + if (!reviewedFilenames.has(filename)) { + if (!migrationFilenamePattern.test(filename)) { + throw new SafeMigrationError( + "migration ledger contains an invalid filename", + ); + } + throw new SafeMigrationError(`migration file missing: ${filename}`); + } + } + + for (const file of files) { + const recordedChecksum = ledger.get(file.filename); + if (recordedChecksum === undefined) { + pending.push(file.filename); + } else if (recordedChecksum !== file.checksum) { + throw new SafeMigrationError( + `migration checksum mismatch: ${file.filename}`, + ); + } + } + + for (const filename of pending) logger.log(filename); + return pending.length === 0 ? 0 : 3; + } + + await client.query( + "create schema if not exists migration authorization schema_owner", + ); + await client.query("revoke all on schema migration from public"); + await client.query(` + create table if not exists migration.schema_migrations ( + filename text primary key, + checksum text not null check (length(checksum) = 64), + applied_at timestamptz not null default now() + ) + `); + await client.query( + "revoke all on table migration.schema_migrations from public", + ); + + const ledger = await readLedger(client); + for (const file of files) { + const recordedChecksum = ledger.get(file.filename); + if (recordedChecksum !== undefined) { + if (recordedChecksum !== file.checksum) { + throw new SafeMigrationError( + `migration checksum mismatch: ${file.filename}`, + ); + } + logger.log(`already applied ${file.filename}`); + continue; + } + + await client.query("begin"); + try { + await client.query(file.bytes.toString("utf8")); + await client.query( + "insert into migration.schema_migrations (filename, checksum) values ($1, $2)", + [file.filename, file.checksum], + ); + await client.query("commit"); + } catch { + await client.query("rollback"); + throw new SafeMigrationError(`migration failed: ${file.filename}`); + } + logger.log(`applied ${file.filename}`); + } + + return 0; + } finally { + if (locked) { + try { + await client.query( + "select pg_advisory_unlock(hashtext('jyotisha_schema_migrations'))", + ); + } catch { + // The connection may already be unusable; closing it still releases the lock. + } + } + await client.end().catch(() => {}); + } +} + +function requireSchemaDatabaseUrl(env) { + const value = env.SCHEMA_DATABASE_URL?.trim(); + if (!value) throw new SafeMigrationError("SCHEMA_DATABASE_URL is required"); + if (!value.startsWith("postgresql://")) { + throw new SafeMigrationError("SCHEMA_DATABASE_URL must be a PostgreSQL URL"); + } + return value; +} + +function safeErrorMessage(error) { + return error instanceof SafeMigrationError + ? error.message + : "database migration failed"; +} + +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; + +if (invokedPath === import.meta.url) { + const defaultDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + "../db/migrations", + ); + try { + const status = await runMigrations({ + connectionString: requireSchemaDatabaseUrl(process.env), + migrationsDirectory: + process.env.MIGRATIONS_DIRECTORY?.trim() || defaultDirectory, + check: process.argv.slice(2).includes("--check"), + }); + process.exitCode = status; + } catch (error) { + console.error(safeErrorMessage(error)); + process.exitCode = 1; + } +} diff --git a/frontend/src/lib/db/client.ts b/frontend/src/lib/db/client.ts new file mode 100644 index 00000000..b144f4d6 --- /dev/null +++ b/frontend/src/lib/db/client.ts @@ -0,0 +1,18 @@ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +export type DomainDatabase = { pool: Pool; db: NodePgDatabase }; + +export function createDomainDatabase( + connectionString: string, + maxConnections = 5, +): DomainDatabase { + const pool = new Pool({ + connectionString, + max: maxConnections, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + application_name: "jyotisha-web", + }); + return { pool, db: drizzle(pool) }; +} diff --git a/frontend/src/lib/db/config.ts b/frontend/src/lib/db/config.ts new file mode 100644 index 00000000..354abc2b --- /dev/null +++ b/frontend/src/lib/db/config.ts @@ -0,0 +1,16 @@ +export type DatabaseUrlKey = + | "IDENTITY_DATABASE_URL" + | "APP_DATABASE_URL" + | "ADMIN_DATABASE_URL"; + +export function readDatabaseUrl( + env: NodeJS.ProcessEnv, + key: DatabaseUrlKey, +): string { + const value = env[key]?.trim(); + if (!value) throw new Error(`${key} is required`); + if (!value.startsWith("postgresql://")) { + throw new Error(`${key} must be a PostgreSQL URL`); + } + return value; +} diff --git a/frontend/tests/database-foundation.test.ts b/frontend/tests/database-foundation.test.ts new file mode 100644 index 00000000..1191920d --- /dev/null +++ b/frontend/tests/database-foundation.test.ts @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { + appendFileSync, + copyFileSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { readDatabaseUrl } from "../src/lib/db/config"; +import { startPostgresFixture } from "./helpers/postgres-fixture"; + +const migrationFilename = "20260720000100_backend_foundation.sql"; +const pendingFilename = "20260720000200_pending_check.sql"; +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); +const migrationPath = fileURLToPath( + new URL(`../db/migrations/${migrationFilename}`, import.meta.url), +); +const fixturePasswords = [ + "schema-owner-test-password", + "identity-runtime-test-password", + "app-runtime-test-password", + "admin-runtime-test-password", + "migration-runner-test-password", + "backup-reader-test-password", + "postgres-test-password", +]; + +function runMigration( + connectionString: string, + options: { check?: boolean; migrationsDirectory?: string } = {}, +): SpawnSyncReturns { + return spawnSync( + process.execPath, + [runnerPath, ...(options.check ? ["--check"] : [])], + { + encoding: "utf8", + env: { + SCHEMA_DATABASE_URL: connectionString, + ...(options.migrationsDirectory + ? { MIGRATIONS_DIRECTORY: options.migrationsDirectory } + : {}), + } as NodeJS.ProcessEnv, + }, + ); +} + +function assertSafeOutput(result: SpawnSyncReturns): void { + const output = `${result.stdout}${result.stderr}`; + for (const password of fixturePasswords) { + assert.doesNotMatch(output, new RegExp(password)); + } +} + +test("readDatabaseUrl requires APP_DATABASE_URL", () => { + assert.throws( + () => readDatabaseUrl({} as NodeJS.ProcessEnv, "APP_DATABASE_URL"), + new Error("APP_DATABASE_URL is required"), + ); +}); + +test("migration runner applies once, detects drift, and checks read-only", () => { + const fixture = startPostgresFixture(); + const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-migrations-")); + const copiedMigration = join(temporaryDirectory, migrationFilename); + const schemaUrl = fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ); + const results: SpawnSyncReturns[] = []; + + try { + copyFileSync(migrationPath, copiedMigration); + + const missingLedgerCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(missingLedgerCheck); + assert.equal(missingLedgerCheck.status, 3); + assert.equal(missingLedgerCheck.stdout.trim(), migrationFilename); + assert.equal( + fixture.psql( + "select exists (select from pg_namespace where nspname = 'migration')", + ), + "f", + ); + + const firstRun = runMigration(schemaUrl); + results.push(firstRun); + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.match(firstRun.stdout, new RegExp(`applied ${migrationFilename}`)); + + const ledgerRow = fixture.psql(` + select filename || ':' || checksum || ':' || applied_at + from migration.schema_migrations + `); + const [filename, checksum] = ledgerRow.split(":"); + assert.equal(filename, migrationFilename); + assert.equal(checksum.length, 64); + + const secondRun = runMigration(schemaUrl); + results.push(secondRun); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.match( + secondRun.stdout, + new RegExp(`already applied ${migrationFilename}`), + ); + assert.equal( + fixture.psql(` + select filename || ':' || checksum || ':' || applied_at + from migration.schema_migrations + `), + ledgerRow, + ); + + const currentCheck = runMigration(schemaUrl, { check: true }); + results.push(currentCheck); + assert.equal(currentCheck.status, 0, currentCheck.stderr); + + const emptyDirectory = join(temporaryDirectory, "empty"); + mkdirSync(emptyDirectory); + const missingFileCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: emptyDirectory, + }); + results.push(missingFileCheck); + assert.equal(missingFileCheck.status, 1); + assert.match( + missingFileCheck.stderr, + new RegExp(`migration file missing: ${migrationFilename}`), + ); + + const pendingPath = join(temporaryDirectory, pendingFilename); + mkdirSync(dirname(pendingPath), { recursive: true }); + writeFileSync(pendingPath, "create schema check_mode_side_effect;\n"); + const ledgerCountBeforeCheck = fixture.psql( + "select count(*) from migration.schema_migrations", + ); + const pendingCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(pendingCheck); + assert.equal(pendingCheck.status, 3, pendingCheck.stderr); + assert.equal(pendingCheck.stdout.trim(), pendingFilename); + assert.equal( + fixture.psql("select count(*) from migration.schema_migrations"), + ledgerCountBeforeCheck, + ); + assert.equal( + fixture.psql( + "select exists (select from pg_namespace where nspname = 'check_mode_side_effect')", + ), + "f", + ); + + appendFileSync(copiedMigration, " "); + const driftCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(driftCheck); + assert.equal(driftCheck.status, 1); + assert.match( + driftCheck.stderr, + new RegExp(`migration checksum mismatch: ${migrationFilename}`), + ); + + const driftRun = runMigration(schemaUrl, { + migrationsDirectory: temporaryDirectory, + }); + results.push(driftRun); + assert.equal(driftRun.status, 1); + assert.match( + driftRun.stderr, + new RegExp(`migration checksum mismatch: ${migrationFilename}`), + ); + + assert.equal( + fixture.psql( + "select has_database_privilege('app_runtime', current_database(), 'create')", + ), + "f", + ); + assert.equal( + fixture.psql( + "select has_table_privilege('app_runtime', 'migration.schema_migrations', 'select')", + ), + "f", + ); + + for (const result of results) assertSafeOutput(result); + } finally { + fixture.stop(); + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +});