Files
Ncurator/scripts/release/shared/sqlite-migration-lock.mjs
T

86 lines
3.0 KiB
JavaScript

import { createHash } from 'node:crypto'
import { readdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const root = process.cwd()
const migrationsDirectory = path.join(
root,
'src',
'main',
'services',
'storage',
'sqlite',
'migrations'
)
const valueAfter = (name) => {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : undefined
}
const lockPath = path.resolve(
valueAfter('--lock-file') || path.join(migrationsDirectory, 'released-migrations.json')
)
const migrationFilePattern = /^(\d{3})_[a-z0-9_]+\.ts$/u
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
/** Normalize CRLF so Windows checkout with autocrlf still matches published LF hashes. */
const normalizeMigrationBytes = (bytes) => Buffer.from(bytes.toString('utf8').replace(/\r\n/gu, '\n'))
const readMigration = async (file) => {
const bytes = normalizeMigrationBytes(await readFile(path.join(migrationsDirectory, file)))
const source = bytes.toString('utf8')
const version = Number(file.match(migrationFilePattern)?.[1])
const declaredVersion = Number(source.match(/\bversion:\s*(\d+)/u)?.[1])
const name = source.match(/\bname:\s*'([^']+)'/u)?.[1] || ''
if (!version || declaredVersion !== version || !name) {
throw new Error(`invalid SQLite migration definition: ${file}`)
}
return { version, name, file, sha256: sha256(bytes) }
}
const readCurrentMigrations = async () => {
const files = (await readdir(migrationsDirectory))
.filter((file) => migrationFilePattern.test(file))
.sort()
const migrations = await Promise.all(files.map(readMigration))
migrations.forEach((migration, index) => {
if (migration.version !== index + 1) {
throw new Error(
`SQLite migration files must be contiguous: expected ${index + 1}, found ${migration.version}`
)
}
})
return migrations
}
const readLock = async () => JSON.parse(await readFile(lockPath, 'utf8'))
const validateLock = async (lock, current) => {
if (lock.publishedThrough !== lock.migrations.length) {
throw new Error('SQLite migration lock publishedThrough does not match its entries')
}
for (const locked of lock.migrations) {
const actual = current[locked.version - 1]
if (!actual || JSON.stringify(actual) !== JSON.stringify(locked)) {
throw new Error(
`published SQLite migration ${locked.version} changed; published migrations are immutable`
)
}
}
}
const current = await readCurrentMigrations()
const lock = await readLock()
await validateLock(lock, current)
if (process.argv.includes('--promote-current')) {
const nextLock = { publishedThrough: current.length, migrations: current }
await writeFile(lockPath, `${JSON.stringify(nextLock, null, 2)}\n`, 'utf8')
process.stdout.write(`[release] locked SQLite migrations through version ${current.length}\n`)
} else {
process.stdout.write(
`[release] verified SQLite migrations through published version ${lock.publishedThrough}\n`
)
}