feat: add reviewed postgres migration foundation

This commit is contained in:
Jesse_Chen
2026-07-20 22:12:55 +08:00
parent 1afbc23e19
commit 69b38ee6ed
8 changed files with 750 additions and 3 deletions
+2
View File
@@ -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
@@ -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;
+288 -2
View File
@@ -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",
+6 -1
View File
@@ -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",
+192
View File
@@ -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;
}
}
+18
View File
@@ -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) };
}
+16
View File
@@ -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;
}
+205
View File
@@ -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<string> {
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<string>): 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<string>[] = [];
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 });
}
});