diff --git a/.changeset/uuid-detection-no-name-inference.md b/.changeset/uuid-detection-no-name-inference.md new file mode 100644 index 0000000..53f147e --- /dev/null +++ b/.changeset/uuid-detection-no-name-inference.md @@ -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(...)`. diff --git a/CLAUDE.md b/CLAUDE.md index d636da0..1eb641b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index cd68111..0a2e242 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/__tests__/prisma-parsing.test.ts b/src/__tests__/prisma-parsing.test.ts index 6629084..4d1e943 100644 --- a/src/__tests__/prisma-parsing.test.ts +++ b/src/__tests__/prisma-parsing.test.ts @@ -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', @@ -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); } }); diff --git a/src/prisma/type.ts b/src/prisma/type.ts index 9dfdb40..469db4b 100644 --- a/src/prisma/type.ts +++ b/src/prisma/type.ts @@ -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; } /**