From 059acd173e1006271bc305f28f2faf57ab4655fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 10:00:17 +0000 Subject: [PATCH] Fix regression: list-only many-to-many relationships not generating synthetic back-reference fields In 0.19.0, a change to the synthetic field generation condition (from `joinTableNaming !== 'keystone'` to `!m2mCheck.isManyToMany`) fixed one-sided many-to-one relationships with Keystone naming but accidentally broke list-only many-to-many relationships. Prisma requires both sides of any implicit many-to-many relationship to be defined. Without the synthetic back-reference field on the target model, Prisma throws a validation error: Error validating field `readBy` in model `TextMessage`: The relation field `readBy` on model `TextMessage` is missing an opposite relation field on the model `User`. Fix: always generate synthetic back-reference fields for list-only refs (no `targetField`), regardless of whether the relationship is many-to-many or many-to-one. The relation name now correctly uses `db.relationName` when explicitly set, falling back to the auto-generated `${listName}_${fieldName}`. Updates two incorrect tests that asserted synthetic fields were NOT generated for list-only many-to-many (both with Keystone naming and explicit relationName), and adds a dedicated regression test matching the reported bug scenario. https://claude.ai/code/session_01TQUjki3U3XpDWmvU3AbYNr --- .changeset/silent-roads-burn.md | 5 +++ packages/cli/src/generator/prisma.test.ts | 53 ++++++++++++++++++++--- packages/cli/src/generator/prisma.ts | 12 ++--- 3 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 .changeset/silent-roads-burn.md diff --git a/.changeset/silent-roads-burn.md b/.changeset/silent-roads-burn.md new file mode 100644 index 00000000..ab55f722 --- /dev/null +++ b/.changeset/silent-roads-burn.md @@ -0,0 +1,5 @@ +--- +'@opensaas/stack-cli': patch +--- + +Fix regression where list-only many-to-many relationships no longer generated synthetic back-reference fields on the target model, causing Prisma schema validation errors diff --git a/packages/cli/src/generator/prisma.test.ts b/packages/cli/src/generator/prisma.test.ts index 4df75d46..ccc389aa 100644 --- a/packages/cli/src/generator/prisma.test.ts +++ b/packages/cli/src/generator/prisma.test.ts @@ -319,6 +319,41 @@ describe('Prisma Schema Generator', () => { expect(schema).toContain('from_Post_category Post[] @relation("Post_category")') }) + it('should generate synthetic back-reference for list-only many-to-many (regression: 0.19.0)', () => { + // Regression test: relationship({ ref: 'User', many: true }) stopped generating + // back-reference fields on the target model in 0.19.0, causing Prisma validation errors: + // "The relation field `readBy` on model `TextMessage` is missing an opposite relation field" + const config: OpenSaasConfig = { + db: { + provider: 'sqlite', + }, + lists: { + User: { + fields: { + name: text(), + }, + }, + TextMessage: { + fields: { + content: text(), + readBy: relationship({ ref: 'User', many: true }), + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + // TextMessage should have the relationship with named relation + expect(schema).toContain('readBy User[] @relation("TextMessage_readBy")') + + // User MUST have the synthetic back-reference — Prisma requires both sides + // of an implicit many-to-many to be defined or it throws a validation error + expect(schema).toContain( + 'from_TextMessage_readBy TextMessage[] @relation("TextMessage_readBy")', + ) + }) + it('should handle multiple list-only refs pointing to same target', () => { const config: OpenSaasConfig = { db: { @@ -999,7 +1034,7 @@ describe('Prisma Schema Generator', () => { expect(schema).toContain('@relation("Post_tags")') }) - it('should not generate synthetic fields for list-only many-to-many with Keystone naming', () => { + it('should generate synthetic fields for list-only many-to-many with Keystone naming', () => { const config: OpenSaasConfig = { db: { provider: 'sqlite', @@ -1022,8 +1057,12 @@ describe('Prisma Schema Generator', () => { const schema = generatePrismaSchema(config) - // Should NOT have synthetic field (handled by implicit join table) - expect(schema).not.toContain('from_Post_tags') + // Post should have the relationship field with Keystone relation name + expect(schema).toContain('tags Tag[] @relation("Post_tags")') + + // Tag MUST have a synthetic back-reference field — Prisma requires both sides + // of an implicit many-to-many relationship to be explicitly defined + expect(schema).toContain('from_Post_tags Post[] @relation("Post_tags")') }) it('should generate synthetic fields for one-sided many-to-one with Keystone naming', () => { @@ -1248,10 +1287,12 @@ describe('Prisma Schema Generator', () => { const schema = generatePrismaSchema(config) - // Should use per-field relationName + // Should use per-field relationName on the source side expect(schema).toContain('tags Tag[] @relation("Post_tags")') - // Should NOT create synthetic field - expect(schema).not.toContain('from_Post_tags') + + // Tag MUST have a synthetic back-reference using the same explicit relation name + // Prisma requires both sides of implicit many-to-many to be defined + expect(schema).toContain('from_Post_tags Post[] @relation("Post_tags")') }) }) diff --git a/packages/cli/src/generator/prisma.ts b/packages/cli/src/generator/prisma.ts index 00532e94..adbb32c6 100644 --- a/packages/cli/src/generator/prisma.ts +++ b/packages/cli/src/generator/prisma.ts @@ -404,12 +404,14 @@ export function generatePrismaSchema(config: OpenSaasConfig): string { } } - // If no target field specified, we need to add a synthetic field - // (unless it's a many-to-many relationship or per-field relationName is set) - const hasExplicitRelationName = relField.db?.relationName - if (!targetField && !hasExplicitRelationName && !m2mCheck.isManyToMany) { + // If no target field specified, we need to add a synthetic back-reference field + // on the target model. Prisma requires both sides of any relationship to be + // defined, including implicit many-to-many relationships. + // This applies to all list-only refs (both many-to-many and many-to-one). + if (!targetField) { const syntheticFieldName = `from_${listName}_${fieldName}` - const relationName = `${listName}_${fieldName}` + // Use explicit relation name if set, otherwise auto-generate + const relationName = relField.db?.relationName ?? `${listName}_${fieldName}` if (!syntheticFields.has(targetList)) { syntheticFields.set(targetList, [])