Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/guides/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<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.
```

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": "<required>" }
```

yarn spells it `resolutions`:

```json
"resolutions": { "effect": "<required>" }
```

and pnpm nests it under `pnpm`:

```json
"pnpm": { "overrides": { "effect": "<required>" } }
```

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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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, unknown>,
): 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/,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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/);
});
});
22 changes: 21 additions & 1 deletion packages/0-framework/3-tooling/cli/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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();
109 changes: 109 additions & 0 deletions packages/0-framework/3-tooling/cli/src/check-effect-resolution.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading