From 1b51548ae417ebe7c1f1b0eb733a578b29c94fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Guldmund?= Date: Thu, 6 Aug 2026 20:49:03 +0200 Subject: [PATCH 1/3] build(server-release): prune foreign-platform and unreachable Prisma artifacts The packaged server binary artifact shipped Prisma query-engine binaries for all 5 platforms (schema.prisma's binaryTargets lists linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows) even though a release build only ever targets one, plus WASM engines for cockroachdb/sqlserver (never a reachable ServerDbProvider) and .map sourcemaps in @prisma/client/runtime. On a linux-arm64 build this drops the artifact 656MB -> 392MB (-40%), verified against a real local build (real bun compile + Prisma generate) and confirmed both sqlite and mysql providers still resolve their query engine correctly at runtime in a container. --- ...yArtifactPayload.prismaEnginePrune.test.ts | 134 ++++++++++++++++++ .../buildServerBinaryArtifactPayload.ts | 62 +++++++- 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts 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..385e2fd804 --- /dev/null +++ b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts @@ -0,0 +1,134 @@ +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(); + }); +}); diff --git a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts index 987323587a..5cc70cea60 100644 --- a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts +++ b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { cp, mkdir, readdir, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; @@ -33,6 +33,64 @@ async function ensureFile(path: string, message: string): Promise { } } +// 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 readdir(directoryPath, { withFileTypes: true }).catch(() => []); + 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 readdir(runtimeDirectoryPath, { withFileTypes: true }).catch(() => []); + 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 readdir(generatedDir, { withFileTypes: true }).catch(() => []); + 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 +217,8 @@ export async function buildServerBinaryArtifactPayload({ }); } + await pruneServerPrismaArtifactsForTarget({ payloadDir, target }); + await validateServerPrismaEnginesForTarget({ payloadDir, target, From 57dd8ff70762c2349a27d3924c1218e8fdc244f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Guldmund?= Date: Thu, 6 Aug 2026 23:18:42 +0200 Subject: [PATCH 2/3] fix(server-release): rethrow non-ENOENT errors during Prisma pruning Address CodeRabbit/Greptile review feedback on #231: the pruning helpers swallowed every readdir failure into an empty directory, not just a missing one. A permission error or an expected directory path that's actually a file would silently skip pruning for that directory while validateServerPrismaEnginesForTarget still passes (it only checks that the kept engine file exists, not that pruning ran) -- letting foreign-platform engines, unreachable WASM engines, or sourcemaps survive into the release artifact undetected. Only ENOENT is now treated as "nothing to prune"; every other error propagates and fails the build. --- ...yArtifactPayload.prismaEnginePrune.test.ts | 14 +++++++++++++ .../buildServerBinaryArtifactPayload.ts | 20 ++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts index 385e2fd804..670b0749ff 100644 --- a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts +++ b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts @@ -131,4 +131,18 @@ describe('pruneServerPrismaArtifactsForTarget', () => { 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.toThrow(); + }); }); diff --git a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.ts index 5cc70cea60..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, 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,13 +34,26 @@ 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 readdir(directoryPath, { withFileTypes: true }).catch(() => []); + 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-'); @@ -58,7 +72,7 @@ async function pruneNonTargetPrismaEngineFiles(directoryPath: string, target: Bi const PRISMA_RUNTIME_NEVER_REACHABLE_PROVIDER_MARKERS = ['.cockroachdb.', '.sqlserver.']; async function pruneUnreachablePrismaRuntimeFiles(runtimeDirectoryPath: string): Promise { - const entries = await readdir(runtimeDirectoryPath, { withFileTypes: true }).catch(() => []); + const entries = await readdirOrEmptyIfMissing(runtimeDirectoryPath); for (const entry of entries) { if (!entry.isFile()) continue; const isNeverReachableProviderFile = PRISMA_RUNTIME_NEVER_REACHABLE_PROVIDER_MARKERS.some( @@ -81,7 +95,7 @@ export async function pruneServerPrismaArtifactsForTarget({ await pruneNonTargetPrismaEngineFiles(join(payloadDir, 'node_modules', '.prisma', 'client'), target); const generatedDir = join(payloadDir, 'generated'); - const generatedEntries = await readdir(generatedDir, { withFileTypes: true }).catch(() => []); + const generatedEntries = await readdirOrEmptyIfMissing(generatedDir); for (const entry of generatedEntries) { if (entry.isDirectory() && entry.name.endsWith('-client')) { await pruneNonTargetPrismaEngineFiles(join(generatedDir, entry.name), target); From 2f6e872c8fd5cb64ef018e26abfaebfd9faf363f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Guldmund?= Date: Thu, 6 Aug 2026 23:23:59 +0200 Subject: [PATCH 3/3] test(server-release): assert ENOTDIR code on the directory-is-a-file test Address CodeRabbit follow-up on #231: the new rejection test only asserted .rejects.toThrow(), which would also pass on an unrelated error. Assert the specific ENOTDIR code so the test can't silently pass for the wrong reason. --- .../buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts index 670b0749ff..f588258068 100644 --- a/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts +++ b/packages/cli-common/src/componentArtifacts/buildServerBinaryArtifactPayload.prismaEnginePrune.test.ts @@ -143,6 +143,6 @@ describe('pruneServerPrismaArtifactsForTarget', () => { await expect(pruneServerPrismaArtifactsForTarget({ payloadDir, target: { bunTarget: 'bun-linux-arm64', os: 'linux', arch: 'arm64', exeExt: '' }, - })).rejects.toThrow(); + })).rejects.toMatchObject({ code: 'ENOTDIR' }); }); });