diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index 807b9b1401..0b73466cce 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -57,24 +57,19 @@ export type NativeTypeExpander = (input: { export type DefaultRenderer = (def: ColumnDefault, column: StorageColumn) => string; /** - * Target-supplied callback that computes the schema-qualified annotation-map - * key for a namespace-scoped enum storage type. + * Target-supplied callback that resolves a contract namespace to the live + * database schema its enums are stored under. * - * Enum lookups (`readExistingEnumValues`) are namespace/schema-qualified so two - * namespaces holding an enum with the same TypeScript name (and even the same - * native type) resolve to distinct live-database types. The *format* of that - * key — and the namespace → DDL-schema resolution it depends on — is a - * target-specific concern (Postgres schemas; SQLite/MySQL differ), so the - * target injects it here as data rather than the family layer importing a - * concrete `ddlSchemaName`/key implementation. This keeps the family layer - * target-agnostic (no `@prisma-next/target-*` dependency) while the projection - * still emits keys that match the target's read side exactly. + * The projected enum annotations are nested by schema + * (`storageTypes[schema][nativeType]`) so two namespaces holding an enum with + * the same native type resolve to distinct live-database types. Mapping a + * namespace to its DDL schema is target-specific (Postgres schemas; + * SQLite/MySQL differ), so the target injects it here rather than the family + * importing a concrete `ddlSchemaName`. This keeps the family layer + * target-agnostic while the projection nests under the same schema the + * target's read side (`readExistingEnumValues`) looks up. */ -export type EnumStorageKeyResolver = ( - storage: SqlStorage, - namespaceId: string, - nativeType: string, -) => string; +export type EnumNamespaceSchemaResolver = (storage: SqlStorage, namespaceId: string) => string; function convertColumn( name: string, @@ -349,13 +344,13 @@ export interface ContractToSchemaIROptions { readonly expandNativeType?: NativeTypeExpander; readonly renderDefault?: DefaultRenderer; /** - * Target-supplied resolver for namespace/schema-qualified enum annotation - * keys. When provided (Postgres), every namespace-scoped enum is keyed by the - * resolver's output so the projected `storageTypes` map matches the target's - * `readExistingEnumValues` lookup. Targets without namespace-qualified enum - * storage (SQLite) omit it; enums are absent there. + * Target-supplied resolver mapping a namespace to the live database schema + * its enums are stored under. When provided (Postgres), namespace-scoped + * enums are nested by that schema in `enumTypes` so the projection matches + * the target's `readExistingEnumValues` lookup. Targets without + * schema-scoped enum storage (SQLite) omit it; enums are absent there. */ - readonly resolveEnumStorageKey?: EnumStorageKeyResolver; + readonly resolveEnumNamespaceSchema?: EnumNamespaceSchemaResolver; } /** @@ -428,7 +423,7 @@ export function contractToSchemaIR( const annotations = deriveAnnotations( storage, options.annotationNamespace, - options.resolveEnumStorageKey, + options.resolveEnumNamespaceSchema, ); return { @@ -455,21 +450,26 @@ function normalizeEnumAnnotation(entry: PostgresEnumStorageEntry): StorageTypeIn function deriveAnnotations( storage: SqlStorage, annotationNamespace: string, - resolveEnumStorageKey: EnumStorageKeyResolver | undefined, + resolveEnumNamespaceSchema: EnumNamespaceSchemaResolver | undefined, ): SqlAnnotations | undefined { const storageTypes: Record = {}; + const enumTypes: Record> = {}; + + const addEnum = (namespaceId: string, entry: PostgresEnumStorageEntry): void => { + const schemaName = resolveEnumNamespaceSchema + ? resolveEnumNamespaceSchema(storage, namespaceId) + : 'public'; + const bySchema = enumTypes[schemaName] ?? {}; + bySchema[entry.nativeType] = normalizeEnumAnnotation(entry); + enumTypes[schemaName] = bySchema; + }; - // Top-level `storage.types`: codec-typed entries (vector, decimal, …) keyed - // by bare `nativeType` (unchanged). Post-S1.B enums live in - // `namespaces[*].entries.type`, not here; a defensive top-level enum is still - // namespace/schema-qualified via the resolver under the unbound coordinate - // so it never collides on a bare name. + // Top-level `storage.types`: non-enum codec entries (vector, decimal, …) keyed + // by bare `nativeType`. Post-S1.B enums live in `namespaces[*].entries.type`; + // a defensive top-level enum is nested under the unbound coordinate's schema. for (const typeInstance of Object.values((storage.types ?? {}) as ResolvedStorageTypes)) { if (isPostgresEnumStorageEntry(typeInstance)) { - const key = resolveEnumStorageKey - ? resolveEnumStorageKey(storage, UNBOUND_NAMESPACE_ID, typeInstance.nativeType) - : typeInstance.nativeType; - storageTypes[key] = normalizeEnumAnnotation(typeInstance); + addEnum(UNBOUND_NAMESPACE_ID, typeInstance); continue; } if (isStorageTypeInstance(typeInstance)) { @@ -477,21 +477,22 @@ function deriveAnnotations( } } - // Namespace-scoped enums: schema-qualified compound key matching the target's - // `readExistingEnumValues` read side, so two namespaces sharing an enum name - // (or native type) resolve to distinct live-database types. + // Namespace-scoped enums: nested by live schema so two namespaces sharing a + // native type resolve to distinct live-database types, matching the target's + // `readExistingEnumValues` read side (`enumTypes[schema][nativeType]`). for (const [namespaceId, ns] of Object.entries(storage.namespaces)) { const nsEnums = ns.entries['type']; if (!nsEnums) continue; for (const entry of Object.values(nsEnums)) { if (!isPostgresEnumStorageEntry(entry)) continue; - const key = resolveEnumStorageKey - ? resolveEnumStorageKey(storage, namespaceId, entry.nativeType) - : entry.nativeType; - storageTypes[key] = normalizeEnumAnnotation(entry); + addEnum(namespaceId, entry); } } - if (Object.keys(storageTypes).length === 0) return undefined; - return { [annotationNamespace]: { storageTypes } }; + const envelope = { + ...(Object.keys(storageTypes).length > 0 ? { storageTypes } : {}), + ...(Object.keys(enumTypes).length > 0 ? { enumTypes } : {}), + }; + if (Object.keys(envelope).length === 0) return undefined; + return { [annotationNamespace]: envelope }; } diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-type-map.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-type-map.ts index 0844632378..d2bf869c6b 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-type-map.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/postgres-type-map.ts @@ -132,20 +132,29 @@ export function createPostgresTypeMap(enumTypeNames?: ReadonlySet): PslT export function extractEnumInfo(annotations?: Record): EnumInfo { const pgAnnotations = annotations?.['pg'] as Record | undefined; - const storageTypes = pgAnnotations?.['storageTypes'] as - | Record }> + const enumTypes = pgAnnotations?.['enumTypes'] as + | Record< + string, + Record< + string, + { codecId: string; nativeType: string; typeParams?: Record } + > + > | undefined; const typeNames = new Set(); const definitions = new Map(); - if (storageTypes) { - for (const [key, typeInstance] of Object.entries(storageTypes)) { - if (typeInstance.codecId === ENUM_CODEC_ID) { - typeNames.add(key); - const values = typeInstance.typeParams?.['values']; - if (Array.isArray(values)) { - definitions.set(key, values as string[]); + if (enumTypes) { + for (const bySchema of Object.values(enumTypes)) { + for (const typeInstance of Object.values(bySchema)) { + if (typeInstance.codecId === ENUM_CODEC_ID) { + const nativeType = typeInstance.nativeType; + typeNames.add(nativeType); + const values = typeInstance.typeParams?.['values']; + if (Array.isArray(values) && values.every((v): v is string => typeof v === 'string')) { + definitions.set(nativeType, values); + } } } } diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 387a6d2c4e..412e0687bf 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -17,7 +17,7 @@ export type { SqlControlFamilyInstance } from '../core/control-instance'; export type { ContractToSchemaIROptions, DefaultRenderer, - EnumStorageKeyResolver, + EnumNamespaceSchemaResolver, NativeTypeExpander, } from '../core/migrations/contract-to-schema-ir'; // Contract → SchemaIR conversion for offline migration planning diff --git a/packages/2-sql/9-family/test/psl-contract-infer/postgres-type-map.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/postgres-type-map.test.ts index 301eb6285e..0a5bece820 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/postgres-type-map.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/postgres-type-map.test.ts @@ -134,16 +134,18 @@ describe('extractEnumTypeNames', () => { it('extracts enum type names from annotations', () => { const annotations = { pg: { - storageTypes: { - user_role: { - codecId: 'pg/enum@1', - nativeType: 'user_role', - typeParams: { values: ['USER', 'ADMIN'] }, - }, - status: { - codecId: 'pg/enum@1', - nativeType: 'status', - typeParams: { values: ['ACTIVE', 'INACTIVE'] }, + enumTypes: { + public: { + user_role: { + codecId: 'pg/enum@1', + nativeType: 'user_role', + typeParams: { values: ['USER', 'ADMIN'] }, + }, + status: { + codecId: 'pg/enum@1', + nativeType: 'status', + typeParams: { values: ['ACTIVE', 'INACTIVE'] }, + }, }, }, }, @@ -157,24 +159,46 @@ describe('extractEnumTypeNames', () => { expect(extractEnumTypeNames({})).toEqual(new Set()); }); - it('ignores non-enum storage types while keeping enum codec entries', () => { + it('reads enums nested by schema, keyed by native type', () => { const annotations = { pg: { - storageTypes: { - user_role: { - codecId: 'pg/enum@1', - nativeType: 'user_role', - typeParams: { values: ['USER', 'ADMIN'] }, + enumTypes: { + public: { + application_kind: { + codecId: 'pg/enum@1', + nativeType: 'application_kind', + typeParams: { values: ['complete', 'formless'] }, + }, }, - status: { - codecId: 'pg/enum@1', - nativeType: 'status', - typeParams: { values: 'ACTIVE' }, - }, - metadata: { - codecId: 'pg/json@1', - nativeType: 'jsonb', - typeParams: {}, + }, + }, + }; + expect(extractEnumTypeNames(annotations)).toEqual(new Set(['application_kind'])); + expect(extractEnumDefinitions(annotations)).toEqual( + new Map([['application_kind', ['complete', 'formless']]]), + ); + }); + + it('ignores non-enum storage types while keeping enum codec entries', () => { + const annotations = { + pg: { + enumTypes: { + public: { + user_role: { + codecId: 'pg/enum@1', + nativeType: 'user_role', + typeParams: { values: ['USER', 'ADMIN'] }, + }, + status: { + codecId: 'pg/enum@1', + nativeType: 'status', + typeParams: { values: 'ACTIVE' }, + }, + metadata: { + codecId: 'pg/json@1', + nativeType: 'jsonb', + typeParams: {}, + }, }, }, }, @@ -191,11 +215,13 @@ describe('extractEnumDefinitions', () => { it('extracts enum definitions', () => { const annotations = { pg: { - storageTypes: { - user_role: { - codecId: 'pg/enum@1', - nativeType: 'user_role', - typeParams: { values: ['USER', 'ADMIN'] }, + enumTypes: { + public: { + user_role: { + codecId: 'pg/enum@1', + nativeType: 'user_role', + typeParams: { values: ['USER', 'ADMIN'] }, + }, }, }, }, diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts index 68a24000d6..72f24c3314 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts @@ -272,11 +272,13 @@ describe('printPsl', () => { }, annotations: { pg: { - storageTypes: { - role: { - codecId: 'pg/enum@1', - nativeType: 'role', - typeParams: { values: ['USER', 'ADMIN'] }, + enumTypes: { + public: { + role: { + codecId: 'pg/enum@1', + nativeType: 'role', + typeParams: { values: ['USER', 'ADMIN'] }, + }, }, }, }, diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.enums.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.enums.test.ts index 809c022b6c..82c153a76a 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.enums.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.enums.test.ts @@ -25,11 +25,13 @@ describe('printPsl', () => { }, annotations: { pg: { - storageTypes: { - user_role: { - codecId: 'pg/enum@1', - nativeType: 'user_role', - typeParams: { values: ['USER', 'ADMIN', 'MODERATOR'] }, + enumTypes: { + public: { + user_role: { + codecId: 'pg/enum@1', + nativeType: 'user_role', + typeParams: { values: ['USER', 'ADMIN', 'MODERATOR'] }, + }, }, }, }, @@ -78,12 +80,14 @@ describe('printPsl', () => { }, annotations: { pg: { - storageTypes: { - deployment_status: { - codecId: 'pg/enum@1', - nativeType: 'deployment_status', - typeParams: { - values: ['READY', 'in-progress', '2FA', 'default', 'inProgress'], + enumTypes: { + public: { + deployment_status: { + codecId: 'pg/enum@1', + nativeType: 'deployment_status', + typeParams: { + values: ['READY', 'in-progress', '2FA', 'default', 'inProgress'], + }, }, }, }, @@ -138,11 +142,13 @@ describe('printPsl', () => { }, annotations: { pg: { - storageTypes: { - Role: { - codecId: 'pg/enum@1', - nativeType: 'Role', - typeParams: { values: ['!!!'] }, + enumTypes: { + public: { + Role: { + codecId: 'pg/enum@1', + nativeType: 'Role', + typeParams: { values: ['!!!'] }, + }, }, }, }, diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.naming-and-constraints.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.naming-and-constraints.test.ts index f30235e720..18e66f1d88 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.naming-and-constraints.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.naming-and-constraints.test.ts @@ -367,11 +367,13 @@ describe('printPsl', () => { }, annotations: { pg: { - storageTypes: { - user_role: { - codecId: 'pg/enum@1', - nativeType: 'user_role', - typeParams: { values: ['USER', 'ADMIN'] }, + enumTypes: { + public: { + user_role: { + codecId: 'pg/enum@1', + nativeType: 'user_role', + typeParams: { values: ['USER', 'ADMIN'] }, + }, }, }, }, @@ -389,16 +391,18 @@ describe('printPsl', () => { tables: {}, annotations: { pg: { - storageTypes: { - user_role: { - codecId: 'pg/enum@1', - nativeType: 'user_role', - typeParams: { values: ['USER'] }, - }, - UserRole: { - codecId: 'pg/enum@1', - nativeType: 'UserRole', - typeParams: { values: ['ADMIN'] }, + enumTypes: { + public: { + user_role: { + codecId: 'pg/enum@1', + nativeType: 'user_role', + typeParams: { values: ['USER'] }, + }, + UserRole: { + codecId: 'pg/enum@1', + nativeType: 'UserRole', + typeParams: { values: ['ADMIN'] }, + }, }, }, }, diff --git a/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts index ced8c47de0..b6d8846115 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts @@ -96,11 +96,13 @@ describe('sqlSchemaIrToPslAst', () => { }, annotations: { pg: { - storageTypes: { - role_t: { - codecId: 'pg/enum@1', - nativeType: 'role_t', - typeParams: { values: ['admin', 'user'] }, + enumTypes: { + public: { + role_t: { + codecId: 'pg/enum@1', + nativeType: 'role_t', + typeParams: { values: ['admin', 'user'] }, + }, }, }, }, @@ -114,6 +116,48 @@ describe('sqlSchemaIrToPslAst', () => { expect(roleField?.typeName).toBe('RoleT'); }); + it('links columns to enums and emits a clean @@map for schema-nested storage', () => { + const schemaIR = ir({ + tables: { + applications: { + name: 'applications', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + status: { name: 'status', nativeType: 'application_kind', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + annotations: { + pg: { + enumTypes: { + public: { + application_kind: { + codecId: 'pg/enum@1', + nativeType: 'application_kind', + typeParams: { values: ['complete', 'formless'] }, + }, + }, + }, + }, + }, + }); + + const ast = sqlSchemaIrToPslAst(schemaIR); + + const enumDecl = flatPslEnums(ast)[0]; + expect(enumDecl?.name).toBe('ApplicationKind'); + const mapArg = enumDecl?.attributes.find((a) => a.name === 'map')?.args[0]; + const mapValue = mapArg && mapArg.kind === 'positional' ? mapArg.value : ''; + expect(mapValue).toBe('"application_kind"'); + + const statusField = flatPslModels(ast)[0]?.fields.find((f) => f.name === 'status'); + expect(statusField?.typeName).toBe('ApplicationKind'); + }); + it('produces a @default(now()) attribute for raw now() defaults', () => { const schemaIR = ir({ tables: { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/enum-planning.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/enum-planning.ts index 53700feaed..fd335f7b3c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/enum-planning.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/enum-planning.ts @@ -14,17 +14,25 @@ import { isPostgresSchema } from '../postgres-schema'; /** * Codec-typed enum entry shape stored under - * `schema.annotations.pg.storageTypes[(schemaName, nativeType)]`. + * `schema.annotations.pg.enumTypes[schemaName][nativeType]`. */ interface PgStorageTypeEntry { readonly codecId?: string; readonly typeParams?: { readonly values?: unknown }; } +/** + * Live enum types keyed by `(schemaName, nativeType)` as a nested map, so two + * schemas sharing a native enum name stay distinct without packing the pair + * into a string. This is the same `(namespace, entityName)` coordinate the + * contract side addresses entities by. + */ +type PgEnumTypesMap = Readonly>>>; + /** Postgres-specific subtree on family `SqlSchemaIR.annotations`. */ export interface PostgresSchemaIrAnnotations { readonly schema?: string; - readonly storageTypes?: Readonly>; + readonly enumTypes?: PgEnumTypesMap; } function readOptionalString(value: unknown): string | undefined { @@ -50,20 +58,27 @@ function readPgStorageTypeEntry(value: unknown): PgStorageTypeEntry | undefined }; } -function readPgStorageTypesMap( - value: unknown, -): Readonly> | undefined { +function readPgEnumTypesMap(value: unknown): PgEnumTypesMap | undefined { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return undefined; } - const entries: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - const entry = readPgStorageTypeEntry(entryValue); - if (entry !== undefined) { - entries[key] = entry; + const bySchema: Record> = {}; + for (const [schemaName, byTypeRaw] of Object.entries(value)) { + if (byTypeRaw === null || typeof byTypeRaw !== 'object' || Array.isArray(byTypeRaw)) { + continue; + } + const byType: Record = {}; + for (const [nativeType, entryValue] of Object.entries(byTypeRaw)) { + const entry = readPgStorageTypeEntry(entryValue); + if (entry !== undefined) { + byType[nativeType] = entry; + } + } + if (Object.keys(byType).length > 0) { + bySchema[schemaName] = byType; } } - return Object.keys(entries).length > 0 ? entries : undefined; + return Object.keys(bySchema).length > 0 ? bySchema : undefined; } /** @@ -78,25 +93,13 @@ export function readPostgresSchemaIrAnnotations(schema: SqlSchemaIR): PostgresSc return {}; } const schemaField = readOptionalString(Reflect.get(raw, 'schema')); - const storageTypes = readPgStorageTypesMap(Reflect.get(raw, 'storageTypes')); + const enumTypes = readPgEnumTypesMap(Reflect.get(raw, 'enumTypes')); return { ...(schemaField !== undefined ? { schema: schemaField } : {}), - ...(storageTypes !== undefined ? { storageTypes } : {}), + ...(enumTypes !== undefined ? { enumTypes } : {}), }; } -/** - * Separator for `(schemaName, nativeType)` keys in introspected - * `schema.annotations.pg.storageTypes`. NUL cannot appear in Postgres - * identifiers, so the pair is unambiguous. - */ -export const ENUM_STORAGE_KEY_SEP = '\u0000'; - -/** Builds the schema-qualified storageTypes map key for a live Postgres enum. */ -export function enumStorageCompoundKey(schemaName: string, nativeType: string): string { - return `${schemaName}${ENUM_STORAGE_KEY_SEP}${nativeType}`; -} - /** * Resolves the live-schema name a namespace's enums are introspected under, * for keying `readExistingEnumValues` lookups. The unbound namespace's @@ -149,9 +152,10 @@ export type EnumDiff = /** * Reads existing enum values for `(schemaName, nativeType)` from the - * Postgres-introspected `schema.annotations.pg.storageTypes` map. + * Postgres-introspected `schema.annotations.pg.enumTypes` map, addressed by + * the `(schema, nativeType)` coordinate. * - * Schema IR's `storageTypes` slots are always codec-typed + * Schema IR's enum entries are always codec-typed * (`{codecId: PG_ENUM_CODEC_ID, typeParams.values}`): the introspector * writes that shape, and the Contract→Schema IR projector resolves * `PostgresEnumType` instances down to the same codec-typed triple before @@ -165,8 +169,8 @@ export function readExistingEnumValues( schemaName: string, nativeType: string, ): readonly string[] | null { - const storageTypes = readPostgresSchemaIrAnnotations(schema).storageTypes; - const existing = storageTypes?.[enumStorageCompoundKey(schemaName, nativeType)]; + const enumTypes = readPostgresSchemaIrAnnotations(schema).enumTypes; + const existing = enumTypes?.[schemaName]?.[nativeType]; if (!existing || existing.codecId !== PG_ENUM_CODEC_ID) { return null; } diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 4767bc1e47..ad9a27402d 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -10,10 +10,7 @@ import type { import type { SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types'; import { ifDefined } from '@prisma-next/utils/defined'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; -import { - enumStorageCompoundKey, - resolveDdlSchemaForNamespaceStorage, -} from '../core/migrations/enum-planning'; +import { resolveDdlSchemaForNamespaceStorage } from '../core/migrations/enum-planning'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; @@ -80,18 +77,14 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP annotationNamespace: 'pg', ...ifDefined('expandNativeType', expander), renderDefault: postgresRenderDefault, - // Schema-qualify enum annotation keys so the projected "from" IR's - // `storageTypes` match `readExistingEnumValues` on the read side - // (the contract-to-contract `migration plan` path). The DDL-schema - // resolution + compound-key format stay here in the target layer; - // the family projector treats the returned string as opaque. - // `undefined` schema IR ⇒ the unbound coordinate resolves to the - // default `public` landing schema, matching the read-side fallback. - resolveEnumStorageKey: (storage, namespaceId, nativeType) => - enumStorageCompoundKey( - resolveDdlSchemaForNamespaceStorage(storage, namespaceId, undefined), - nativeType, - ), + // Map each namespace to the live DDL schema its enums are stored + // under, so the projected "from" IR nests enums by the same schema + // `readExistingEnumValues` reads (the contract-to-contract `migration + // plan` path). `undefined` schema IR ⇒ the unbound coordinate + // resolves to the default `public` landing schema, matching the + // read-side fallback. + resolveEnumNamespaceSchema: (storage, namespaceId) => + resolveDdlSchemaForNamespaceStorage(storage, namespaceId, undefined), }); }, }, diff --git a/packages/3-targets/3-targets/postgres/src/exports/enum-planning.ts b/packages/3-targets/3-targets/postgres/src/exports/enum-planning.ts index 353f4b57ab..ba44b3b1d2 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/enum-planning.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/enum-planning.ts @@ -2,7 +2,6 @@ export { createResolveExistingEnumValues, determineEnumDiff, type EnumDiff, - enumStorageCompoundKey, getDesiredEnumValues, type PostgresSchemaIrAnnotations, readExistingEnumValues, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/enum-collision.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/enum-collision.test.ts index b76a4169aa..7965e28505 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/enum-collision.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/enum-collision.test.ts @@ -8,7 +8,6 @@ import { SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; -import { enumStorageCompoundKey } from '../../src/core/migrations/enum-planning'; import { planIssues } from '../../src/core/migrations/issue-planner'; import { AddEnumValuesCall, @@ -78,20 +77,25 @@ function makeCollisionContract( function makeLiveEnumSchema( entries: ReadonlyArray<{ schemaName: string; nativeType: string; values: readonly string[] }>, ): SqlSchemaIR { - const storageTypes: Record< + const enumTypes: Record< string, - { codecId: string; nativeType: string; typeParams: { values: readonly string[] } } + Record< + string, + { codecId: string; nativeType: string; typeParams: { values: readonly string[] } } + > > = {}; for (const entry of entries) { - storageTypes[enumStorageCompoundKey(entry.schemaName, entry.nativeType)] = { + const bySchema = enumTypes[entry.schemaName] ?? {}; + bySchema[entry.nativeType] = { codecId: PG_ENUM_CODEC_ID, nativeType: entry.nativeType, typeParams: { values: entry.values }, }; + enumTypes[entry.schemaName] = bySchema; } return { tables: {}, - annotations: { pg: { storageTypes } }, + annotations: { pg: { enumTypes } }, }; } diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index f1c7b5eef7..167876e143 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -46,7 +46,6 @@ import type { PostgresDdlNode } from '@prisma-next/target-postgres/ddl'; import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer'; import { createResolveExistingEnumValues, - enumStorageCompoundKey, readExistingEnumValues, readPostgresSchemaIrAnnotations, } from '@prisma-next/target-postgres/enum-planning'; @@ -625,16 +624,18 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { } } - const mergedStorageTypes: Record = {}; - for (let i = 0; i < perSchema.length; i++) { - const ir = perSchema[i]; - const pg = blindCast< - { storageTypes?: Record } | undefined, + const mergedEnumTypes: Record> = {}; + for (const ir of perSchema) { + const enumTypes = blindCast< + | { enumTypes?: Record> } + | undefined, 'pg annotation envelope index slot' - >(ir?.annotations?.['pg'])?.storageTypes; - if (!pg) continue; - for (const [key, value] of Object.entries(pg)) { - mergedStorageTypes[key] = value; + >(ir?.annotations?.['pg'])?.enumTypes; + if (!enumTypes) continue; + for (const [schemaName, byType] of Object.entries(enumTypes)) { + const merged = mergedEnumTypes[schemaName] ?? {}; + Object.assign(merged, byType); + mergedEnumTypes[schemaName] = merged; } } @@ -650,8 +651,8 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { pg: { ...firstPg, ...ifDefined( - 'storageTypes', - Object.keys(mergedStorageTypes).length > 0 ? mergedStorageTypes : undefined, + 'enumTypes', + Object.keys(mergedEnumTypes).length > 0 ? mergedEnumTypes : undefined, ), }, }), @@ -1093,20 +1094,17 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { }; } - const rawStorageTypes = await introspectPostgresEnumTypes({ driver, schemaName: schema }); - const storageTypes: Record = {}; - for (const [typeName, annotation] of Object.entries(rawStorageTypes)) { - storageTypes[enumStorageCompoundKey(schema, typeName)] = annotation; - } + const rawEnumTypes = await introspectPostgresEnumTypes({ driver, schemaName: schema }); + const enumTypes: Record< + string, + Record + > = Object.keys(rawEnumTypes).length > 0 ? { [schema]: rawEnumTypes } : {}; const annotations = { pg: { schema, version: await this.getPostgresVersion(driver), - ...ifDefined( - 'storageTypes', - Object.keys(storageTypes).length > 0 ? storageTypes : undefined, - ), + ...ifDefined('enumTypes', Object.keys(enumTypes).length > 0 ? enumTypes : undefined), }, }; diff --git a/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts b/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts index 0049b62557..bae0a19d84 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-adapter.test.ts @@ -1,6 +1,5 @@ import { CliStructuredError } from '@prisma-next/errors/control'; import type { SqlControlDriverInstance } from '@prisma-next/sql-contract/types'; -import { enumStorageCompoundKey } from '@prisma-next/target-postgres/enum-planning'; import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer'; import { timeouts } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; @@ -119,11 +118,13 @@ describe('PostgresControlAdapter', () => { const result = await adapter.introspect(mockDriver); expect(result.annotations?.['pg']).toMatchObject({ - storageTypes: { - [enumStorageCompoundKey('public', 'role')]: { - codecId: 'pg/enum@1', - nativeType: 'role', - typeParams: { values: ['USER', 'ADMIN'] }, + enumTypes: { + public: { + role: { + codecId: 'pg/enum@1', + nativeType: 'role', + typeParams: { values: ['USER', 'ADMIN'] }, + }, }, }, }); diff --git a/test/integration/test/cross-package/postgres-issue-planner.test.ts b/test/integration/test/cross-package/postgres-issue-planner.test.ts index 97f0e58386..6846148294 100644 --- a/test/integration/test/cross-package/postgres-issue-planner.test.ts +++ b/test/integration/test/cross-package/postgres-issue-planner.test.ts @@ -9,7 +9,6 @@ import { type StorageTableInput, } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; -import { enumStorageCompoundKey } from '@prisma-next/target-postgres/enum-planning'; import { planIssues } from '@prisma-next/target-postgres/issue-planner'; import type { CreateTableCall } from '@prisma-next/target-postgres/op-factory-call'; import { renderCallsToTypeScript } from '@prisma-next/target-postgres/render-typescript'; @@ -67,22 +66,24 @@ function makeSchemaWithEnum( values: readonly string[], schemaName = UNBOUND_NAMESPACE_ID, ): SqlSchemaIR { - // Introspection always keys `storageTypes` by the *live* schema name the - // adapter walked — the unbound coordinate resolves to `current_schema()` - // (`public` here), never the `__unbound__` DDL-emit sentinel. + // Introspection nests `enumTypes` by the *live* schema name the adapter + // walked — the unbound coordinate resolves to `current_schema()` (`public` + // here), never the `__unbound__` DDL-emit sentinel. const liveSchema = schemaName === UNBOUND_NAMESPACE_ID ? 'public' : schemaName; return { tables: {}, annotations: { pg: { schema: liveSchema, - storageTypes: { - [enumStorageCompoundKey(liveSchema, nativeType)]: { - kind: 'postgres-enum', - codecId: 'pg/enum@1', - nativeType, - values, - typeParams: { values }, + enumTypes: { + [liveSchema]: { + [nativeType]: { + kind: 'postgres-enum', + codecId: 'pg/enum@1', + nativeType, + values, + typeParams: { values }, + }, }, }, },