Skip to content

Commit f061a47

Browse files
wmadden-electricwmaddenclaude
authored
TML-2835: make migration check single-target resolution multi-space (#732)
Completes the multi-space `migration check` work [TML-2801](https://linear.app/prisma-company/issue/TML-2801) began. That slice made the **holistic** (no-arg) check span every contract space; its **single-target** mode (`check <ref|path>`) was deliberately left app-space-only as a follow-up. This makes single-target resolution multi-space too. ## Changes - **Single-target resolves across all contract spaces** (`migration-check.ts`, `checkSingleTarget`): a migration reference is resolved per-space against each space's graph + refs (reusing TML-2801's `enumerateCheckSpaces` / `CheckSpace`), and the matched package is checked in its own space. A migration that lives in a non-app space (e.g. an extension space) is now resolved and checked, where before it was a `PRECONDITION` "not found on disk". - **`--space <id>` narrows single-target** resolution to one space, validated identically to the holistic path (`isValidSpaceId` → `errorInvalidSpaceId`; unknown id → `errorSpaceNotFound`; `PRECONDITION`). - **Cross-space ambiguity is a precondition failure**: a reference (dirName / hash-prefix) that resolves in more than one space exits `PRECONDITION` via a new `errorAmbiguousMigrationRef` factory (`cli-errors.ts`) that names the spaces and tells the user to qualify with `--space`. - **Unresolvable references keep the shared error envelope**: a reference that resolves in no space is surfaced through `mapRefResolutionError` (PN-RUN-3000), preserving the consistency contract TML-2801 established — not a bespoke string. - **Filesystem-path targets** resolve within whichever space's directory contains them (`migration-path-target.ts`, `resolveTargetPathAcrossSpaces`). - Human output shows the resolved space when it isn't `app`; the `--help` long description and examples are updated to describe multi-space single-target; the custom exit codes (`0`/`2`/`4`) remain documented in `--help`. ## Why `migration check` is the integrity verb; having single-target silently ignore non-app spaces meant a corrupted extension-space package could pass `check <ref>` while the no-arg `check` caught it. Resolving across spaces (with explicit ambiguity handling) closes that asymmetry and matches the policy `list` / `graph` / `status` already follow. **Behaviour note for reviewers:** single-target now loads the full read aggregate (as the holistic path does) and therefore inherits the holistic path's integrity-refusal gate, where the old single-target path read only the app migrations directory. This is the spec's chosen design and is arguably more correct, but it does widen single-target's pre-resolution failure surface. **Out of scope:** the holistic path (TML-2801, done); the other read verbs; runtime-validatable arktype `--json` schemas ([TML-2836](https://linear.app/prisma-company/issue/TML-2836)); `--space` semantics for `show`/`log`. Exit codes and the `MigrationCheckResult` shape are unchanged. Closes TML-2835. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Migration check now resolves migration references across multiple contract spaces. * Added optional --space flag to limit resolution to a specific contract space. * Success output shows the resolved contract space when applicable. * **Improvements** * Clearer handling and messaging for ambiguous migration references; ambiguity treated as a precondition failure with guidance. * **Tests** * New test suite covering multi-space resolution, ambiguity, narrowing by --space, and related edge cases. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1b0cdc9 commit f061a47

7 files changed

Lines changed: 749 additions & 44 deletions

File tree

packages/1-framework/3-tooling/cli/src/commands/migration-check.ts

Lines changed: 147 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,27 @@ import type {
88
import { loadContractSpaceAggregate } from '@prisma-next/migration-tools/aggregate';
99
import type { MigrationGraph } from '@prisma-next/migration-tools/graph';
1010
import { verifyMigrationHash } from '@prisma-next/migration-tools/hash';
11-
import { readMigrationsDir } from '@prisma-next/migration-tools/io';
12-
import { reconstructGraph } from '@prisma-next/migration-tools/migration-graph';
1311
import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package';
14-
import { parseMigrationRef } from '@prisma-next/migration-tools/ref-resolution';
12+
import {
13+
parseMigrationRef,
14+
type RefResolutionError,
15+
} from '@prisma-next/migration-tools/ref-resolution';
1516
import type { Refs } from '@prisma-next/migration-tools/refs';
16-
import { readRefs } from '@prisma-next/migration-tools/refs';
1717
import {
1818
isValidSpaceId,
1919
listContractSpaceDirectories,
2020
RESERVED_SPACE_SUBDIR_NAMES,
2121
spaceMigrationDirectory,
2222
spaceRefsDirectory,
2323
} from '@prisma-next/migration-tools/spaces';
24+
import { ifDefined } from '@prisma-next/utils/defined';
2425
import { notOk, ok, type Result } from '@prisma-next/utils/result';
2526
import { Command } from 'commander';
2627
import { join, relative } from 'pathe';
2728
import { loadConfig } from '../config-loader';
2829
import {
2930
type CliStructuredError,
31+
errorAmbiguousMigrationRef,
3032
errorInvalidSpaceId,
3133
errorSpaceNotFound,
3234
mapRefResolutionError,
@@ -53,6 +55,7 @@ import {
5355
findPackageByDirPath,
5456
looksLikePath,
5557
resolveAppTargetPath,
58+
resolveTargetPathAcrossSpaces,
5659
} from '../utils/migration-path-target';
5760
import { createTerminalUI, type TerminalUI } from '../utils/terminal-ui';
5861
import { INTEGRITY_FAILED, OK, PRECONDITION } from './migration-check/exit-codes';
@@ -308,6 +311,7 @@ interface MigrationCheckOutcome {
308311
readonly result?: MigrationCheckResult;
309312
readonly error?: CliStructuredError;
310313
readonly exitCode: number;
314+
readonly resolvedSpaceId?: string;
311315
}
312316

313317
async function executeMigrationCheckCommand(
@@ -317,7 +321,7 @@ async function executeMigrationCheckCommand(
317321
ui: TerminalUI,
318322
): Promise<MigrationCheckOutcome> {
319323
const config = await loadConfig(options.config);
320-
const { configPath, migrationsDir, appMigrationsDir, appMigrationsRelative, refsDir } =
324+
const { configPath, migrationsDir, appMigrationsDir, appMigrationsRelative } =
321325
resolveMigrationPaths(options.config, config);
322326

323327
if (!flags.json && !flags.quiet) {
@@ -337,20 +341,22 @@ async function executeMigrationCheckCommand(
337341
ui.stderr(header);
338342
}
339343

344+
const loadedAggregate = await buildReadAggregate(config, { migrationsDir });
345+
if (!loadedAggregate.ok) {
346+
return { error: loadedAggregate.failure, exitCode: PRECONDITION };
347+
}
348+
349+
const spaces = await enumerateCheckSpaces(loadedAggregate.value.aggregate, migrationsDir);
350+
340351
if (target) {
341352
return await checkSingleTarget(target, {
353+
spaces,
354+
...(options.space !== undefined ? { spaceFilter: options.space } : {}),
342355
appMigrationsDir,
343356
appMigrationsRelative,
344-
refsDir,
345357
});
346358
}
347359

348-
const loadedAggregate = await buildReadAggregate(config, { migrationsDir });
349-
if (!loadedAggregate.ok) {
350-
return { error: loadedAggregate.failure, exitCode: PRECONDITION };
351-
}
352-
353-
const spaces = await enumerateCheckSpaces(loadedAggregate.value.aggregate, migrationsDir);
354360
const checkResult = runMigrationCheck({
355361
spaces,
356362
...(options.space !== undefined ? { spaceFilter: options.space } : {}),
@@ -382,53 +388,140 @@ async function executeMigrationCheckCommand(
382388
};
383389
}
384390

385-
interface SingleTargetPaths {
391+
interface SingleTargetInputs {
392+
readonly spaces: readonly CheckSpace[];
393+
readonly spaceFilter?: string;
386394
readonly appMigrationsDir: string;
387395
readonly appMigrationsRelative: string;
388-
readonly refsDir: string;
389396
}
390397

391398
/**
392-
* Single-target (`check <ref/path>`) mode — app-space only by design (the
393-
* migration's space is pinned by the reference; multi-space single-target
394-
* resolution is a deliberate follow-up, see the slice spec § Out of scope).
395-
* Resolves the one referenced package and verifies its hash / manifest /
396-
* snapshot, plus the app-space orphan-manifest check the prior behaviour ran.
399+
* Ranks ref-resolution failure kinds by how informative they are, so a
400+
* single-target check surfaces the most useful failure across spaces instead of
401+
* whichever space failed first. `not-found` (the input matched nothing here)
402+
* says the least; a malformed input, a wrong grammar, or an in-space ambiguity
403+
* all say more.
404+
*/
405+
function refFailureSpecificity(error: RefResolutionError): number {
406+
switch (error.kind) {
407+
case 'wrong-grammar':
408+
return 3;
409+
case 'ambiguous':
410+
return 2;
411+
case 'invalid-format':
412+
return 1;
413+
case 'not-found':
414+
return 0;
415+
}
416+
}
417+
418+
/**
419+
* Single-target (`check <ref/path>`) mode — resolves a migration reference
420+
* across all contract spaces (or the one space narrowed by `--space <id>`).
421+
*
422+
* Resolution:
423+
* - filesystem path → find the owning space by checking which space's
424+
* `migrationsDir` contains the resolved path; falls back to app-relative
425+
* validation when the path is outside every space dir.
426+
* - ref → `parseMigrationRef` against each in-scope space; collect every
427+
* (space, package) hit; 0 hits = not-found, 1 = check it, >1 = ambiguity
428+
* error (qualify with `--space`).
429+
*
430+
* `--space <id>` is validated the same way the holistic path does it:
431+
* invalid id → `errorInvalidSpaceId`; no on-disk space → `errorSpaceNotFound`.
397432
*/
398433
async function checkSingleTarget(
399434
target: string,
400-
paths: SingleTargetPaths,
435+
inputs: SingleTargetInputs,
401436
): Promise<MigrationCheckOutcome> {
402-
const { appMigrationsDir, appMigrationsRelative, refsDir } = paths;
403-
const loaded = await readMigrationsDir(appMigrationsDir);
404-
const bundles: readonly OnDiskMigrationPackage[] = loaded.packages;
405-
const appSpace: CheckSpace = {
406-
spaceId: 'app',
407-
packages: bundles,
408-
refs: await readRefs(refsDir),
409-
graph: reconstructGraph(bundles),
410-
migrationsDir: appMigrationsDir,
411-
refsDir,
412-
};
437+
const { spaces, spaceFilter, appMigrationsDir, appMigrationsRelative } = inputs;
413438

414-
const failures: CheckFailure[] = [...checkManifestFilesPresent(appSpace)];
439+
if (spaceFilter !== undefined && !isValidSpaceId(spaceFilter)) {
440+
return { error: errorInvalidSpaceId(spaceFilter), exitCode: PRECONDITION };
441+
}
442+
if (spaceFilter !== undefined && !spaces.some((s) => s.spaceId === spaceFilter)) {
443+
return {
444+
error: errorSpaceNotFound(spaceFilter, spaces.map((s) => s.spaceId).sort()),
445+
exitCode: PRECONDITION,
446+
};
447+
}
415448

449+
const scopedSpaces =
450+
spaceFilter !== undefined ? spaces.filter((s) => s.spaceId === spaceFilter) : spaces;
451+
452+
let matchedSpace: CheckSpace | undefined;
416453
let matchedPkg: OnDiskMigrationPackage | undefined;
454+
417455
if (looksLikePath(target)) {
418-
const resolved = resolveAppTargetPath(target, appMigrationsDir, appMigrationsRelative);
419-
if (!resolved.ok) {
420-
return { error: resolved.failure, exitCode: PRECONDITION };
456+
const resolvedPath = resolveTargetPathAcrossSpaces(target, scopedSpaces);
457+
if (resolvedPath !== null) {
458+
for (const space of scopedSpaces) {
459+
const found = findPackageByDirPath(space.packages, resolvedPath);
460+
if (found) {
461+
matchedSpace = space;
462+
matchedPkg = found;
463+
break;
464+
}
465+
}
466+
} else {
467+
// Path outside every space dir — fall back to app-relative validation
468+
const resolved = resolveAppTargetPath(target, appMigrationsDir, appMigrationsRelative);
469+
if (!resolved.ok) {
470+
return { error: resolved.failure, exitCode: PRECONDITION };
471+
}
472+
const appSpace = scopedSpaces.find((s) => s.spaceId === 'app');
473+
if (appSpace) {
474+
matchedSpace = appSpace;
475+
matchedPkg = findPackageByDirPath(appSpace.packages, resolved.value);
476+
}
421477
}
422-
matchedPkg = findPackageByDirPath(bundles, resolved.value);
423478
} else {
424-
const migResult = parseMigrationRef(target, { graph: appSpace.graph, refs: appSpace.refs });
425-
if (!migResult.ok) {
426-
return { error: mapRefResolutionError(migResult.failure), exitCode: PRECONDITION };
479+
// Ref resolution: try each in-scope space, collect all hits.
480+
const hits: Array<{ space: CheckSpace; pkg: OnDiskMigrationPackage }> = [];
481+
let bestParseFailure: RefResolutionError | undefined;
482+
for (const space of scopedSpaces) {
483+
const migResult = parseMigrationRef(target, { graph: space.graph, refs: space.refs });
484+
if (!migResult.ok) {
485+
// Keep scanning — a later space may hold a hit that must not be discarded.
486+
// When no space yields a hit, keep the most informative failure rather than
487+
// whichever space failed first (the kind is space-dependent).
488+
if (
489+
bestParseFailure === undefined ||
490+
refFailureSpecificity(migResult.failure) > refFailureSpecificity(bestParseFailure)
491+
) {
492+
bestParseFailure = migResult.failure;
493+
}
494+
continue;
495+
}
496+
const pkg = space.packages.find(
497+
(p) => p.metadata.migrationHash === migResult.value.migrationHash,
498+
);
499+
if (pkg) {
500+
hits.push({ space, pkg });
501+
}
502+
}
503+
504+
if (hits.length > 1) {
505+
const spaceIds = hits.map((h) => h.space.spaceId);
506+
return {
507+
error: errorAmbiguousMigrationRef(target, spaceIds),
508+
exitCode: PRECONDITION,
509+
};
510+
}
511+
512+
if (hits.length === 1) {
513+
matchedSpace = hits[0]!.space;
514+
matchedPkg = hits[0]!.pkg;
515+
} else if (bestParseFailure !== undefined) {
516+
// The ref didn't resolve in any in-scope space — surface the most informative
517+
// parse failure through the shared ref-resolution envelope (PN-RUN-3000) the
518+
// earlier work established, rather than a bespoke string. (Ref-resolved-but-
519+
// no-package falls through to the "not found on disk" result below.)
520+
return { error: mapRefResolutionError(bestParseFailure), exitCode: PRECONDITION };
427521
}
428-
matchedPkg = bundles.find((p) => p.metadata.migrationHash === migResult.value.migrationHash);
429522
}
430523

431-
if (!matchedPkg) {
524+
if (!matchedPkg || !matchedSpace) {
432525
return {
433526
result: {
434527
ok: false,
@@ -439,6 +532,8 @@ async function checkSingleTarget(
439532
};
440533
}
441534

535+
const failures: CheckFailure[] = [...checkManifestFilesPresent(matchedSpace)];
536+
442537
for (const f of ['migration.json', 'ops.json']) {
443538
const fail = checkFileExists(matchedPkg.dirPath, matchedPkg.dirName, f);
444539
if (fail) failures.push(fail);
@@ -457,15 +552,19 @@ async function checkSingleTarget(
457552
const snapshotFailure = checkSnapshotConsistency(matchedPkg);
458553
if (snapshotFailure) failures.push(snapshotFailure);
459554

555+
const resolvedSpaceId = matchedSpace.spaceId !== 'app' ? matchedSpace.spaceId : undefined;
556+
460557
if (failures.length === 0) {
461558
return {
462559
result: { ok: true, failures: [], summary: 'All checks passed' },
463560
exitCode: OK,
561+
...ifDefined('resolvedSpaceId', resolvedSpaceId),
464562
};
465563
}
466564
return {
467565
result: { ok: false, failures, summary: `${failures.length} integrity failure(s)` },
468566
exitCode: INTEGRITY_FAILED,
567+
...ifDefined('resolvedSpaceId', resolvedSpaceId),
469568
};
470569
}
471570

@@ -477,8 +576,9 @@ export function createMigrationCheckCommand(): Command {
477576
'Validates that on-disk migration packages are internally consistent\n' +
478577
'(hashes match, manifests are complete) and that the graph is well-formed\n' +
479578
'(edges connect, refs point at valid nodes). The whole-graph check spans\n' +
480-
'every contract space by default; pass --space <id> to narrow to one, or\n' +
481-
'a migration reference to check a single app-space package.\n' +
579+
'every contract space by default; pass --space <id> to narrow to one. A\n' +
580+
'migration reference checks a single package, resolved across all contract\n' +
581+
'spaces (narrow with --space; an ambiguous reference is a precondition failure).\n' +
482582
'Offline — does not consult the database.\n' +
483583
'Exit codes: 0 = all checks passed, 2 = precondition failed\n' +
484584
'(unresolved target or unknown --space), 4 = integrity failure(s) found.',
@@ -487,6 +587,7 @@ export function createMigrationCheckCommand(): Command {
487587
'prisma-next migration check',
488588
'prisma-next migration check --space app',
489589
'prisma-next migration check 20260101-add-users',
590+
'prisma-next migration check 20260101-add-users --space app',
490591
'prisma-next migration check --json',
491592
]);
492593
setCommandSeeAlso(command, [
@@ -535,7 +636,9 @@ export function createMigrationCheckCommand(): Command {
535636
ui.output(JSON.stringify(result, null, 2));
536637
} else if (!flags.quiet) {
537638
if (result.ok) {
538-
ui.log(`✔ ${result.summary}`);
639+
const spaceSuffix =
640+
outcome.resolvedSpaceId !== undefined ? ` (space: ${outcome.resolvedSpaceId})` : '';
641+
ui.log(`✔ ${result.summary}${spaceSuffix}`);
539642
} else {
540643
for (const f of result.failures) {
541644
ui.log(`✗ [${f.pnCode}] ${f.where}: ${f.why}`);

packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,29 @@ export function requireLiveDatabase(args: {
374374
* Maps a `RefResolutionError` from the contract/migration reference
375375
* resolver into a CLI structured error envelope.
376376
*/
377+
/**
378+
* A migration ref (dirName or hash-prefix) resolves in more than one contract
379+
* space. The user must qualify with `--space <id>` to disambiguate.
380+
*/
381+
export function errorAmbiguousMigrationRef(
382+
ref: string,
383+
spaceIds: readonly string[],
384+
): CliStructuredError {
385+
const spaceList = spaceIds.join(', ');
386+
return errorRuntime(
387+
`Ambiguous migration reference: "${ref}" resolves in multiple spaces — qualify with --space <id>`,
388+
{
389+
why: `"${ref}" matches migrations in spaces: ${spaceList}.`,
390+
fix: `Qualify with --space <id> to select one space. Available matching spaces: ${spaceList}.`,
391+
meta: {
392+
code: 'MIGRATION.AMBIGUOUS_MIGRATION_REF',
393+
ref,
394+
spaceIds: [...spaceIds],
395+
},
396+
},
397+
);
398+
}
399+
377400
export function mapRefResolutionError(error: RefResolutionError): CliStructuredError {
378401
switch (error.kind) {
379402
case 'not-found':

packages/1-framework/3-tooling/cli/src/utils/migration-path-target.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,27 @@ export function resolveAppTargetPath(
3030
return ok(targetPath);
3131
}
3232

33+
/**
34+
* Resolve a filesystem-path target to the migration dir that contains it,
35+
* searching each in-scope space's `migrationsDir`. A path is explicit, so
36+
* it can belong to at most one space — returns the first match, or `null`
37+
* when the path falls outside every space dir.
38+
*/
39+
export function resolveTargetPathAcrossSpaces(
40+
target: string,
41+
spaces: ReadonlyArray<{ readonly migrationsDir: string }>,
42+
): string | null {
43+
const targetPath = resolve(target);
44+
for (const space of spaces) {
45+
const rel = relative(space.migrationsDir, targetPath);
46+
const isOutside = rel === '' || rel === '.' || rel.startsWith('..') || isAbsolute(rel);
47+
if (!isOutside) {
48+
return targetPath;
49+
}
50+
}
51+
return null;
52+
}
53+
3354
export function findPackageByDirPath(
3455
packages: readonly OnDiskMigrationPackage[],
3556
resolvedDirPath: string,

0 commit comments

Comments
 (0)