Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 31 additions & 0 deletions .changeset/uuid-detection-no-name-inference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"prisma-effect-kysely": minor
---

fix: only treat `@db.Uuid` columns as UUIDs — drop field-name inference

`isUuidField` previously had a third detection tier that inferred UUID from the
field *name* (`/^id$/`, `/_id$/`, `/^.*_uuid$/`, `/^uuid$/`) for any `String`
column. UUID is a column *type*, not a naming convention, so this was a
false-positive generator: every external-system identifier stored as text — most
notably Stripe IDs (`acct_…`, `cus_…`, `sub_…`, `price_…`, `txn_…`, `ch_…`,
`evt_…`), plus slugs and provider/session references — ends in `_id` without
being a UUID. The generator emitted `Schema.String.check(Schema.isUUID())` for
those columns, and decoding real data threw `Die`/`ParseError`
("Expected a UUID, got \"acct_…\"") at runtime.

Prisma always records a genuine `uuid` column via the `@db.Uuid` native type (a
bare `String` maps to `text`), so the native-type and `@db.Uuid`-documentation
checks already capture 100% of real UUID columns. The name-pattern tier only
ever contradicted that authoritative information, so it has been removed.

`isUuidField` now returns true only when:

1. `field.nativeType[0] === 'Uuid'` (a `@db.Uuid` column), or
2. the field documentation includes `@db.Uuid`.

**Migration:** columns that are genuinely UUID-typed are unaffected (they carry
`@db.Uuid`). Columns that were relying on name inference to get UUID validation
lose it — which is the fix, since they were text. To keep an explicit UUID check
on a non-`@db.Uuid` column, add `/// @db.Uuid` to the field, or override its
schema with `/// @customType(...)`.
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,15 @@ Prisma stores `A`/`B` columns; we emit semantic snake_case fields via `Schema.pr

## UUID detection

`isUuidField()` in `src/prisma/type.ts`, priority order:
`isUuidField()` in `src/prisma/type.ts` — authoritative DMMF type only:

1. `field.nativeType[0] === 'Uuid'` (from `@db.Uuid`)
2. `field.documentation` includes `@db.Uuid`
3. Name regex: `id`, `*_id`, `*_uuid`, `uuid`

No name-regex tier. UUID is a column type, not a naming convention; inferring it
from `*_id`/`*_uuid` names false-positived on text identifiers (e.g. Stripe
`acct_…`/`cus_…`) and crashed at decode. Use `/// @db.Uuid` or `@customType(...)`
to mark non-native UUID columns.

## Type mappings

Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,18 @@ Arrays → `Schema.Array(t)`. Nullable → `Schema.NullOr(t)`. `DateFromInput` a

## UUID Detection

Priority order:
A column is treated as a UUID only when Prisma's type information says so:

1. Native type: `@db.Uuid`
2. Documentation: `@db.Uuid` in field comment
3. Field name pattern: `id`, `*_id`, `*_uuid`, `uuid`
2. Documentation: `@db.Uuid` in the field comment (`/// @db.Uuid`)

UUID is a column type, not a naming convention — a bare `String` maps to `text`,
so `@db.Uuid` always captures genuine UUID columns. Field-name inference
(`*_id`, `*_uuid`, …) is intentionally NOT used: external identifiers such as
Stripe IDs (`acct_…`, `cus_…`) are text but end in `_id`, and inferring UUID
from the name produced false `Schema.isUUID()` checks that crashed at decode
time. Mark a non-`@db.Uuid` column as a UUID explicitly via `/// @db.Uuid`, or
override its schema entirely with `@customType(...)`.

## Custom Type Overrides

Expand Down
14 changes: 9 additions & 5 deletions src/__tests__/prisma-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { mapFieldToEffectType } from '../effect/type';
*/

describe('Prisma Parsing & Domain Logic', () => {
describe('UUID Detection (3-Tier Strategy)', () => {
describe('UUID Detection (authoritative @db.Uuid only)', () => {
it('should detect UUID via @db.Uuid native type (priority 1)', () => {
const field = createMockField({
name: 'id',
Expand All @@ -45,16 +45,20 @@ describe('Prisma Parsing & Domain Logic', () => {
expect(PrismaType.isUuidField(field)).toBe(true);
});

it('should detect UUID via field name patterns (priority 3)', () => {
const uuidFields = [
it('should NOT infer UUID from field-name patterns', () => {
// Names ending in `_id`/`_uuid` (and bare `id`/`uuid`) are NOT proof of a
// UUID column — external identifiers like Stripe IDs (`acct_…`, `cus_…`)
// are text. Only an explicit @db.Uuid marks a UUID; name guessing is gone.
const nameOnlyFields = [
createMockField({ name: 'id', isId: true }),
createMockField({ name: 'user_id' }),
createMockField({ name: 'stripe_customer_id' }),
createMockField({ name: 'external_uuid' }),
createMockField({ name: 'uuid' }),
];

for (const field of uuidFields) {
expect(PrismaType.isUuidField(field)).toBe(true);
for (const field of nameOnlyFields) {
expect(PrismaType.isUuidField(field)).toBe(false);
}
});

Expand Down
34 changes: 17 additions & 17 deletions src/prisma/type.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
import type { DMMF } from '@prisma/generator-helper';

/**
* Check if a field is a UUID using native DMMF type information
* 3-tier detection: native type � documentation � field name patterns
* Check if a field is a UUID, based solely on the authoritative DMMF type info.
*
* UUID is a *column type*, not a naming convention. Prisma always records a
* `uuid` column as the `@db.Uuid` native type (a bare `String` maps to `text`),
* so the native-type/`@db.Uuid` checks capture every genuine UUID column.
*
* A previous third tier inferred UUID from field-name patterns (`id`, `*_id`,
* `*_uuid`, `uuid`). That was a false-positive generator: any external-system
* identifier stored as text — Stripe IDs (`acct_…`, `cus_…`, `txn_…`), slugs,
* provider/session references — ends in `_id` without being a UUID, yet got
* `Schema.isUUID()` applied and then died at decode time on real data. The DMMF
* already knows the real type, so the name guess only ever contradicted ground
* truth. It has been removed; use `/// @db.Uuid` (or a real `@db.Uuid` column)
* to mark UUID columns explicitly.
*/
export function isUuidField(field: DMMF.Field) {
// 1. Check native type (most reliable)
// Native type — the authoritative signal for a `uuid` column.
if (field.nativeType?.[0] === 'Uuid') {
return true;
}

// 2. Check documentation for @db.Uuid
// `@db.Uuid` recorded in the field's documentation/attributes.
if (field.documentation?.includes('@db.Uuid')) {
return true;
}

// 3. Fallback: Field name patterns (only for String type)
if (field.type !== 'String') {
return false;
}

const uuidFieldPatterns = [
/^id$/, // Primary ID fields
/_id$/, // Foreign key ID fields
/^.*_uuid$/, // uuid suffix
/^uuid$/, // Direct uuid fields
] as const;

return uuidFieldPatterns.some((pattern) => pattern.test(field.name));
return false;
}

/**
Expand Down
Loading