Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 8 additions & 0 deletions packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,14 @@ export async function loadPluginModule(provider: string, basePath: string) {
}
}

// try jiti import for bare package specifiers (handles workspace packages)
try {
const result = (await jiti.import(moduleSpec, { default: true })) as CliPlugin;
return result;
} catch {
// fall through to last resort
}

// last resort, try to import as esm directly
try {
const mod = await import(moduleSpec);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/actions/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ async function checkPluginResolution(schemaFile: string, model: Model) {
for (const plugin of plugins) {
const provider = getPluginProvider(plugin);
if (!provider.startsWith('@core/')) {
await loadPluginModule(provider, path.dirname(schemaFile));
const pluginSourcePath =
plugin.$cstNode?.parent?.element.$document?.uri?.fsPath ?? schemaFile;
await loadPluginModule(provider, path.dirname(pluginSourcePath));
}
}
}
6 changes: 5 additions & 1 deletion packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string,
throw new CliError(`Unknown core plugin: ${provider}`);
}
} else {
cliPlugin = await loadPluginModule(provider, path.dirname(schemaFile));
// resolve relative plugin paths against the schema file where the plugin is declared,
// not the entry schema file
const pluginSourcePath =
plugin.$cstNode?.parent?.element.$document?.uri?.fsPath ?? schemaFile;
cliPlugin = await loadPluginModule(provider, path.dirname(pluginSourcePath));
}

