import assert from 'node:assert/strict' import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const MANIFEST = 'workers/database-migrator/package.json' const LOCK = 'workers/database-migrator/package-lock.json' const DIRECT_DEPENDENCIES = ['@prisma/client', 'prisma', 'tsx', 'zod'] // Preserve the main lock's exact locations, versions and integrity hashes. In // particular Prisma 5's nested engines must not resolve to the app's Prisma 6 // engines. npm ci consumes this subset without resolving a new dependency tree. export function createMigratorLock(rootLock, manifest) { assert.equal(rootLock.lockfileVersion, 3, 'Main lock must use lockfileVersion 3') assert.deepEqual(Object.keys(manifest.dependencies).sort(), DIRECT_DEPENDENCIES, 'Migrator direct dependencies must be explicitly reviewed') assert.ok(!manifest.devDependencies && !manifest.optionalDependencies, 'Migrator manifest cannot introduce unreviewed dependency groups') const packages = {} const visit = packagePath => { if (packages[packagePath]) return const record = rootLock.packages[packagePath] assert.ok(record && !record.link, `Missing or linked dependency: ${packagePath}`) assert.ok(record.version && record.resolved && record.integrity, `Dependency must have a pinned version, source and integrity: ${packagePath}`) const copy = structuredClone(record) // These packages are all runtime dependencies of the one-shot image. delete copy.dev delete copy.devOptional packages[packagePath] = copy const resolve = name => { let parent = packagePath while (parent) { const candidate = `${parent}/node_modules/${name}` if (rootLock.packages[candidate]) return candidate const index = parent.lastIndexOf('/node_modules/') parent = index < 0 ? '' : parent.slice(0, index) } const candidate = `node_modules/${name}` assert.ok(rootLock.packages[candidate], `Unresolved dependency ${name} from ${packagePath}`) return candidate } for (const name of Object.keys({ ...record.dependencies, ...record.optionalDependencies })) { visit(resolve(name)) } for (const name of Object.keys(record.peerDependencies ?? {})) { // A newly introduced optional peer could change runtime behavior even // when npm accepts its absence. Review it instead of silently omitting it. if (record.peerDependenciesMeta?.[name]?.optional) { assert.ok(packagePath === 'node_modules/@prisma/client' && name === 'prisma', `Unreviewed optional peer ${name} from ${packagePath}`) } visit(resolve(name)) } } for (const [name, version] of Object.entries(manifest.dependencies)) { const packagePath = `node_modules/${name}` assert.equal(rootLock.packages[packagePath]?.version, version, `${name} differs from the main lock; review and update the migrator manifest`) visit(packagePath) } return { name: manifest.name, version: manifest.version, lockfileVersion: 3, requires: true, packages: { '': { name: manifest.name, version: manifest.version, dependencies: manifest.dependencies, engines: manifest.engines, }, ...Object.fromEntries(Object.entries(packages).sort(([a], [b]) => a.localeCompare(b, 'en'))), }, } } async function main() { const [mode = '--check', ...extra] = process.argv.slice(2) assert.ok(['--check', '--write'].includes(mode) && extra.length === 0, 'Usage: node scripts/database-migrator-lock.mjs [--check|--write]') const rootLock = JSON.parse(await readFile(path.join(ROOT, 'package-lock.json'), 'utf8')) const manifest = JSON.parse(await readFile(path.join(ROOT, MANIFEST), 'utf8')) const expected = createMigratorLock(rootLock, manifest) if (mode === '--write') { await writeFile(path.join(ROOT, LOCK), `${JSON.stringify(expected, null, 2)}\n`) } else { const actual = JSON.parse(await readFile(path.join(ROOT, LOCK), 'utf8')) assert.deepEqual(actual, expected, 'Migrator lock differs from the main lock. Run node scripts/database-migrator-lock.mjs --write and review it.') } console.log(`Migrator lock ${mode === '--write' ? 'written' : 'verified'}: ${Object.keys(expected.packages).length - 1} pinned packages`) } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch(error => { console.error(error.message); process.exitCode = 1 }) }