From db937642ad559e1eb5360c2488637595087e8c78 Mon Sep 17 00:00:00 2001 From: Elliot Shepherd Date: Sun, 15 Mar 2026 18:42:57 +1100 Subject: [PATCH 1/5] fix: load plugins with jiti --- packages/cli/src/actions/action-utils.ts | 8 ++++++ packages/cli/test/generate.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index aed343193..5f18f9be4 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -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); diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index 291f0e734..c9c0807ee 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -272,6 +272,40 @@ 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 prefer CLI options over @core/typescript plugin settings for generateModels and generateInput', async () => { const modelWithPlugin = ` plugin typescript { From 042dc492b1efcd4426dcab1e4be541e4cbfafdca Mon Sep 17 00:00:00 2001 From: Elliot Shepherd Date: Sun, 15 Mar 2026 18:45:18 +1100 Subject: [PATCH 2/5] add createOpposite option on @relation x --- .../cli/test/plugins/prisma-plugin.test.ts | 139 ++++++++++++++++++ packages/language/res/stdlib.zmodel | 3 +- .../src/validators/datamodel-validator.ts | 7 + packages/sdk/src/prisma/prisma-builder.ts | 4 + .../sdk/src/prisma/prisma-schema-generator.ts | 138 ++++++++++++++++- 5 files changed, 286 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/plugins/prisma-plugin.test.ts b/packages/cli/test/plugins/prisma-plugin.test.ts index 4b619c679..70da4d57a 100644 --- a/packages/cli/test/plugins/prisma-plugin.test.ts +++ b/packages/cli/test/plugins/prisma-plugin.test.ts @@ -78,4 +78,143 @@ model User { runCli('generate', workDir); expect(fs.existsSync(path.join(workDir, 'relative/schema.prisma'))).toBe(true); }); + + it('should auto-generate opposite relation field with createOpposite: true', async () => { + const { workDir } = await createProject(` +plugin prisma { + provider = '@core/prisma' +} + +model User { + id String @id @default(cuid()) +} + +model Post { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], createOpposite: true) +} +`); + runCli('generate', workDir); + const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8'); + expect(prismaSchema).toContain('post Post[]'); + expect(prismaSchema).not.toContain('createOpposite'); + }); + + it('should distinguish multiple relations to the same model by relation name', async () => { + const { workDir } = await createProject(` +plugin prisma { + provider = '@core/prisma' +} + +model User { + id String @id @default(cuid()) +} + +model Post { + id String @id @default(cuid()) + createdById String + createdBy User @relation("CreatedBy", fields: [createdById], references: [id], createOpposite: true) + updatedById String + updatedBy User @relation("UpdatedBy", fields: [updatedById], references: [id], createOpposite: true) +} +`); + runCli('generate', workDir); + const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8'); + // Both opposite relations should be generated with disambiguated names + expect(prismaSchema).toContain('createdBy Post[]'); + expect(prismaSchema).toContain('updatedBy Post[]'); + expect(prismaSchema).toContain('"CreatedBy"'); + expect(prismaSchema).toContain('"UpdatedBy"'); + }); + + it('should error on field name collision with createOpposite', async () => { + const { workDir } = await createProject(` +plugin prisma { + provider = '@core/prisma' +} + +model User { + id String @id @default(cuid()) + post String +} + +model Post { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], createOpposite: true) +} +`); + // The Prisma plugin catches errors internally, so verify the prisma schema was NOT generated + runCli('generate', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(false); + }); + + it('should handle composite key models with createOpposite on scalar side', async () => { + const { workDir } = await createProject(` +plugin prisma { + provider = '@core/prisma' +} + +model Tenant { + orgId String + tenantId String + name String + + @@id([orgId, tenantId]) +} + +model Invoice { + id String @id @default(cuid()) + tenantOrgId String + tenantTenantId String + tenant Tenant @relation(fields: [tenantOrgId, tenantTenantId], references: [orgId, tenantId], createOpposite: true) +} +`); + runCli('generate', workDir); + const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8'); + expect(prismaSchema).toContain('invoice Invoice[]'); + }); + + it('should handle createOpposite on array side using correct model id fields', async () => { + const { workDir } = await createProject(` +plugin prisma { + provider = '@core/prisma' +} + +model User { + uuid String @id @default(cuid()) + posts Post[] @relation(createOpposite: true) +} + +model Post { + id String @id @default(cuid()) +} +`); + runCli('generate', workDir); + const prismaSchema = fs.readFileSync(path.join(workDir, 'zenstack/schema.prisma'), 'utf8'); + // FK should reference User's uuid field, not Post's id + expect(prismaSchema).toContain('userUuid String'); + expect(prismaSchema).toContain('user User'); + expect(prismaSchema).toContain('@relation(fields: [userUuid], references: [uuid])'); + }); + + it('should error on missing opposite relation without createOpposite', async () => { + const { workDir } = await createProject(` +plugin prisma { + provider = '@core/prisma' +} + +model User { + id String @id @default(cuid()) +} + +model Post { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) +} +`); + expect(() => runCli('generate', workDir)).toThrow(/missing an opposite relation/); + }); }); diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index cb604c74a..b7e20f9bf 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -364,8 +364,9 @@ attribute @@index(_ fields: FieldReference[], name: String?, map: String?, lengt * @param map: Defines a custom name for the foreign key in the database. * @param onUpdate: Defines the referential action to perform when a referenced entry in the referenced model is being updated. * @param onDelete: Defines the referential action to perform when a referenced entry in the referenced model is being deleted. + * @param createOpposite: When true, auto-generates the opposite relation field in the Prisma schema. */ -attribute @relation(_ name: String?, fields: FieldReference[]?, references: TransitiveFieldReference[]?, onDelete: ReferentialAction?, onUpdate: ReferentialAction?, map: String?) @@@prisma +attribute @relation(_ name: String?, fields: FieldReference[]?, references: TransitiveFieldReference[]?, onDelete: ReferentialAction?, onUpdate: ReferentialAction?, map: String?, createOpposite: Boolean?) @@@prisma /** * Maps a field name or enum value from the schema to a column with a different name in the database. diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index d2fcd155d..e46b74127 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -7,6 +7,7 @@ import { DataModel, ReferenceExpr, TypeDef, + isBooleanLiteral, isDataModel, isEnum, isStringLiteral, @@ -258,6 +259,12 @@ export default class DataModelValidator implements AstValidator { }); if (oppositeFields.length === 0) { + // skip error if createOpposite: true is set — the opposite will be auto-generated + const createOppositeArg = thisRelation.attr?.args.find((a) => a.name === 'createOpposite'); + if (createOppositeArg && isBooleanLiteral(createOppositeArg.value) && createOppositeArg.value.value === true) { + return; + } + const info: DiagnosticInfo = { node: field, code: IssueCodes.MissingOppositeRelation, diff --git a/packages/sdk/src/prisma/prisma-builder.ts b/packages/sdk/src/prisma/prisma-builder.ts index a118a6472..10ae55d15 100644 --- a/packages/sdk/src/prisma/prisma-builder.ts +++ b/packages/sdk/src/prisma/prisma-builder.ts @@ -44,6 +44,10 @@ export class PrismaModel { return e; } + findModel(name: string): Model | undefined { + return this.models.find((m) => m.name === name); + } + toString(): string { return [...this.datasources, ...this.generators, ...this.enums, ...this.models] .map((d) => d.toString()) diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 815403cac..a151c3a32 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -111,6 +111,9 @@ export class PrismaSchemaGenerator { this.generateDefaultGenerator(prisma); } + // auto-generate missing opposite relation fields so Prisma schema is always valid + this.generateMissingOppositeRelations(prisma); + return this.PRELUDE + prisma.toString(); } @@ -335,12 +338,15 @@ export class PrismaSchemaGenerator { return AstUtils.streamAst(expr).some(isAuthInvocation); } + // Args that are ZenStack-only and should not appear in the generated Prisma schema + private readonly NON_PRISMA_RELATION_ARGS = ['createOpposite']; + private makeFieldAttribute(attr: DataFieldAttribute) { const attrName = attr.decl.ref!.name; - return new PrismaFieldAttribute( - attrName, - attr.args.map((arg) => this.makeAttributeArg(arg)), - ); + const args = attr.args + .filter((arg) => !(attrName === '@relation' && arg.name && this.NON_PRISMA_RELATION_ARGS.includes(arg.name))) + .map((arg) => this.makeAttributeArg(arg)); + return new PrismaFieldAttribute(attrName, args); } private makeAttributeArg(arg: AttributeArg): PrismaAttributeArg { @@ -512,6 +518,130 @@ export class PrismaSchemaGenerator { ); } + private hasCreateOpposite(field: DataField): boolean { + const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation'); + if (!relAttr) return false; + const createOppositeArg = relAttr.args.find((arg) => arg.name === 'createOpposite'); + if (!createOppositeArg) return false; + return isLiteralExpr(createOppositeArg.value) && createOppositeArg.value.value === true; + } + + private getRelationName(field: DataField): string | undefined { + const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation'); + if (!relAttr) return undefined; + const nameArg = relAttr.args.find((arg) => !arg.name || arg.name === 'name'); + if (!nameArg) return undefined; + return isStringLiteral(nameArg.value) ? (nameArg.value.value as string) : undefined; + } + + /** + * For relation fields with `createOpposite: true`, auto-generates the opposite relation + * field in the Prisma schema so it stays valid. + */ + private generateMissingOppositeRelations(prisma: PrismaModel) { + for (const decl of this.zmodel.declarations) { + if (!isDataModel(decl)) continue; + + const allFields = getAllFields(decl, false); + for (const field of allFields) { + if (!isDataModel(field.type.reference?.ref)) continue; + if (!this.hasCreateOpposite(field)) continue; + + const relationName = this.getRelationName(field); + const oppositeModel = field.type.reference!.ref! as DataModel; + + // match opposite fields by both target model name and relation name + const oppositeFields = getAllFields(oppositeModel, false).filter((f) => { + if (f === field || f.type.reference?.ref?.name !== decl.name) return false; + return this.getRelationName(f) === relationName; + }); + + if (oppositeFields.length === 0) { + // missing opposite relation — add it to the Prisma model + const prismaOppositeModel = prisma.findModel(oppositeModel.name); + if (!prismaOppositeModel) continue; + + // use relation name to disambiguate when multiple relations target the same model + const fieldName = relationName + ? lowerCaseFirst(relationName) + : lowerCaseFirst(decl.name); + if (prismaOppositeModel.fields.some((f) => f.name === fieldName)) { + throw new Error( + `Cannot auto-generate opposite relation field "${fieldName}" on model "${oppositeModel.name}": a field with that name already exists`, + ); + } + + // build @relation args for the generated field, including relation name if present + const buildRelationAttr = (extraArgs: PrismaAttributeArg[]) => { + const args: PrismaAttributeArg[] = []; + if (relationName) { + args.push( + new PrismaAttributeArg( + undefined, + new PrismaAttributeArgValue('String', relationName), + ), + ); + } + args.push(...extraArgs); + return new PrismaFieldAttribute('@relation', args); + }; + + if (field.type.array) { + // the field is an array (e.g., posts Post[]), so the opposite should be a scalar relation + const idFields = getIdFields(decl); + if (idFields.length === 0) continue; + + // create FK fields for all id fields (supports composite keys) + idFields.forEach((idFieldName) => { + const refIdFieldName = fieldName + idFieldName.charAt(0).toUpperCase() + idFieldName.slice(1); + if (!prismaOppositeModel.fields.some((f) => f.name === refIdFieldName)) { + const idField = allFields.find((f) => f.name === idFieldName); + if (idField?.type.type) { + prismaOppositeModel.addField( + refIdFieldName, + new ModelFieldType(idField.type.type, false, false), + ); + } + } + }); + + prismaOppositeModel.addField( + fieldName, + new ModelFieldType(decl.name, false, false), + [ + buildRelationAttr([ + new PrismaAttributeArg( + 'fields', + new PrismaAttributeArgValue('Array', idFields.map( + (idFieldName) => new PrismaAttributeArgValue('FieldReference', + new PrismaFieldReference(fieldName + idFieldName.charAt(0).toUpperCase() + idFieldName.slice(1))), + )), + ), + new PrismaAttributeArg( + 'references', + new PrismaAttributeArgValue('Array', idFields.map( + (idFieldName) => new PrismaAttributeArgValue('FieldReference', new PrismaFieldReference(idFieldName)), + )), + ), + ]), + ], + ); + } else { + // the field is a scalar relation (e.g., user User), so the opposite should be an array + const attrs = relationName + ? [buildRelationAttr([])] + : []; + prismaOppositeModel.addField( + fieldName, + new ModelFieldType(decl.name, true, false), + attrs, + ); + } + } + } + } + } + private truncate(name: string) { if (name.length <= IDENTIFIER_NAME_MAX_LENGTH) { return name; From 76cf7f672e6e0fcc77a9c75e9c3ca71a211c11fb Mon Sep 17 00:00:00 2001 From: Elliot Shepherd Date: Sun, 15 Mar 2026 18:52:53 +1100 Subject: [PATCH 3/5] fix: resolve plugin paths against the schema file where the plugin is declared --- packages/cli/src/actions/check.ts | 4 ++- packages/cli/src/actions/generate.ts | 6 +++- packages/cli/test/generate.test.ts | 47 +++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/actions/check.ts b/packages/cli/src/actions/check.ts index a7f765f36..61f12c57f 100644 --- a/packages/cli/src/actions/check.ts +++ b/packages/cli/src/actions/check.ts @@ -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)); } } } diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index ce499ef11..d4150c9e6 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -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) { diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index c9c0807ee..9cc31ba96 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -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 { @@ -306,6 +307,50 @@ export default plugin; 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 { From 7c5318b7f3584d63d90b0a358ddfb672693b5dff Mon Sep 17 00:00:00 2001 From: Elliot Shepherd Date: Sun, 15 Mar 2026 18:56:13 +1100 Subject: [PATCH 4/5] fix: add missing migrate diff command --- packages/cli/src/actions/migrate.ts | 68 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 29 ++++++++++++ packages/cli/test/migrate.test.ts | 5 +++ 3 files changed, 102 insertions(+) diff --git a/packages/cli/src/actions/migrate.ts b/packages/cli/src/actions/migrate.ts index c0b5ffc47..0df0060d4 100644 --- a/packages/cli/src/actions/migrate.ts +++ b/packages/cli/src/actions/migrate.ts @@ -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 */ @@ -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)) { @@ -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(' ')); + } catch (err) { + handleSubProcessError(err); + } +} + function handleSubProcessError(err: unknown) { if (err instanceof Error && 'status' in err && typeof err.status === 'number') { process.exit(err.status); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bdeca9b5d..406a1c263 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 to the "from" migrations directory')) + .addOption(new Option('--to-migrations-directory ', 'path to the "to" migrations directory')) + .addOption(new Option('--from-url ', 'database URL as the "from" source')) + .addOption(new Option('--to-url ', 'database URL as the "to" source')) + .addOption(new Option('--shadow-database-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 diff --git a/packages/cli/test/migrate.test.ts b/packages/cli/test/migrate.test.ts index bb3a7cd53..b457740a5 100644 --- a/packages/cli/test/migrate.test.ts +++ b/packages/cli/test/migrate.test.ts @@ -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); + }); }); From 4fe4eaa22f7e041e6539102fabeaf42e575b7426 Mon Sep 17 00:00:00 2001 From: Elliot Shepherd Date: Sun, 15 Mar 2026 21:07:23 +1100 Subject: [PATCH 5/5] rethrow errors thrown from generator plugins --- packages/cli/src/actions/generate.ts | 2 +- packages/cli/test/plugins/prisma-plugin.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index d4150c9e6..e65747405 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -256,7 +256,7 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string, spinner?.succeed(); } catch (err) { spinner?.fail(); - console.error(err); + throw err; } } } diff --git a/packages/cli/test/plugins/prisma-plugin.test.ts b/packages/cli/test/plugins/prisma-plugin.test.ts index 70da4d57a..22be47ff4 100644 --- a/packages/cli/test/plugins/prisma-plugin.test.ts +++ b/packages/cli/test/plugins/prisma-plugin.test.ts @@ -145,9 +145,7 @@ model Post { user User @relation(fields: [userId], references: [id], createOpposite: true) } `); - // The Prisma plugin catches errors internally, so verify the prisma schema was NOT generated - runCli('generate', workDir); - expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(false); + expect(() => runCli('generate', workDir)).toThrow(/Cannot auto-generate opposite relation/); }); it('should handle composite key models with createOpposite on scalar side', async () => {