From 8ba5ca11fca93a50fd727bd0a1f307709dc0cf8e Mon Sep 17 00:00:00 2001 From: chris-stafflink <84813115+bellcoTech@users.noreply.github.com> Date: Mon, 11 May 2026 10:54:13 +1000 Subject: [PATCH 1/3] fix(internal): fall back to schema parsing for Prisma 7 ModelName Prisma 7's new `prisma-client` provider no longer exports `Prisma.ModelName`, which `getPrismaClient()` relies on to populate the codegen mappers config. When both import attempts succeed but yield no ModelName record, codegen ends up with an empty mappers map and emits broken type definitions (e.g. `type X = MakeRelationsOptional`), producing hundreds of downstream type errors in user projects. Add a third fallback that reads model declarations directly from the Prisma schema file (or schema directory, supported in Prisma 7). Model declarations have stable syntax across all providers, so a small regex recovers the names without depending on any generated artefacts. Wired in after both `importGeneratedPrismaClient()` attempts; behaviour is unchanged for projects on the legacy `prisma-client-js` provider because the first attempt still resolves `Prisma.ModelName` normally. --- .../internal/src/generate/graphqlCodeGen.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/internal/src/generate/graphqlCodeGen.ts b/packages/internal/src/generate/graphqlCodeGen.ts index a874cb9a11..fea414b1b1 100644 --- a/packages/internal/src/generate/graphqlCodeGen.ts +++ b/packages/internal/src/generate/graphqlCodeGen.ts @@ -21,6 +21,7 @@ import { Kind, type DocumentNode } from 'graphql' import { getPaths, getConfig, + getSchemaPath, resolveGeneratedPrismaClient, } from '@cedarjs/project-config' import { getPackageManager } from '@cedarjs/project-config/packageManager' @@ -273,6 +274,38 @@ function getModelName(mod: unknown): Record | null { return null } +async function readModelNamesFromSchema(): Promise | null> { + try { + const cedarPaths = getPaths() + const schemaPath = await getSchemaPath(cedarPaths.api.prismaConfig) + if (!schemaPath || !fs.existsSync(schemaPath)) { + return null + } + + const schemaSource = fs.statSync(schemaPath).isDirectory() + ? fs + .readdirSync(schemaPath) + .filter((entry) => entry.endsWith('.prisma')) + .map((entry) => fs.readFileSync(path.join(schemaPath, entry), 'utf8')) + .join('\n') + : fs.readFileSync(schemaPath, 'utf8') + + const modelRegex = /^\s*model\s+(\w+)\s*\{/gm + const modelNames: Record = {} + let match + while ((match = modelRegex.exec(schemaSource)) !== null) { + modelNames[match[1]] = match[1] + } + + return Object.keys(modelNames).length > 0 ? modelNames : null + } catch { + return null + } +} + async function getPrismaClient(): Promise<{ ModelName: Record }> { @@ -301,7 +334,15 @@ async function getPrismaClient(): Promise<{ return { ModelName: modelName } } } catch { - // Fall through to empty ModelName object below. + // Fall through to schema-based fallback below. + } + + // Prisma 7's `prisma-client` provider no longer exports `Prisma.ModelName`, + // so neither attempt above can populate it. Fall back to parsing the schema + // file(s) directly — model declarations are stable across all providers. + const schemaModelNames = await readModelNamesFromSchema() + if (schemaModelNames) { + return { ModelName: schemaModelNames } } return { ModelName: {} } From 1ad96a836dbe5cf895cd69e4dc24a3584379f88f Mon Sep 17 00:00:00 2001 From: chris-stafflink <84813115+bellcoTech@users.noreply.github.com> Date: Mon, 11 May 2026 22:06:29 +1000 Subject: [PATCH 2/3] review: filter subdirs in readModelNamesFromSchema + add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses feedback on https://github.com/cedarjs/cedar/pull/1752: - `readdirSync` without `withFileTypes` doesn't distinguish files from subdirectories, so a directory named e.g. `views.prisma/` would slip through the `.endsWith('.prisma')` filter and cause `readFileSync(directoryPath)` to throw `EISDIR`. The outer try/catch would swallow that and the fallback would silently return `null`. Switch to `withFileTypes: true` + `entry.isFile()`. - Add `packages/internal/src/__tests__/readModelNamesFromSchema.test.ts` with dedicated coverage for the fallback. The function silently swallows all errors and returns `null` on failure, so a regression would reproduce the very symptom the PR fixes (empty mappers, type errors) without any visible failure signal — explicit unit tests prevent that. Cases covered: - single-file schema parsing - multi-file directory schema (only `.prisma` files merged) - subdirectories ending in `.prisma` are ignored (greptile-bot case) - schema with no model declarations returns null - getSchemaPath resolving to null returns null - non-existent schema path returns null - getSchemaPath throwing returns null - regex matches indented, tight, and padded model declarations and ignores commented-out ones Tests use a real temp directory rather than memfs so the `isFile()` filter exercises real filesystem semantics, and to avoid adding memfs as a new devDependency. `readModelNamesFromSchema` is exported solely to make these tests possible — the public-API surface of `@cedarjs/internal` is unchanged in practice because consumers import from the package root, not from this submodule. --- .../readModelNamesFromSchema.test.ts | 184 ++++++++++++++++++ .../internal/src/generate/graphqlCodeGen.ts | 10 +- 2 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 packages/internal/src/__tests__/readModelNamesFromSchema.test.ts diff --git a/packages/internal/src/__tests__/readModelNamesFromSchema.test.ts b/packages/internal/src/__tests__/readModelNamesFromSchema.test.ts new file mode 100644 index 0000000000..ab2df3f1da --- /dev/null +++ b/packages/internal/src/__tests__/readModelNamesFromSchema.test.ts @@ -0,0 +1,184 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' + +import type * as ProjectConfig from '@cedarjs/project-config' + +// `getPaths` / `getSchemaPath` are the only project-config helpers exercised by +// `readModelNamesFromSchema`. Stub them to point at a per-test temp directory +// so the function under test does real filesystem work against fixtures we +// control, without dragging in the rest of a Cedar project. +const mockGetPaths = vi.hoisted(() => + vi.fn<() => { api: { prismaConfig: string | null } }>(), +) +const mockGetSchemaPath = vi.hoisted(() => + vi.fn<(prismaConfig: string | null) => Promise>(), +) + +vi.mock('@cedarjs/project-config', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + getPaths: mockGetPaths, + getSchemaPath: mockGetSchemaPath, + } +}) + +// Import after the mocks are registered so `readModelNamesFromSchema` picks +// them up. +const { readModelNamesFromSchema } = + await import('../generate/graphqlCodeGen.js') + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'cedar-read-model-names-from-schema-'), + ) + mockGetPaths.mockReturnValue({ api: { prismaConfig: null } }) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +describe('readModelNamesFromSchema', () => { + it('parses model declarations from a single-file schema', async () => { + const schemaPath = path.join(tmpDir, 'schema.prisma') + fs.writeFileSync( + schemaPath, + [ + 'generator client { provider = "prisma-client" }', + '', + 'model User {', + ' id Int @id', + '}', + '', + 'model Post {', + ' id Int @id', + ' authorId Int', + '}', + '', + ].join('\n'), + ) + mockGetSchemaPath.mockResolvedValue(schemaPath) + + const result = await readModelNamesFromSchema() + + expect(result).toEqual({ User: 'User', Post: 'Post' }) + }) + + it('merges model declarations across multi-file directory schemas', async () => { + const schemaDir = path.join(tmpDir, 'schema') + fs.mkdirSync(schemaDir) + fs.writeFileSync( + path.join(schemaDir, 'user.prisma'), + ['model User {', ' id Int @id', '}'].join('\n'), + ) + fs.writeFileSync( + path.join(schemaDir, 'post.prisma'), + ['model Post {', ' id Int @id', '}'].join('\n'), + ) + // Non-prisma file in the same dir should be ignored. + fs.writeFileSync( + path.join(schemaDir, 'notes.txt'), + 'model Decoy { id Int @id }', + ) + mockGetSchemaPath.mockResolvedValue(schemaDir) + + const result = await readModelNamesFromSchema() + + expect(result).toEqual({ User: 'User', Post: 'Post' }) + }) + + it('ignores subdirectories whose names end with .prisma', async () => { + // Regression: a previous version filtered by name only and crashed with + // EISDIR when a subdirectory happened to be named e.g. `views.prisma/`. + const schemaDir = path.join(tmpDir, 'schema') + fs.mkdirSync(schemaDir) + fs.mkdirSync(path.join(schemaDir, 'views.prisma')) + fs.writeFileSync( + path.join(schemaDir, 'main.prisma'), + ['model User {', ' id Int @id', '}'].join('\n'), + ) + mockGetSchemaPath.mockResolvedValue(schemaDir) + + const result = await readModelNamesFromSchema() + + expect(result).toEqual({ User: 'User' }) + }) + + it('returns null when the schema has no model declarations', async () => { + const schemaPath = path.join(tmpDir, 'schema.prisma') + fs.writeFileSync( + schemaPath, + [ + 'generator client { provider = "prisma-client" }', + 'datasource db { provider = "postgresql" url = env("DATABASE_URL") }', + ].join('\n'), + ) + mockGetSchemaPath.mockResolvedValue(schemaPath) + + const result = await readModelNamesFromSchema() + + expect(result).toBeNull() + }) + + it('returns null when getSchemaPath resolves to null', async () => { + mockGetSchemaPath.mockResolvedValue(null) + + const result = await readModelNamesFromSchema() + + expect(result).toBeNull() + }) + + it('returns null when the schema path does not exist', async () => { + mockGetSchemaPath.mockResolvedValue(path.join(tmpDir, 'missing.prisma')) + + const result = await readModelNamesFromSchema() + + expect(result).toBeNull() + }) + + it('returns null when getSchemaPath throws', async () => { + mockGetSchemaPath.mockRejectedValue(new Error('boom')) + + const result = await readModelNamesFromSchema() + + expect(result).toBeNull() + }) + + test('regex matches `model` lines with surrounding whitespace and ignores comments', async () => { + const schemaPath = path.join(tmpDir, 'schema.prisma') + fs.writeFileSync( + schemaPath, + [ + '// model CommentedOut { id Int @id }', + ' model Indented {', + ' id Int @id', + '}', + '', + 'model Tight{', + ' id Int @id', + '}', + '', + 'model PaddedName {', + ' id Int @id', + '}', + ].join('\n'), + ) + mockGetSchemaPath.mockResolvedValue(schemaPath) + + const result = await readModelNamesFromSchema() + + expect(result).toEqual({ + Indented: 'Indented', + Tight: 'Tight', + PaddedName: 'PaddedName', + }) + expect(result).not.toHaveProperty('CommentedOut') + }) +}) diff --git a/packages/internal/src/generate/graphqlCodeGen.ts b/packages/internal/src/generate/graphqlCodeGen.ts index fea414b1b1..27b98ba27b 100644 --- a/packages/internal/src/generate/graphqlCodeGen.ts +++ b/packages/internal/src/generate/graphqlCodeGen.ts @@ -274,7 +274,7 @@ function getModelName(mod: unknown): Record | null { return null } -async function readModelNamesFromSchema(): Promise | null> { @@ -287,9 +287,11 @@ async function readModelNamesFromSchema(): Promise entry.endsWith('.prisma')) - .map((entry) => fs.readFileSync(path.join(schemaPath, entry), 'utf8')) + .readdirSync(schemaPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.prisma')) + .map((entry) => + fs.readFileSync(path.join(schemaPath, entry.name), 'utf8'), + ) .join('\n') : fs.readFileSync(schemaPath, 'utf8') From 340bb32b7ca9a3a5c07eeb735e887c20821b0096 Mon Sep 17 00:00:00 2001 From: chris-stafflink <84813115+bellcoTech@users.noreply.github.com> Date: Mon, 11 May 2026 23:44:47 +1000 Subject: [PATCH 3/3] test(internal): end-to-end codegen test for Prisma 7 ModelName fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses follow-up review on https://github.com/cedarjs/cedar/pull/1752 asking for a regression test "at the codegen-output level" — one that would have caught the broken-mappers symptom before it shipped, not just the helper in isolation. Adds `graphqlCodeGen.prismaClientProvider.test.ts`: - Reuses the `example-todo-main` fixture's real `schema.prisma` (model `Todo`). - Mocks the Prisma module to deliberately omit `Prisma.ModelName` — the exact shape Prisma 7's `prisma-client` provider produces at runtime, which is what triggered the original silent-empty-mappers bug. - Stubs `execa.sync` so the intermediate `cedar prisma generate` shell-out in `getPrismaClient` is a no-op. - Stubs `getSchemaPath` to point at the fixture's schema (the fixture predates Prisma's `prisma.config.{ts,cjs}` style, so the real `loadPrismaConfig` would throw on the missing config file — that's a separate piece of plumbing not under test here). - Runs `generateTypeDefGraphQLApi` end-to-end and asserts on the generated `graphql.d.ts` content: - `import { Todo as PrismaTodo } from 'src/lib/db'` is present — proves the schema-parsing fallback ran and recovered the model name. - `type AllMappedModels = MaybeOrArrayOfMaybe` is present — `printMappedModelsPlugin`'s output, the strongest single signal that mappers came through. - `type AllMappedModels = MaybeOrArrayOfMaybe<>` is _not_ present — negative assertion against regressing back to empty mappers. Together with the existing helper-level tests in `readModelNamesFromSchema.test.ts`, this gives two layers of cover for the regression class: the unit tests pin the helper's behaviour, and this test pins the codegen pipeline's behaviour when the helper is the only thing providing model names. --- ...raphqlCodeGen.prismaClientProvider.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/internal/src/__tests__/graphqlCodeGen.prismaClientProvider.test.ts diff --git a/packages/internal/src/__tests__/graphqlCodeGen.prismaClientProvider.test.ts b/packages/internal/src/__tests__/graphqlCodeGen.prismaClientProvider.test.ts new file mode 100644 index 0000000000..1ddc8ec01c --- /dev/null +++ b/packages/internal/src/__tests__/graphqlCodeGen.prismaClientProvider.test.ts @@ -0,0 +1,139 @@ +/** + * End-to-end regression test for the Prisma 7 `prisma-client` generator + * provider scenario. + * + * Prisma 7's new `prisma-client` provider deliberately stops exporting + * `Prisma.ModelName` at runtime, which is what `@cedarjs/internal`'s codegen + * previously relied on to populate its GraphQL `mappers` config. Without that + * record, codegen used to emit a `graphql.d.ts` with empty mappers — every + * resolver typed against `unknown`, every relation collapsed to `never` — + * which only surfaced as hundreds of type errors when the project ran + * `cedar type-check` downstream. + * + * This file exercises the actual codegen output (not just the helper in + * isolation) so a future regression in the schema-parsing fallback would + * fail this test rather than silently producing broken types. + */ +import fs from 'node:fs' +import path from 'node:path' + +const { mockPrismaClientPath, mockPrismaClientFileUrl } = await vi.hoisted( + async () => { + const p = await import('node:path') + const { pathToFileURL } = await import('node:url') + + const mockPrismaClientPath = p.resolve('/mock-prisma-client-path') + const mockPrismaClientFileUrl = pathToFileURL(mockPrismaClientPath) + return { mockPrismaClientPath, mockPrismaClientFileUrl } + }, +) + +import { afterAll, afterEach, beforeAll, expect, test, vi } from 'vitest' + +import type * as ProjectConfig from '@cedarjs/project-config' + +import { generateTypeDefGraphQLApi } from '../generate/graphqlCodeGen.js' +import { generateGraphQLSchema } from '../generate/graphqlSchema.js' + +const FIXTURE_PATH = path.resolve( + __dirname, + '../../../../__fixtures__/example-todo-main', +) + +beforeAll(() => { + process.env.CEDAR_CWD = FIXTURE_PATH +}) + +afterAll(() => { + delete process.env.CEDAR_CWD +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +vi.mock('@cedarjs/project-config', async (importOriginal) => { + const original = await importOriginal() + const p = await import('node:path') + + return { + ...original, + resolveGeneratedPrismaClient: () => + Promise.resolve({ clientPath: mockPrismaClientPath, error: undefined }), + // The `example-todo-main` fixture predates Prisma's `prisma.config.{ts,cjs}` + // style, so `loadPrismaConfig` (called by the real `getSchemaPath`) would + // throw on the missing config file. Short-circuit it to the fixture's + // actual schema path — the rest of the schema-fallback pipeline still + // runs against real `fs` against a real schema file. + getSchemaPath: () => + Promise.resolve( + p.resolve( + __dirname, + '../../../../__fixtures__/example-todo-main/api/prisma/schema.prisma', + ), + ), + } +}) + +// Stub `execa` so that the intermediate `cedar prisma generate` shell-out in +// `getPrismaClient` is a no-op — we don't want this test to actually invoke +// the CLI. +vi.mock('execa', () => { + return { + default: { + sync: vi.fn(), + }, + } +}) + +const mockNow = vi.hoisted(() => new Date().getTime()) + +// Crucially, the mocked Prisma client *omits* `ModelName`. This is what the +// Prisma 7 `prisma-client` generator produces at runtime and is the exact +// shape that previously caused codegen to emit empty mappers. +vi.mock(mockPrismaClientFileUrl + '?t=' + mockNow, () => { + return { + Prisma: { + // intentionally no `ModelName` + }, + } +}) + +test('codegen recovers ModelName from schema when Prisma client omits it', async () => { + await generateGraphQLSchema() + + vi.setSystemTime(mockNow) + + let codegenOutput: { + file: fs.PathOrFileDescriptor + data: string | ArrayBufferView + } = { file: '', data: '' } + + vi.spyOn(fs, 'writeFileSync').mockImplementation( + (file: fs.PathOrFileDescriptor, data: string | ArrayBufferView) => { + codegenOutput = { file, data } + }, + ) + + const { typeDefFiles, errors } = await generateTypeDefGraphQLApi() + expect(errors).toEqual([]) + expect(typeDefFiles).toHaveLength(1) + + const data = String(codegenOutput.data) + + // The fixture's `schema.prisma` declares a single `Todo` model. With the + // schema-parsing fallback wired in, the codegen output should import it + // from prisma and use it in the resolver mapper aliases — even though the + // mocked Prisma client doesn't expose `ModelName`. + expect(data).toContain("import { Todo as PrismaTodo } from 'src/lib/db'") + + // `AllMappedModels` is generated by `printMappedModelsPlugin` and is the + // strongest single signal that mappers came through. An empty mapper map + // produces `MaybeOrArrayOfMaybe<>` here; a working one produces + // `MaybeOrArrayOfMaybe`. + expect(data).toContain('type AllMappedModels = MaybeOrArrayOfMaybe') + + // And the negative assertion — make sure we don't regress back to the + // empty-mapper output. + expect(data).not.toContain('type AllMappedModels = MaybeOrArrayOfMaybe<>') +})