if (cliPlugin) {
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/src/actions/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ type ResolveOptions = CommonOptions & {
rolledBack?: string;
};

type DiffOptions = CommonOptions & {
fromEmpty?: boolean;
toEmpty?: boolean;
fromSchemaDatamodel?: boolean;
toSchemaDatamodel?: boolean;
fromMigrationsDirectory?: string;
toMigrationsDirectory?: string;
fromUrl?: string;
toUrl?: string;
shadowDatabaseUrl?: string;
script?: boolean;
exitCode?: boolean;
extraArgs?: string[];
};

/**
* CLI action for migration-related commands
*/
Expand Down Expand Up @@ -62,6 +77,10 @@ export async function run(command: string, options: CommonOptions) {
case 'resolve':
await runResolve(prismaSchemaFile, options as ResolveOptions);
break;

case 'diff':
runDiff(prismaSchemaFile, options as DiffOptions);
break;
}
} finally {
if (fs.existsSync(prismaSchemaFile)) {
Expand Down Expand Up @@ -140,6 +159,55 @@ function runResolve(prismaSchemaFile: string, options: ResolveOptions) {
}
}

function runDiff(prismaSchemaFile: string, options: DiffOptions) {
try {
const parts = ['migrate diff'];

if (options.fromEmpty) {
parts.push('--from-empty');
}
if (options.toEmpty) {
parts.push('--to-empty');
}
if (options.fromSchemaDatamodel) {
parts.push(`--from-schema-datamodel "${prismaSchemaFile}"`);
}
if (options.toSchemaDatamodel) {
parts.push(`--to-schema-datamodel "${prismaSchemaFile}"`);
}
if (options.fromMigrationsDirectory) {
parts.push(`--from-migrations-directory "${options.fromMigrationsDirectory}"`);
}
if (options.toMigrationsDirectory) {
parts.push(`--to-migrations-directory "${options.toMigrationsDirectory}"`);
}
if (options.fromUrl) {
parts.push(`--from-url "${options.fromUrl}"`);
}
if (options.toUrl) {
parts.push(`--to-url "${options.toUrl}"`);
}
if (options.shadowDatabaseUrl) {
parts.push(`--shadow-database-url "${options.shadowDatabaseUrl}"`);
}
if (options.script) {
parts.push('--script');
}
if (options.exitCode) {
parts.push('--exit-code');
}

// pass through any extra args
if (options.extraArgs?.length) {
parts.push(...options.extraArgs);
}

execPrisma(parts.join(' '));
Comment thread
ymc9 marked this conversation as resolved.
Outdated
} catch (err) {
handleSubProcessError(err);
}
}

function handleSubProcessError(err: unknown) {
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
process.exit(err.status);
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,35 @@ function createProgram() {
.description('Resolve issues with database migrations in deployment databases')
.action((options) => migrateAction('resolve', options));

migrateCommand
.command('diff')
.addOption(schemaOption)
.addOption(noVersionCheckOption)
.addOption(new Option('--from-empty', 'assume the "from" state is an empty schema'))
.addOption(new Option('--to-empty', 'assume the "to" state is an empty schema'))
.addOption(
new Option(
'--from-schema-datamodel',
'use the ZModel schema as the "from" source (auto-generates Prisma schema)',
),
)
.addOption(
new Option(
'--to-schema-datamodel',
'use the ZModel schema as the "to" source (auto-generates Prisma schema)',
),
)
.addOption(new Option('--from-migrations-directory <path>', 'path to the "from" migrations directory'))
.addOption(new Option('--to-migrations-directory <path>', 'path to the "to" migrations directory'))
.addOption(new Option('--from-url <url>', 'database URL as the "from" source'))
.addOption(new Option('--to-url <url>', 'database URL as the "to" source'))
.addOption(new Option('--shadow-database-url <url>', 'shadow database URL for migrations'))
.addOption(new Option('--script', 'output a SQL script instead of human-readable diff'))
.addOption(new Option('--exit-code', 'exit with non-zero code if diff is not empty'))
.allowExcessArguments(true)
.description('Compare database schemas from two sources and output the differences')
.action((options, command) => migrateAction('diff', { ...options, extraArgs: command.args }));

const dbCommand = program.command('db').description('Manage your database schema during development');

dbCommand
Expand Down
81 changes: 80 additions & 1 deletion packages/cli/test/generate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { formatDocument } from '@zenstackhq/language';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createProject, runCli } from './utils';
import { createProject, getDefaultPrelude, runCli } from './utils';

const model = `
model User {
Expand Down Expand Up @@ -272,6 +273,84 @@ model User {
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
});

it('should load plugin from a bare package specifier via jiti', async () => {
const modelWithBarePlugin = `
plugin foo {
provider = 'my-test-plugin'
}

model User {
id String @id @default(cuid())
}
`;
const { workDir } = await createProject(modelWithBarePlugin);
// Create a fake node_modules package with a TS entry point
// This can only be resolved by jiti, not by native import() or fs.existsSync checks
const pkgDir = path.join(workDir, 'node_modules/my-test-plugin');
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, 'package.json'),
JSON.stringify({ name: 'my-test-plugin', main: './index.ts' }),
);
fs.writeFileSync(
path.join(pkgDir, 'index.ts'),
`
const plugin = {
name: 'test-bare-plugin',
statusText: 'Testing bare plugin',
async generate() {},
};
export default plugin;
`,
);
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
});

it('should resolve plugin paths relative to the schema file where the plugin is declared', async () => {
// Entry schema imports a sub-schema that declares a plugin with a relative path.
// The plugin path should resolve relative to the sub-schema, not the entry schema.
const { workDir } = await createProject(
`import './core/core'

${getDefaultPrelude()}

model User {
id String @id @default(cuid())
}
`,
{ customPrelude: true },
);

// Create core/ subdirectory with its own schema and plugin
const coreDir = path.join(workDir, 'zenstack/core');
fs.mkdirSync(coreDir, { recursive: true });

const coreSchema = await formatDocument(`
plugin foo {
provider = './my-core-plugin.ts'
}
`);
fs.writeFileSync(path.join(coreDir, 'core.zmodel'), coreSchema);

// Plugin lives next to the core schema, NOT next to the entry schema
fs.writeFileSync(
path.join(coreDir, 'my-core-plugin.ts'),
`
const plugin = {
name: 'core-plugin',
statusText: 'Testing core plugin',
async generate() {},
};
export default plugin;
`,
);

// This would fail if the plugin path was resolved relative to the entry schema
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
});

it('should prefer CLI options over @core/typescript plugin settings for generateModels and generateInput', async () => {
const modelWithPlugin = `
plugin typescript {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/test/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,9 @@ describe('CLI migrate commands test', () => {
const { workDir } = await createProject(model, { provider: 'sqlite' });
expect(() => runCli('migrate resolve', workDir)).toThrow();
});

it('supports migrate diff with --from-empty and --to-schema-datamodel', async () => {
const { workDir } = await createProject(model, { provider: 'sqlite' });
runCli('migrate diff --from-empty --to-schema-datamodel --script', workDir);
});
});
Loading