From 63a267760170ba5d76d65ac3772fe8b0fdfcc214 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 15:54:50 +0400 Subject: [PATCH] fix(db): add locale repair migration hygiene guard --- .github/workflows/ci.yml | 4 ++ package.json | 2 + packages/db/drizzle-checksums.json | 15 +++++ packages/db/drizzle/0009_quiet_wallflower.sql | 2 + packages/db/drizzle/meta/_journal.json | 7 +++ scripts/check-migration-hygiene.ts | 56 +++++++++++++++++++ scripts/update-migration-checksums.ts | 27 +++++++++ 7 files changed, 113 insertions(+) create mode 100644 packages/db/drizzle-checksums.json create mode 100644 packages/db/drizzle/0009_quiet_wallflower.sql create mode 100644 scripts/check-migration-hygiene.ts create mode 100644 scripts/update-migration-checksums.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 176486f..4d7114e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - test - build - db-check + - db-migrations steps: - name: Checkout @@ -74,6 +75,9 @@ jobs: db-check) bun run db:check ;; + db-migrations) + bun run db:migrations:check + ;; *) echo "Unknown task: ${{ matrix.task }}" exit 1 diff --git a/package.json b/package.json index f248aaf..6ab2a20 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "db:generate": "bunx drizzle-kit generate --config packages/db/drizzle.config.ts", "db:check": "bunx drizzle-kit check --config packages/db/drizzle.config.ts", "db:migrate": "bunx drizzle-kit migrate --config packages/db/drizzle.config.ts", + "db:migrations:check": "bun run scripts/check-migration-hygiene.ts", + "db:migrations:manifest": "bun run scripts/update-migration-checksums.ts", "db:push": "bunx drizzle-kit push --config packages/db/drizzle.config.ts", "db:studio": "bunx drizzle-kit studio --config packages/db/drizzle.config.ts", "db:seed": "set -a; [ -f .env ] && . ./.env; set +a; bun run --filter @household/db seed", diff --git a/packages/db/drizzle-checksums.json b/packages/db/drizzle-checksums.json new file mode 100644 index 0000000..ab4b969 --- /dev/null +++ b/packages/db/drizzle-checksums.json @@ -0,0 +1,15 @@ +{ + "algorithm": "sha256", + "files": { + "0000_modern_centennial.sql": "3529b04edcdbe2537466fe532f50779e3175d60c384eb2f77818a37231b65fac", + "0001_spicy_sersi.sql": "148421f99147fbac0c740d67c1a33f085540dd75375916e33821cc92eba9e35b", + "0002_tough_sandman.sql": "842e3e153760224e2d6710dd43f72a64cc0f4845f6467ad0747de29c273441d1", + "0003_mature_roulette.sql": "ea3eeba17984fedf862d05027013addfe8dc5997fad6236c41484b4fed2c0389", + "0004_big_ultimatum.sql": "d01fda1cc11720417bc5ef4d09eef0fc9ed82c0ca2ea4f34f7c06959751007e4", + "0005_free_kang.sql": "e749a58fadc9332ed7980ba498897e7094e583d95bead473ad88f162221b0e34", + "0006_marvelous_nehzno.sql": "369a862f68dede5568116e29865aa3c8a7a0ff494487af1b202b62932ffe83f4", + "0007_sudden_murmur.sql": "eedf265c46705c20be4ddb3e2bd7d9670108756915b911e580e19a00f8118104", + "0008_lowly_spiral.sql": "4f016332d60f7ef1fef0311210a0fa1a0bfc1d9b6da1da4380a60c14d54a9681", + "0009_quiet_wallflower.sql": "c5bcb6a01b6f22a9e64866ac0d11468105aad8b2afb248296370f15b462e3087" + } +} diff --git a/packages/db/drizzle/0009_quiet_wallflower.sql b/packages/db/drizzle/0009_quiet_wallflower.sql new file mode 100644 index 0000000..7948ae3 --- /dev/null +++ b/packages/db/drizzle/0009_quiet_wallflower.sql @@ -0,0 +1,2 @@ +ALTER TABLE "households" ADD COLUMN IF NOT EXISTS "default_locale" text DEFAULT 'ru' NOT NULL;--> statement-breakpoint +ALTER TABLE "members" ADD COLUMN IF NOT EXISTS "preferred_locale" text;--> statement-breakpoint diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 29ca766..95bb917 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1773047624171, "tag": "0008_lowly_spiral", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1773055200000, + "tag": "0009_quiet_wallflower", + "breakpoints": true } ] } diff --git a/scripts/check-migration-hygiene.ts b/scripts/check-migration-hygiene.ts new file mode 100644 index 0000000..7d7e3f7 --- /dev/null +++ b/scripts/check-migration-hygiene.ts @@ -0,0 +1,56 @@ +import { createHash } from 'node:crypto' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +interface ChecksumManifest { + algorithm: string + files: Record +} + +const rootDir = process.cwd() +const migrationDir = path.join(rootDir, 'packages', 'db', 'drizzle') +const manifestPath = path.join(rootDir, 'packages', 'db', 'drizzle-checksums.json') + +function sha256(input: string): string { + return createHash('sha256').update(input).digest('hex') +} + +async function main() { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ChecksumManifest + + if (manifest.algorithm !== 'sha256') { + throw new Error(`Unsupported migration checksum algorithm: ${manifest.algorithm}`) + } + + const files = (await readdir(migrationDir)).filter((entry) => entry.endsWith('.sql')).sort() + + const missingFromDisk = Object.keys(manifest.files).filter((file) => !files.includes(file)) + if (missingFromDisk.length > 0) { + throw new Error(`Missing committed migration files: ${missingFromDisk.join(', ')}`) + } + + const unexpectedFiles = files.filter((file) => !(file in manifest.files)) + if (unexpectedFiles.length > 0) { + throw new Error( + `New migrations must update packages/db/drizzle-checksums.json: ${unexpectedFiles.join(', ')}` + ) + } + + const changedFiles: string[] = [] + + for (const file of files) { + const sql = await readFile(path.join(migrationDir, file), 'utf8') + const actual = sha256(sql) + if (actual !== manifest.files[file]) { + changedFiles.push(file) + } + } + + if (changedFiles.length > 0) { + throw new Error(`Historical migration files changed: ${changedFiles.join(', ')}`) + } + + console.log(`Verified ${files.length} migration checksums`) +} + +await main() diff --git a/scripts/update-migration-checksums.ts b/scripts/update-migration-checksums.ts new file mode 100644 index 0000000..b93ebb4 --- /dev/null +++ b/scripts/update-migration-checksums.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto' +import { readdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const rootDir = process.cwd() +const migrationDir = path.join(rootDir, 'packages', 'db', 'drizzle') +const manifestPath = path.join(rootDir, 'packages', 'db', 'drizzle-checksums.json') + +function sha256(input: string): string { + return createHash('sha256').update(input).digest('hex') +} + +const files = (await readdir(migrationDir)).filter((entry) => entry.endsWith('.sql')).sort() + +const manifest = { + algorithm: 'sha256', + files: {} as Record +} + +for (const file of files) { + const sql = await readFile(path.join(migrationDir, file), 'utf8') + manifest.files[file] = sha256(sql) +} + +await writeFile(`${manifestPath}`, `${JSON.stringify(manifest, null, 2)}\n`) + +console.log(`Wrote checksums for ${files.length} migrations`)