feat: add reviewed postgres migration foundation
This commit is contained in:
@@ -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;
|
||||
Generated
+288
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user