diff --git a/package-lock.json b/package-lock.json index ae728a43..d04b76ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "license": "MIT", "dependencies": { "@neondatabase/api-client": "^1.10.0", + "@neondatabase/serverless": "^0.10.4", "@segment/analytics-node": "^1.0.0-beta.26", "axios": "^1.4.0", "axios-debug-log": "^1.0.0", - "bun": "^1.1.21", "chalk": "^5.2.0", "cli-table": "^0.3.11", "crypto-random-string": "^5.0.0", @@ -940,6 +940,14 @@ "axios": "^1.6.0" } }, + "node_modules/@neondatabase/serverless": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz", + "integrity": "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==", + "dependencies": { + "@types/pg": "8.11.6" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -1105,110 +1113,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.1.21.tgz", - "integrity": "sha512-n1hZewJPZg5XcubisWDaKn/wLaldgagAWya3ZuMBuFwsz4PnGTeQ7Wl3aBe7XzW6fNUAd+ZIfvfNYBRNv1R7Rw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.1.21.tgz", - "integrity": "sha512-Vr7tz6UBrtkJ0UMCQBRhKH/JThWxkZWnGAmcGFf8h3zFgMfCaTmmWzB4PSCad1wu+4GCrmVoEG8P7MY8+TmS7w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.1.21.tgz", - "integrity": "sha512-4MhDFYONGIg2MqO56u6H/X9TD3+hbDQpOjlGdl7J0aUiV47b3k7vLn5hENYEjAIBR3g744E23rIw4FQAXakFMw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.1.21.tgz", - "integrity": "sha512-0avxsNle8QOLsDwo1lqO1o2Mv1bLp3RlVr83XNV2yGVnzCwZmupQcI76fcc2e+Y+YU173xCUasMkiIbguS271g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.1.21.tgz", - "integrity": "sha512-zmps8oWLE2L+9Cn6oQPbcxIWDIjOT1txbYAv9zlcd84I12DXiB++e/PEE8dPe/3powygCpwZM9b7gZfTv9sx0w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.1.21.tgz", - "integrity": "sha512-HT+PEWa2PY73gBrNuUHrihsGNOBQKp6s6IzAqHUfmDlIyXYaEvRYUZg6vEqyRRSuNcCC6PiQDHWZP99OT2VMZg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.1.21.tgz", - "integrity": "sha512-p9rjwZPiJJtBafJ7MoJvmqyCA4QxVVpM7QaDx6Lhqua7b+i7dsigog8BgeCxGXAMpSKqoBuAuziqnLh0pcdAYQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.1.21.tgz", - "integrity": "sha512-xwPqSrcdSAJVmCnDlpvEWVHDSf9lmCBIcL5PtM9udrqTJOAVxiyQm0cpXjuv/h6MAZxt7rtt9YqrcK0ixA2xIQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "dev": true, @@ -2064,7 +1968,6 @@ }, "node_modules/@types/node": { "version": "18.19.41", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -2075,6 +1978,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.11.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", + "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, "node_modules/@types/prompts": { "version": "2.4.9", "dev": true, @@ -2850,36 +2763,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bun": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.1.21.tgz", - "integrity": "sha512-mvqYEvafGskIVTjlftbKvsXtyR6z/SQnhJsVw0xCU46pc56oX1sAGvaemWKOy/sy/gGMHcgLE0KUidDQQzqXWQ==", - "cpu": [ - "arm64", - "x64" - ], - "hasInstallScript": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bun.exe" - }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.1.21", - "@oven/bun-darwin-x64": "1.1.21", - "@oven/bun-darwin-x64-baseline": "1.1.21", - "@oven/bun-linux-aarch64": "1.1.21", - "@oven/bun-linux-x64": "1.1.21", - "@oven/bun-linux-x64-baseline": "1.1.21", - "@oven/bun-windows-x64": "1.1.21", - "@oven/bun-windows-x64-baseline": "1.1.21" - } - }, "node_modules/bun-types": { "version": "1.1.17", "dev": true, @@ -7597,14 +7480,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/npm/node_modules/cacache": { "version": "18.0.3", "dev": true, @@ -7885,7 +7760,7 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "10.4.5", + "version": "10.4.2", "dev": true, "inBundle": true, "license": "ISC", @@ -7900,6 +7775,9 @@ "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -9519,6 +9397,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "license": "MIT", @@ -9903,6 +9786,44 @@ "node": "*" } }, + "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==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" + }, + "node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/picocolors": { "version": "1.0.1", "dev": true, @@ -10190,6 +10111,46 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" + }, "node_modules/prebuild-install": { "version": "7.1.1", "dev": true, @@ -11997,7 +11958,6 @@ }, "node_modules/undici-types": { "version": "5.26.5", - "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index 3570dfdf..ab9dfbcb 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "@neondatabase/api-client": "^1.10.0", + "@neondatabase/serverless": "^0.10.4", "@segment/analytics-node": "^1.0.0-beta.26", "axios": "^1.4.0", "axios-debug-log": "^1.0.0", diff --git a/src/commands/index.ts b/src/commands/index.ts index 3b0ec5df..3e0eb054 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -10,6 +10,7 @@ import * as operations from './operations.js'; import * as cs from './connection_string.js'; import * as setContext from './set_context.js'; import * as bootstrap from './bootstrap/index.js'; +import * as migrations from './migrations.js'; export default [ auth, @@ -24,4 +25,5 @@ export default [ cs, setContext, bootstrap, + migrations, ]; diff --git a/src/commands/migrations.ts b/src/commands/migrations.ts new file mode 100644 index 00000000..2107f5b4 --- /dev/null +++ b/src/commands/migrations.ts @@ -0,0 +1,143 @@ +import yargs from 'yargs'; +import { writer } from '../writer.js'; +import { CommonProps } from '../types.js'; +import { + applyMigrations, + createNewMigration, + listMigrations, +} from './migrations_core.js'; + +export const command = 'migrations'; +export const describe = 'Manage database migrations'; +export const aliases = ['migration', 'migrate']; + +export const builder = (argv: yargs.Argv) => + argv + .usage('$0 migrations [options]') + .command( + ['new ', 'create '], + 'Create a new migration file', + (yargs) => + yargs + .positional('name', { + describe: + 'Migration name (alphanumeric, dashes and underscores only)', + type: 'string', + demandOption: true, + }) + .options({ + 'migrations-dir': { + describe: 'Migrations directory', + type: 'string', + default: './neon-migrations', + }, + }) + .check((argv) => { + // Validate migration name + if (!/^[a-zA-Z0-9_-]+$/.test(argv.name)) { + throw new Error( + 'Migration name must only contain alphanumeric characters, dashes and underscores', + ); + } + return true; + }), + (args) => createNewMigrationCommand(args), + ) + .command( + ['up', 'apply'], + 'Apply pending migrations', + (yargs) => + yargs.options({ + 'migrations-dir': { + describe: 'Migrations directory', + type: 'string', + default: './neon-migrations', + }, + 'db-url': { + describe: 'Database connection URL', + type: 'string', + demandOption: true, + }, + }), + (args) => applyMigrationsCommand(args), + ) + .command( + 'list', + 'List all migrations and their status', + (yargs) => + yargs.options({ + 'migrations-dir': { + describe: 'Migrations directory', + type: 'string', + default: './neon-migrations', + }, + 'db-url': { + describe: 'Database connection URL', + type: 'string', + demandOption: true, + }, + }), + (args) => listMigrationsCommand(args), + ); + +export const handler = (args: yargs.Argv) => { + return args; +}; + +function cliLogger(props: CommonProps) { + return (input: Record | Record[]) => { + if (Array.isArray(input)) { + writer(props).end(input, { fields: Object.keys(input[0]) }); + } else { + writer(props).end(input, { fields: Object.keys(input) }); + } + }; +} + +type CreateMigrationProps = CommonProps & { + name: string; + migrationsDir: string; +}; + +function createNewMigrationCommand(props: CreateMigrationProps) { + return createNewMigration({ + name: props.name, + migrationsDir: props.migrationsDir, + log: (obj) => { + cliLogger(props)(obj); + }, + processStdin: true, + }); +} + +type ApplyMigrationsProps = CommonProps & { + name: string; + dbUrl: string; + migrationsDir: string; +}; + +function applyMigrationsCommand(props: ApplyMigrationsProps) { + return applyMigrations({ + migrationsDir: props.migrationsDir, + dbUrl: props.dbUrl, + log: (obj) => { + cliLogger(props)(obj); + }, + }); +} + +type ListMigrationsProps = CommonProps & { + name: string; + dbUrl: string; + migrationsDir: string; +}; + +function listMigrationsCommand(props: ListMigrationsProps) { + return listMigrations({ + migrationsDir: props.migrationsDir, + dbUrl: props.dbUrl, + log: (obj) => { + cliLogger(props)(obj); + }, + }); +} diff --git a/src/commands/migrations_core.ts b/src/commands/migrations_core.ts new file mode 100644 index 00000000..8e0a5b36 --- /dev/null +++ b/src/commands/migrations_core.ts @@ -0,0 +1,340 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { neon } from '@neondatabase/serverless'; +import { createHash } from 'crypto'; + +async function readStdin(): Promise { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString().trim(); +} + +type CreateMigrationOptions = { + name: string; + migrationsDir: string; + upMigrationContent?: string; + downMigrationContent?: string; + log: MigrationLogger; + processStdin: boolean; +}; + +export async function createNewMigration({ + name, + migrationsDir, + upMigrationContent = '-- Write your migration SQL here\n', + downMigrationContent = '-- Write your down migration SQL here\n', + log, + processStdin, +}: CreateMigrationOptions) { + // Validate migration name - only allow alphanumeric, dashes and underscores + const validNamePattern = /^[a-zA-Z0-9-_]+$/; + if (!validNamePattern.test(name)) { + log({ + message: `Invalid migration name "${name}". Names can only contain letters, numbers, dashes and underscores.`, + }); + return; + } + + try { + await fs.mkdir(migrationsDir, { recursive: true }); + + // Read existing migration files + const files = await fs.readdir(migrationsDir); + const existingMigrationName = files.find((file) => + file.includes(`-${name}.`), + ); + + if (existingMigrationName) { + log({ + message: `Migration with name "${name}" already exists: ${existingMigrationName}`, + }); + return; + } + + // Calculate hashes for new migration content + const upHash = createHash('sha256') + .update(upMigrationContent) + .digest('hex'); + const downHash = createHash('sha256') + .update(downMigrationContent) + .digest('hex'); + + // Check for duplicate content in existing migrations + for (const file of files) { + if (file.endsWith('.up.sql')) { + const existingUpContent = await fs.readFile( + path.join(migrationsDir, file), + 'utf-8', + ); + const existingUpHash = createHash('sha256') + .update(existingUpContent) + .digest('hex'); + if (existingUpHash === upHash) { + log({ + message: `Duplicate migration content found: The up migration content matches existing file ${file}`, + }); + return; + } + + const downFile = file.replace('.up.sql', '.down.sql'); + const existingDownContent = await fs.readFile( + path.join(migrationsDir, downFile), + 'utf-8', + ); + const existingDownHash = createHash('sha256') + .update(existingDownContent) + .digest('hex'); + if (existingDownHash === downHash) { + log({ + message: `Duplicate migration content found: The down migration content matches existing file ${downFile}`, + }); + return; + } + } + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const upFilename = `${timestamp}-${name}.up.sql`; + const downFilename = `${timestamp}-${name}.down.sql`; + const upFilepath = path.join(migrationsDir, upFilename); + const downFilepath = path.join(migrationsDir, downFilename); + + let upContent = upMigrationContent; + if (!process.stdin.isTTY && processStdin) { + upContent = await readStdin(); + } + + await fs.writeFile(upFilepath, upContent); + await fs.writeFile(downFilepath, downMigrationContent); + + log({ message: 'Created new migration files:' }); + log({ + 'Forward migration file': upFilepath, + 'Backward Migration File': downFilepath, + }); + } catch (error: unknown) { + if (error instanceof Error) { + log({ + message: `Failed to create migration files: ${error.message}`, + }); + } else { + // eslint-disable-next-line no-console + console.error(error); + log({ + message: 'Failed to create migration files due to unexpected error.', + }); + } + } +} + +type ApplyMigrationsOptions = { + migrationsDir: string; + dbUrl: string; + log: MigrationLogger; +}; + +export async function applyMigrations({ + migrationsDir, + dbUrl, + log, +}: ApplyMigrationsOptions) { + try { + const sql = neon(dbUrl); + + // Check if migrations table exists + const [tableExists] = await sql` + SELECT EXISTS ( + SELECT 1 + FROM pg_tables + WHERE schemaname = 'neon_migrations' + AND tablename = 'migrations' + );`; + + // Read and sort migration files + const files = await fs.readdir(migrationsDir); + const migrationFiles = files.filter((f) => f.endsWith('.up.sql')).sort(); // Ensures timestamp order + + let appliedMigrations: { hash: string }[] = []; + if (tableExists.exists) { + appliedMigrations = (await sql` + SELECT hash FROM neon_migrations.migrations ORDER BY created_at ASC`) as { + hash: string; + }[]; + } + + // Loop both tables at the same time + const migrationsToBeApplied = []; + for ( + let i = 0; + i < Math.max(appliedMigrations.length, migrationFiles.length); + i++ + ) { + // More migrations remotely than locally + if (i >= migrationFiles.length) { + throw new Error('Migrations table is out of sync with migration files'); + } + + // More migrations locally than remotely + if (i >= appliedMigrations.length) { + migrationsToBeApplied.push(migrationFiles[i]); + continue; + } + + // Compare hashes + const content = await fs.readFile( + path.join(migrationsDir, migrationFiles[i]), + 'utf-8', + ); + const hash = createHash('sha256').update(content).digest('hex'); + + if (appliedMigrations[i].hash !== hash) { + throw new Error('Migrations table is out of sync with migration files'); + } + } + + if (migrationsToBeApplied.length > 0 && !tableExists.exists) { + await sql(`CREATE SCHEMA IF NOT EXISTS neon_migrations`); + + await sql(` + CREATE TABLE neon_migrations.migrations ( + id bigint GENERATED ALWAYS AS IDENTITY, + hash text NOT NULL, + created_at bigint + )`); + } + + // Apply pending migrations + const results = []; + for (const file of migrationsToBeApplied) { + const content = await fs.readFile( + path.join(migrationsDir, file), + 'utf-8', + ); + const hash = createHash('sha256').update(content).digest('hex'); + + // Apply migration + await sql.transaction([ + sql(content), + sql`INSERT INTO neon_migrations.migrations (hash, created_at) + VALUES (${hash}, ${new Date().getTime()})`, + ]); + + results.push({ + file, + status: 'applied', + hash, + }); + } + + log({ + message: `Ran ${results.length} migrations`, + }); + } catch (error: unknown) { + if (error instanceof Error) { + log({ + message: `Failed to apply migrations: ${error.message}`, + }); + } else { + // eslint-disable-next-line no-console + console.error(error); + log({ + message: 'Failed to apply migrations due to unexpected error.', + }); + } + } +} +// Add this helper function +function truncateHash(hash: string): string { + return `${hash.slice(0, 5)}...`; +} +type ListMigrationsOptions = { + migrationsDir: string; + dbUrl: string; + log: MigrationLogger; +}; + +export async function listMigrations({ + migrationsDir, + dbUrl, + log, +}: ListMigrationsOptions) { + try { + const sql = neon(dbUrl); + + // Get local migrations + const files = await fs.readdir(migrationsDir); + const migrationFiles = files.filter((f) => f.endsWith('.up.sql')).sort(); + + // Get remote migrations + const [tableExists] = await sql` + SELECT EXISTS ( + SELECT 1 + FROM pg_tables + WHERE schemaname = 'neon_migrations' + AND tablename = 'migrations' + );`; + + let remoteMigrations: { hash: string; created_at: string }[] = []; + if (tableExists.exists) { + remoteMigrations = (await sql` + SELECT hash, created_at + FROM neon_migrations.migrations + ORDER BY created_at ASC`) as { hash: string; created_at: string }[]; + } + + // Track all migrations (both local and remote) + const allMigrations = new Set(); + + // Add local migration hashes + const localMigrationMap = new Map(); // hash -> filename + for (const filename of migrationFiles) { + const content = await fs.readFile( + path.join(migrationsDir, filename), + 'utf-8', + ); + const hash = createHash('sha256').update(content).digest('hex'); + if (allMigrations.has(hash)) { + throw new Error(`Duplicate migration hash found: ${hash}`); + } + localMigrationMap.set(hash, filename); + allMigrations.add(hash); + } + + // Add remote migration hashes + remoteMigrations.forEach((m) => allMigrations.add(m.hash)); + + // Build results combining both local and remote information + const results = Array.from(allMigrations).map((hash) => { + const filename = localMigrationMap.get(hash); + const remoteMigration = remoteMigrations.find((m) => m.hash === hash); + + return { + filename, + hash: truncateHash(hash), + status: remoteMigration + ? `Applied at ${new Date(Number(remoteMigration.created_at)).toLocaleString()}` + : 'Not applied', + }; + }); + + log(results); + } catch (error: unknown) { + if (error instanceof Error) { + log({ + message: `Failed to list migrations: ${error.message}`, + }); + } else { + // eslint-disable-next-line no-console + console.error(error); + log({ + message: 'Failed to list migrations due to unexpected error.', + }); + } + } +} + +type MigrationLogger = ( + obj: Record | Record[], +) => void;