diff --git a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts new file mode 100644 index 0000000000..f588258068 --- /dev/null +++ b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts @@ -0,0 +1,148 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { pruneServerPrismaArtifactsForTarget } from './buildServerBinaryArtifactPayload.js'; + +const PRISMA_NODE_ENGINE_FILE_NAMES = [ + 'libquery_engine-debian-openssl-3.0.x.so.node', + 'libquery_engine-linux-arm64-openssl-3.0.x.so.node', + 'libquery_engine-darwin.dylib.node', + 'libquery_engine-darwin-arm64.dylib.node', + 'query_engine-windows.dll.node', +]; + +const PRISMA_RUNTIME_FILE_NAMES = [ + // Providers actually reachable via ServerDbProvider ('sqlite' | 'mysql') plus the always-generated + // 'postgres' default client -- these must survive pruning. + 'query_engine_bg.postgresql.wasm-base64.js', + 'query_engine_bg.postgresql.wasm-base64.mjs', + 'query_engine_bg.mysql.wasm-base64.js', + 'query_engine_bg.mysql.wasm-base64.mjs', + 'query_engine_bg.sqlite.wasm-base64.js', + 'query_engine_bg.sqlite.wasm-base64.mjs', + // Providers never reachable through resolveRequestedServerDbProviders/BuildDbProvider -- must be pruned. + 'query_engine_bg.cockroachdb.wasm-base64.js', + 'query_engine_bg.cockroachdb.wasm-base64.mjs', + 'query_engine_bg.sqlserver.wasm-base64.js', + 'query_engine_bg.sqlserver.wasm-base64.mjs', + // Sourcemaps -- never needed at runtime, must be pruned regardless of provider. + 'binary.js.map', + 'binary.mjs.map', + 'index-browser.js.map', +]; + +const tempDirs: string[] = []; + +async function createTempPayloadDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'build-server-binary-artifact-payload-prisma-prune-')); + tempDirs.push(dir); + return dir; +} + +async function writeFixtureFile(path: string): Promise { + await mkdir(join(path, '..'), { recursive: true }); + await writeFile(path, 'fixture', 'utf8'); +} + +async function buildFakePrismaClientDirTree(payloadDir: string, relativeDir: string): Promise { + const dirPath = join(payloadDir, relativeDir); + for (const fileName of PRISMA_NODE_ENGINE_FILE_NAMES) { + await writeFixtureFile(join(dirPath, fileName)); + } + return dirPath; +} + +async function buildFakePrismaClientRuntimeDirTree(payloadDir: string): Promise { + const dirPath = join(payloadDir, 'node_modules', '@prisma', 'client', 'runtime'); + for (const fileName of PRISMA_RUNTIME_FILE_NAMES) { + await writeFixtureFile(join(dirPath, fileName)); + } + // A file that is neither a per-provider engine file nor a sourcemap must survive untouched. + await writeFixtureFile(join(dirPath, 'index.js')); + return dirPath; +} + +describe('pruneServerPrismaArtifactsForTarget', () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(async (dir) => { + await rm(dir, { recursive: true, force: true }); + })); + }); + + it('keeps only the linux-arm64 engine file in each generated provider client directory', async () => { + const payloadDir = await createTempPayloadDir(); + const sqliteClientDir = await buildFakePrismaClientDirTree(payloadDir, join('generated', 'sqlite-client')); + const mysqlClientDir = await buildFakePrismaClientDirTree(payloadDir, join('generated', 'mysql-client')); + const dotPrismaClientDir = await buildFakePrismaClientDirTree(payloadDir, join('node_modules', '.prisma', 'client')); + + await pruneServerPrismaArtifactsForTarget({ + payloadDir, + target: { bunTarget: 'bun-linux-arm64', os: 'linux', arch: 'arm64', exeExt: '' }, + }); + + for (const dir of [sqliteClientDir, mysqlClientDir, dotPrismaClientDir]) { + const remaining = await readdir(dir); + expect(remaining).toEqual(['libquery_engine-linux-arm64-openssl-3.0.x.so.node']); + } + }); + + it('keeps only the darwin-arm64 engine file for a darwin-arm64 target', async () => { + const payloadDir = await createTempPayloadDir(); + const sqliteClientDir = await buildFakePrismaClientDirTree(payloadDir, join('generated', 'sqlite-client')); + + await pruneServerPrismaArtifactsForTarget({ + payloadDir, + target: { bunTarget: 'bun-darwin-arm64', os: 'darwin', arch: 'arm64', exeExt: '' }, + }); + + const remaining = await readdir(sqliteClientDir); + expect(remaining).toEqual(['libquery_engine-darwin-arm64.dylib.node']); + }); + + it('prunes cockroachdb/sqlserver runtime WASM engines and all sourcemaps from @prisma/client/runtime, keeping reachable providers and unrelated files', async () => { + const payloadDir = await createTempPayloadDir(); + const runtimeDir = await buildFakePrismaClientRuntimeDirTree(payloadDir); + + await pruneServerPrismaArtifactsForTarget({ + payloadDir, + target: { bunTarget: 'bun-linux-arm64', os: 'linux', arch: 'arm64', exeExt: '' }, + }); + + const remaining = await readdir(runtimeDir); + expect(remaining.sort()).toEqual([ + 'index.js', + 'query_engine_bg.mysql.wasm-base64.js', + 'query_engine_bg.mysql.wasm-base64.mjs', + 'query_engine_bg.postgresql.wasm-base64.js', + 'query_engine_bg.postgresql.wasm-base64.mjs', + 'query_engine_bg.sqlite.wasm-base64.js', + 'query_engine_bg.sqlite.wasm-base64.mjs', + ].sort()); + }); + + it('is a no-op when the expected Prisma directories are absent', async () => { + const payloadDir = await createTempPayloadDir(); + + await expect(pruneServerPrismaArtifactsForTarget({ + payloadDir, + target: { bunTarget: 'bun-linux-arm64', os: 'linux', arch: 'arm64', exeExt: '' }, + })).resolves.toBeUndefined(); + }); + + it('rejects instead of silently skipping pruning when an expected Prisma directory path is actually a file', async () => { + const payloadDir = await createTempPayloadDir(); + const dotPrismaClientPath = join(payloadDir, 'node_modules', '.prisma', 'client'); + await mkdir(join(dotPrismaClientPath, '..'), { recursive: true }); + // A file where a directory is expected (e.g. from a corrupted staging step) must surface as an + // error, not be silently treated as "directory has no files to prune". + await writeFile(dotPrismaClientPath, 'not a directory', 'utf8'); + + await expect(pruneServerPrismaArtifactsForTarget({ + payloadDir, + target: { bunTarget: 'bun-linux-arm64', os: 'linux', arch: 'arm64', exeExt: '' }, + })).rejects.toMatchObject({ code: 'ENOTDIR' }); + }); +}); diff --git a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts index 987323587a..b398cfaaba 100644 --- a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts +++ b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts @@ -1,4 +1,5 @@ -import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { cp, mkdir, readdir, rm, stat } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; @@ -33,6 +34,77 @@ async function ensureFile(path: string, message: string): Promise { } } +// Prisma client directories are always staged before pruning runs (resolveServerBinarySidecarEntries +// asserts their existence), so a missing directory here is not itself an error worth failing the +// build over -- but any other readdir failure (permission denied, path is actually a file, ...) must +// not be silently treated as "nothing to prune": that would let foreign-platform engines, unreachable +// WASM engines, or sourcemaps survive into the release artifact while validateServerPrismaEnginesForTarget +// still passes (it only checks that the *kept* engine file exists, not that pruning actually ran). +async function readdirOrEmptyIfMissing(directoryPath: string): Promise[]> { + return readdir(directoryPath, { withFileTypes: true, encoding: 'utf8' }).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return []; + throw error; + }); +} + +// Each generated Prisma client directory (generated/*-client, node_modules/.prisma/client) ships a +// native query-engine file per platform (binaryTargets in schema.prisma lists all 5: linux-x64, +// linux-arm64, darwin-x64, darwin-arm64, windows-x64), but a single-platform release payload only +// ever runs on the one platform it was built for. Keep only that target's engine file. +async function pruneNonTargetPrismaEngineFiles(directoryPath: string, target: BinaryTarget): Promise { + const keepFileName = resolvePrismaEngineFileNameForTarget(target); + const entries = await readdirOrEmptyIfMissing(directoryPath); + for (const entry of entries) { + if (!entry.isFile()) continue; + const isEngineFile = entry.name.startsWith('libquery_engine-') || entry.name.startsWith('query_engine-'); + if (isEngineFile && entry.name !== keepFileName) { + await rm(join(directoryPath, entry.name), { force: true }); + } + } +} + +// @prisma/client's runtime/ directory bundles WASM query engines for every database Prisma +// supports (postgresql, mysql, sqlite, cockroachdb, sqlserver) plus .map sourcemaps for every +// bundled format, regardless of which providers this build actually generated clients for. +// ServerDbProvider (serverSidecars.ts) is only ever 'sqlite' | 'mysql', and a postgres client is +// always generated as the default -- cockroachdb and sqlserver are never reachable, and +// sourcemaps are never needed by a production binary. Delete both classes unconditionally. +const PRISMA_RUNTIME_NEVER_REACHABLE_PROVIDER_MARKERS = ['.cockroachdb.', '.sqlserver.']; + +async function pruneUnreachablePrismaRuntimeFiles(runtimeDirectoryPath: string): Promise { + const entries = await readdirOrEmptyIfMissing(runtimeDirectoryPath); + for (const entry of entries) { + if (!entry.isFile()) continue; + const isNeverReachableProviderFile = PRISMA_RUNTIME_NEVER_REACHABLE_PROVIDER_MARKERS.some( + (marker) => entry.name.includes(marker), + ); + const isSourceMap = entry.name.endsWith('.map'); + if (isNeverReachableProviderFile || isSourceMap) { + await rm(join(runtimeDirectoryPath, entry.name), { force: true }); + } + } +} + +export async function pruneServerPrismaArtifactsForTarget({ + payloadDir, + target, +}: { + payloadDir: string; + target: BinaryTarget; +}): Promise { + await pruneNonTargetPrismaEngineFiles(join(payloadDir, 'node_modules', '.prisma', 'client'), target); + + const generatedDir = join(payloadDir, 'generated'); + const generatedEntries = await readdirOrEmptyIfMissing(generatedDir); + for (const entry of generatedEntries) { + if (entry.isDirectory() && entry.name.endsWith('-client')) { + await pruneNonTargetPrismaEngineFiles(join(generatedDir, entry.name), target); + } + } + + await pruneUnreachablePrismaRuntimeFiles(join(payloadDir, 'node_modules', '@prisma', 'client', 'runtime')); +} + async function validateServerPrismaEnginesForTarget({ payloadDir, target, @@ -159,6 +231,8 @@ export async function buildServerBinaryArtifactPayload({ }); } + await pruneServerPrismaArtifactsForTarget({ payloadDir, target }); + await validateServerPrismaEnginesForTarget({ payloadDir, target,