diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index eb5451fa..546cd4cc 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -182,6 +182,45 @@ You'll only meet this if you wrote the connection or the extension on one side of the wire — every block that ships with the framework supplies what it declares. +## When a deploy stops on an effect version conflict + +Before doing anything else, every `prisma-composer` command verifies that the +installed dependency tree gives alchemy (the deploy engine Composer drives) +the exact `effect` version `@prisma/composer` pins. When it doesn't, the +command stops immediately: + +```text +Error: Dependency conflict: alchemy resolves effect@, but +@prisma/composer requires effect@. Your package manager installed a +second effect that alchemy picks up; deploying with it would crash inside +alchemy. +``` + +This happens when another dependency in your app floats to a newer `effect` +and your package manager hoists that copy where alchemy resolves it — npm +allows this with only a warning, and without the check the deploy would crash +mid-run with a `TypeError` from inside alchemy. The fix is the one the error +prints — pin the version your package manager should use everywhere, in your +app's `package.json`: + +```json +"overrides": { "effect": "" } +``` + +yarn spells it `resolutions`: + +```json +"resolutions": { "effect": "" } +``` + +and pnpm nests it under `pnpm`: + +```json +"pnpm": { "overrides": { "effect": "" } } +``` + +Reinstall afterwards — the setting only takes effect when the tree is rebuilt. + ## Production behavior What deployed apps actually run into, and what to do about it: diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts new file mode 100644 index 00000000..c00471a9 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/check-effect-resolution.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + checkEffectResolution, + effectMismatchError, + findAlchemyPackageDir, + requiredEffectVersion, + resolveEffectVersionFrom, +} from '../check-effect-resolution.ts'; +import { CliError } from '../cli-error.ts'; + +const tmpDirs: string[] = []; + +function makeTmpDir(): string { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-cli-effect-')), + ); + tmpDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir !== undefined) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Lays down a resolvable package at `root/<...segments>` with the given manifest fields. */ +function writePackage( + root: string, + segments: readonly string[], + manifest: Record, +): string { + const dir = path.join(root, ...segments); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ main: 'index.js', ...manifest }), + ); + fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = {};\n'); + return dir; +} + +function writeHealthyTree(root: string, version = '4.0.0-beta.93'): void { + writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy', version: '2.0.0-beta.59' }); + writePackage(root, ['node_modules', 'effect'], { name: 'effect', version }); + writePackage(root, ['node_modules', '@prisma', 'composer'], { + name: '@prisma/composer', + version: '0.0.0', + dependencies: { effect: version }, + }); +} + +describe('findAlchemyPackageDir()', () => { + test('finds node_modules/alchemy in the starting directory', () => { + const root = makeTmpDir(); + const dir = writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy' }); + expect(findAlchemyPackageDir(root)).toBe(dir); + }); + + test('walks up through parents (hoisted layouts)', () => { + const root = makeTmpDir(); + const dir = writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy' }); + const nested = path.join(root, 'apps', 'my-app'); + fs.mkdirSync(nested, { recursive: true }); + expect(findAlchemyPackageDir(nested)).toBe(dir); + }); + + test('returns undefined when alchemy is not installed anywhere above', () => { + expect(findAlchemyPackageDir(makeTmpDir())).toBeUndefined(); + }); +}); + +describe('resolveEffectVersionFrom()', () => { + test("returns the version of the effect the package's position resolves", () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + const alchemyDir = path.join(root, 'node_modules', 'alchemy'); + expect(resolveEffectVersionFrom(alchemyDir)).toBe('4.0.0-beta.102'); + }); + + test('a copy nested inside the package wins over the root copy (Node resolution order)', () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + writePackage(root, ['node_modules', 'alchemy', 'node_modules', 'effect'], { + name: 'effect', + version: '4.0.0-beta.93', + }); + const alchemyDir = path.join(root, 'node_modules', 'alchemy'); + expect(resolveEffectVersionFrom(alchemyDir)).toBe('4.0.0-beta.93'); + }); + + test('returns undefined when effect is not resolvable from there', () => { + const root = makeTmpDir(); + const alchemyDir = writePackage(root, ['node_modules', 'alchemy'], { name: 'alchemy' }); + expect(resolveEffectVersionFrom(alchemyDir)).toBeUndefined(); + }); +}); + +describe('requiredEffectVersion()', () => { + test("reads the pin from @prisma/composer's installed package.json", () => { + const root = makeTmpDir(); + writeHealthyTree(root); + expect(requiredEffectVersion(root)).toBe('4.0.0-beta.93'); + }); + + test('returns undefined when @prisma/composer is not resolvable', () => { + expect(requiredEffectVersion(makeTmpDir())).toBeUndefined(); + }); +}); + +describe('effectMismatchError()', () => { + test('healthy: same version on both sides yields no error', () => { + expect(effectMismatchError('4.0.0-beta.93', '4.0.0-beta.93')).toBeUndefined(); + }); + + test('unknown on either side yields no error (the check must not misfire on unusual layouts)', () => { + expect(effectMismatchError(undefined, '4.0.0-beta.93')).toBeUndefined(); + expect(effectMismatchError('4.0.0-beta.102', undefined)).toBeUndefined(); + }); + + test('mismatch names found + required versions and the overrides fix, rendered from the pin', () => { + const message = effectMismatchError('4.0.0-beta.102', '4.0.0-beta.93'); + expect(message).toContain('alchemy resolves effect@4.0.0-beta.102'); + expect(message).toContain('requires effect@4.0.0-beta.93'); + expect(message).toContain('"overrides": { "effect": "4.0.0-beta.93" }'); + }); +}); + +describe('checkEffectResolution()', () => { + test('no-op on a healthy tree', () => { + const root = makeTmpDir(); + writeHealthyTree(root); + expect(() => checkEffectResolution(root)).not.toThrow(); + }); + + test("no-op when alchemy isn't installed (later steps report that with their own error)", () => { + expect(() => checkEffectResolution(makeTmpDir())).not.toThrow(); + }); + + test("throws the actionable CliError on the operator's broken shape: root effect@beta.102, composer's pin nested", () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + // The composer package pins beta.93 and got its own nested copy — exactly + // the tree npm builds when a floating @effect/* range hoists beta.102. + const composerDir = path.join(root, 'node_modules', '@prisma', 'composer'); + fs.writeFileSync( + path.join(composerDir, 'package.json'), + JSON.stringify({ + name: '@prisma/composer', + version: '0.0.0', + main: 'index.js', + dependencies: { effect: '4.0.0-beta.93' }, + }), + ); + expect(() => checkEffectResolution(root)).toThrow(CliError); + expect(() => checkEffectResolution(root)).toThrow( + /alchemy resolves effect@4\.0\.0-beta\.102, but @prisma\/composer requires effect@4\.0\.0-beta\.93/, + ); + }); + + test('runs from a nested app directory below the install root', () => { + const root = makeTmpDir(); + writeHealthyTree(root, '4.0.0-beta.102'); + const composerDir = path.join(root, 'node_modules', '@prisma', 'composer'); + fs.writeFileSync( + path.join(composerDir, 'package.json'), + JSON.stringify({ + name: '@prisma/composer', + version: '0.0.0', + main: 'index.js', + dependencies: { effect: '4.0.0-beta.93' }, + }), + ); + const nested = path.join(root, 'apps', 'my-app'); + fs.mkdirSync(nested, { recursive: true }); + expect(() => checkEffectResolution(nested)).toThrow(CliError); + expect(() => checkEffectResolution(nested)).toThrow(/Dependency conflict/); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/bin.ts b/packages/0-framework/3-tooling/cli/src/bin.ts index 80743b3f..d18c7cc8 100755 --- a/packages/0-framework/3-tooling/cli/src/bin.ts +++ b/packages/0-framework/3-tooling/cli/src/bin.ts @@ -1,4 +1,24 @@ #!/usr/bin/env node -import { cli } from './cli.ts'; +import { checkEffectResolution } from './check-effect-resolution.ts'; +import { CliError } from './cli-error.ts'; +// The effect preflight (TML-3158) must run BEFORE the rest of the CLI loads: +// the command modules transitively import alchemy's provider tree, which +// crashes at import time when the installed tree resolves a mismatched +// `effect` — exactly the break the check exists to explain. The dynamic import +// below keeps that graph out of this module's static graph, so the check gets +// to run first. It guards every command, not just the deploying ones: the +// graph loads whatever the argv says, so `--help` crashes in a broken tree too +// (proved by the adversarial shape in scripts/check-npm-effect-resolution.mjs). +try { + checkEffectResolution(process.cwd()); +} catch (error) { + if (error instanceof CliError) { + console.error(`Error: ${error.message}`); + process.exit(1); + } + throw error; +} + +const { cli } = await import('./cli.ts'); void cli(); diff --git a/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts b/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts new file mode 100644 index 00000000..94c62b44 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts @@ -0,0 +1,109 @@ +/** + * Deploy preflight (TML-3158): verify that alchemy resolves the exact `effect` + * version @prisma/composer pins. Package managers cannot be made to enforce + * this — alchemy's own peer range accepts newer effect betas, and npm resolves + * transitive peer conflicts with a warning, not a failure — so a floating + * `@effect/*` range anywhere in the consumer's tree can hoist a newer effect + * to the root, where alchemy picks it up and crashes mid-deploy + * (`TypeError: Schedule.either is not a function`). This check turns that + * silent break into a start-up error naming the fix. + */ +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { CliError } from './cli-error.ts'; + +/** Walks up from `startDir` looking for `node_modules/alchemy` (mirrors resolveAlchemyBin). Undefined when absent — the check skips rather than second-guessing later, clearer failures. */ +export function findAlchemyPackageDir(startDir: string): string | undefined { + let dir = startDir; + while (true) { + const candidate = path.join(dir, 'node_modules', 'alchemy'); + if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** + * The `effect` version Node gives `packageDir`'s code, resolved exactly as + * Node would: from the package's real path (so pnpm symlink layouts resolve + * from the package's own store position, like at runtime). Undefined when + * `effect` is not resolvable from there. + */ +export function resolveEffectVersionFrom(packageDir: string): string | undefined { + let entry: string; + try { + const requireFrom = createRequire(path.join(fs.realpathSync(packageDir), 'noop.js')); + entry = requireFrom.resolve('effect'); + } catch { + return undefined; + } + let dir = path.dirname(entry); + while (!fs.existsSync(path.join(dir, 'package.json'))) { + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } + const version: unknown = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'), + ).version; + return typeof version === 'string' ? version : undefined; +} + +/** + * The `effect` version @prisma/composer pins, read from its own installed + * package.json — the single source of truth; never hardcoded here. Undefined + * when @prisma/composer is not resolvable from `startDir` (e.g. the CLI is + * driven some other way), in which case the check skips. + * + * Assumes the pin is an exact version — effectMismatchError compares with + * `===`, which scripts/check-npm-effect-resolution.mjs enforces in CI; + * relaxing the pin to a range means revisiting that comparison. + */ +export function requiredEffectVersion(startDir: string): string | undefined { + let manifestPath: string; + try { + const requireFrom = createRequire(path.join(startDir, 'noop.js')); + manifestPath = requireFrom.resolve('@prisma/composer/package.json'); + } catch { + return undefined; + } + const manifest: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if (typeof manifest !== 'object' || manifest === null || !('dependencies' in manifest)) { + return undefined; + } + const dependencies = manifest.dependencies; + if (typeof dependencies !== 'object' || dependencies === null || !('effect' in dependencies)) { + return undefined; + } + const version = dependencies.effect; + return typeof version === 'string' ? version : undefined; +} + +/** Pure comparison + message rendering, separated so tests cover the rule without a filesystem. Returns the error message, or undefined when the tree is healthy or either side is unknown. */ +export function effectMismatchError( + found: string | undefined, + required: string | undefined, +): string | undefined { + if (found === undefined || required === undefined || found === required) return undefined; + return ( + `Dependency conflict: alchemy resolves effect@${found}, but @prisma/composer requires ` + + `effect@${required}. Your package manager installed a second effect that alchemy picks up; ` + + 'deploying with it would crash inside alchemy.\n\n' + + "Fix: add this to your app's package.json, then reinstall:\n\n" + + ` "overrides": { "effect": "${required}" }\n\n` + + '(npm uses "overrides"; yarn calls it "resolutions", pnpm "pnpm.overrides".)' + ); +} + +/** Runs the preflight from the app's directory; throws CliError on a mismatched tree, no-op otherwise. */ +export function checkEffectResolution(cwd: string): void { + const alchemyDir = findAlchemyPackageDir(cwd); + if (alchemyDir === undefined) return; + const message = effectMismatchError( + resolveEffectVersionFrom(alchemyDir), + requiredEffectVersion(cwd), + ); + if (message !== undefined) throw new CliError(message); +} diff --git a/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index 0cc49b8e..0d5e9d3f 100644 --- a/scripts/check-npm-effect-resolution.mjs +++ b/scripts/check-npm-effect-resolution.mjs @@ -14,13 +14,23 @@ // warns, so the break is invisible in-repo; this check installs the real // tarballs with real npm against the real registry. // +// A third, adversarial shape reproduces the tree that broke 0.6.0 in the +// field: the app ALSO depends on `@effect/platform-node-shared@^4.0.0-beta.93`, +// which npm floats to the newest beta, dragging its newer `effect` peer to the +// root — over our exact pins, with only a warning (empirically verified; a +// peerDependency does not prevent it either). Nothing we declare can stop +// that, so the acceptance there is the CLI's own start-up check: running the +// built `prisma-composer` in that broken tree must exit non-zero with our +// actionable error, not the Schedule TypeError. The healthy shapes assert the +// inverse: the check must NOT trip on a good tree. +// // Requires the two public packages to be built (`pnpm turbo build // --filter=@prisma/composer --filter=@prisma/composer-prisma-cloud`) and // network access to the npm registry. // // Usage: node scripts/check-npm-effect-resolution.mjs -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, @@ -86,15 +96,71 @@ function collectEffectVersions(node, found = new Map()) { return found; } -async function checkShape(label, tarballs) { +/** The stable marker of the CLI's own start-up check (check-effect-resolution.ts). */ +const CLI_CHECK_MARKER = 'alchemy resolves effect@'; + +/** Runs `npm install` for a scratch app; returns { appDir, status, output } instead of throwing so callers can judge HOW an install failed. */ +function installApp(label, tarballs, extraDependencies = {}) { const appDir = join(work, label); mkdirSync(appDir, { recursive: true }); - writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: label, private: true })); + writeFileSync( + join(appDir, 'package.json'), + JSON.stringify({ name: label, private: true, dependencies: extraDependencies }), + ); process.stderr.write(`\n[${label}] npm install ${tarballs.length} tarball(s)...\n`); - execFileSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], { + const result = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], { cwd: appDir, - stdio: ['ignore', 'ignore', 'inherit'], + encoding: 'utf-8', }); + if (result.error) fail(`[${label}] failed to spawn npm: ${result.error}`); + process.stderr.write(result.stderr); + return { appDir, status: result.status, output: `${result.stdout}${result.stderr}` }; +} + +/** The version of the `effect` that Node resolves from alchemy's installed position, plus its entry path. */ +function effectSeenByAlchemy(label, appDir) { + const alchemyDir = join(appDir, 'node_modules', 'alchemy'); + if (!existsSync(alchemyDir)) fail(`[${label}] alchemy is not installed`); + const requireFromAlchemy = createRequire(join(alchemyDir, 'noop.js')); + const entry = requireFromAlchemy.resolve('effect'); + let pkgDir = dirname(entry); + while (!existsSync(join(pkgDir, 'package.json'))) pkgDir = dirname(pkgDir); + const version = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')).version; + return { version, entry }; +} + +/** Runs the built prisma-composer bin in the scratch app; returns { status, output }. */ +function runCli(label, appDir, args) { + const bin = join(appDir, 'node_modules', '.bin', 'prisma-composer'); + if (!existsSync(bin)) fail(`[${label}] the prisma-composer bin is not installed`); + const result = spawnSync(bin, args, { cwd: appDir, encoding: 'utf-8' }); + if (result.error) fail(`[${label}] failed to spawn the prisma-composer bin: ${result.error}`); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; +} + +/** + * Asserts the bundled bin starts and reaches its own usage output. The proof is + * the usage banner, not the exit code: a bare `--help` exits 1 on main too, + * because clipanion reports it as a missing command. + */ +function assertCliStarts(label, appDir) { + const help = runCli(label, appDir, ['--help']); + if (!help.output.includes('prisma-composer ')) { + fail( + `[${label}] \`prisma-composer --help\` did not reach its usage output in a healthy tree ` + + `(exit ${help.status}):\n${help.output}`, + ); + } + if (/is not a function|Cannot find module/.test(help.output)) { + fail(`[${label}] the CLI crashed on its module graph in a healthy tree:\n${help.output}`); + } +} + +async function checkShape(label, tarballs) { + const { appDir, status: installStatus, output: installOutput } = installApp(label, tarballs); + if (installStatus !== 0) { + fail(`[${label}] npm install failed (exit ${installStatus}):\n${installOutput}`); + } const tree = JSON.parse( execFileSync('npm', ['ls', 'effect', '--all', '--json'], { cwd: appDir, encoding: 'utf-8' }), @@ -108,13 +174,7 @@ async function checkShape(label, tarballs) { ); } - const alchemyDir = join(appDir, 'node_modules', 'alchemy'); - if (!existsSync(alchemyDir)) fail(`[${label}] alchemy is not installed`); - const requireFromAlchemy = createRequire(join(alchemyDir, 'noop.js')); - const effectEntry = requireFromAlchemy.resolve('effect'); - let pkgDir = dirname(effectEntry); - while (!existsSync(join(pkgDir, 'package.json'))) pkgDir = dirname(pkgDir); - const resolvedVersion = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')).version; + const { version: resolvedVersion, entry: effectEntry } = effectSeenByAlchemy(label, appDir); process.stderr.write(`[${label}] alchemy resolves effect@${resolvedVersion} (${effectEntry})\n`); if (resolvedVersion !== pinnedEffect) { fail(`[${label}] alchemy resolves effect@${resolvedVersion}, expected ${pinnedEffect}`); @@ -124,9 +184,93 @@ async function checkShape(label, tarballs) { if (typeof Schedule.either !== 'function') { fail(`[${label}] Schedule.either is missing from the effect alchemy resolves`); } + + // Positive proof the CLI actually starts in this healthy tree — without it, + // "the failure marker is absent" would also hold for a CLI that never ran. + assertCliStarts(label, appDir); + + // The start-up check must NOT trip on this healthy tree — the deploy should + // get past it and fail on the app itself (no entry/config here), never on a + // broken module graph. + const cli = runCli(label, appDir, ['deploy', 'app.ts']); + if (cli.output.includes(CLI_CHECK_MARKER)) { + fail(`[${label}] the CLI's effect check misfired on a healthy tree:\n${cli.output}`); + } + if (/is not a function|Cannot find module/.test(cli.output)) { + fail(`[${label}] the CLI crashed on its module graph in a healthy tree:\n${cli.output}`); + } + process.stderr.write(`[${label}] OK — single effect@${pinnedEffect}, Schedule.either present\n`); } +// The reported chain: an app dependency on `@effect/platform-node-shared` +// whose own `effect` peer is newer than our pin, so npm hoists that newer +// effect over us (the install still exits 0). In that tree the built CLI must +// refuse to run with our clear error. The app there reached this version by +// floating `^4.0.0-beta.93`; the fixture names it exactly so the shape stays +// adversarial no matter what the registry publishes next. +const ADVERSARIAL_NODE_SHARED = '4.0.0-beta.103'; + +async function checkAdversarialShape(tarballs) { + const label = 'adversarial-node-shared-float'; + const { appDir, status, output } = installApp(label, tarballs, { + '@effect/platform-node-shared': ADVERSARIAL_NODE_SHARED, + }); + if (status !== 0) { + // Also acceptable: npm refuses the conflicted install outright — but ONLY + // when it actually failed on the dependency conflict. Any other failure + // (registry outage, a bug here) must fail the check, not silently pass it. + if (/ERESOLVE|Conflicting peer dependency|unable to resolve dependency tree/i.test(output)) { + process.stderr.write(`[${label}] OK — npm refused the conflicting install\n`); + return; + } + fail(`[${label}] npm install failed for a reason other than the conflict:\n${output}`); + } + + const { version: resolvedVersion } = effectSeenByAlchemy(label, appDir); + process.stderr.write(`[${label}] alchemy resolves effect@${resolvedVersion}\n`); + if (resolvedVersion === pinnedEffect) { + fail( + `[${label}] this shape no longer produces a mismatch, so it proves nothing: ` + + `@effect/platform-node-shared@${ADVERSARIAL_NODE_SHARED} now agrees with our ` + + `effect@${pinnedEffect}. Point ADVERSARIAL_NODE_SHARED at a release whose effect peer ` + + 'is newer than the pin.', + ); + } + + const cli = runCli(label, appDir, ['deploy', 'app.ts']); + if (cli.status === 0) { + fail(`[${label}] the CLI exited 0 in a tree where alchemy resolves effect@${resolvedVersion}`); + } + if (!cli.output.includes(CLI_CHECK_MARKER)) { + fail( + `[${label}] the CLI failed without the effect check's error (expected "${CLI_CHECK_MARKER}"):\n` + + cli.output, + ); + } + if (/is not a function/.test(cli.output)) { + fail(`[${label}] the CLI crashed with a TypeError instead of the effect check:\n${cli.output}`); + } + + // Every command loads the graph that crashes, so every command must hit the + // check first — `--help` included, or the user meets the raw TypeError there. + const help = runCli(label, appDir, ['--help']); + if ( + help.status === 0 || + !help.output.includes(CLI_CHECK_MARKER) || + /is not a function/.test(help.output) + ) { + fail( + `[${label}] \`prisma-composer --help\` did not fail with the effect check in a broken tree ` + + `(exit ${help.status}):\n${help.output}`, + ); + } + + process.stderr.write( + `[${label}] OK — broken tree caught at start-up with the actionable error, deploy and --help alike\n`, + ); +} + work = mkdtempSync(join(tmpdir(), 'npm-effect-check-')); try { const tarballDir = join(work, 'tarballs'); @@ -136,8 +280,12 @@ try { await checkShape('composer-only', [composerTgz]); await checkShape('composer-and-prisma-cloud', [composerTgz, prismaCloudTgz]); + await checkAdversarialShape([composerTgz, prismaCloudTgz]); - process.stderr.write(`\nOK — npm dedupes to a single effect@${pinnedEffect} in both shapes.\n`); + process.stderr.write( + `\nOK — npm dedupes to a single effect@${pinnedEffect} in the healthy shapes, and the CLI ` + + 'catches the adversarial tree at start-up.\n', + ); } finally { rmSync(work, { recursive: true, force: true }); } diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md index 7322ba3b..6967fed7 100644 --- a/skills/prisma-composer/SKILL.md +++ b/skills/prisma-composer/SKILL.md @@ -684,6 +684,12 @@ every shipped block supplies what it declares. locally where nothing is enforced. - **Cold starts reset service-to-service connections.** A call into a scaled-to-zero service can get `ECONNRESET`; retry it. +- **Every `prisma-composer` command stops at start-up on an `effect` version + conflict** (`Dependency conflict: alchemy resolves effect@...`). Another + dependency floated a newer `effect` and the package manager hoisted it over + Composer's pin. Do what the error says: add + `"overrides": { "effect": "" }` to the app's `package.json` + (yarn: `resolutions`; pnpm: `pnpm.overrides`) and reinstall. - **The ingress buffers streaming responses.** An open SSE tail delivers nothing and times out at 60s — don't build on streamed HTTP responses.