Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -428,7 +423,7 @@ export function contractToSchemaIR(
const annotations = deriveAnnotations(
storage,
options.annotationNamespace,
options.resolveEnumStorageKey,
options.resolveEnumNamespaceSchema,
);

return {
Expand All @@ -455,43 +450,49 @@ function normalizeEnumAnnotation(entry: PostgresEnumStorageEntry): StorageTypeIn
function deriveAnnotations(
storage: SqlStorage,
annotationNamespace: string,
resolveEnumStorageKey: EnumStorageKeyResolver | undefined,
resolveEnumNamespaceSchema: EnumNamespaceSchemaResolver | undefined,
): SqlAnnotations | undefined {
const storageTypes: Record<string, StorageTypeInstance> = {};
const enumTypes: Record<string, Record<string, StorageTypeInstance>> = {};

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)) {
storageTypes[typeInstance.nativeType] = typeInstance;
}
}

// 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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,29 @@ export function createPostgresTypeMap(enumTypeNames?: ReadonlySet<string>): PslT

export function extractEnumInfo(annotations?: Record<string, unknown>): EnumInfo {
const pgAnnotations = annotations?.['pg'] as Record<string, unknown> | undefined;
const storageTypes = pgAnnotations?.['storageTypes'] as
| Record<string, { codecId: string; nativeType: string; typeParams?: Record<string, unknown> }>
const enumTypes = pgAnnotations?.['enumTypes'] as
| Record<
string,
Record<
string,
{ codecId: string; nativeType: string; typeParams?: Record<string, unknown> }
>
>
| undefined;

const typeNames = new Set<string>();
const definitions = new Map<string, readonly string[]>();

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);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/2-sql/9-family/src/exports/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
},
},
},
},
Expand All @@ -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: {},
},
},
},
},
Expand All @@ -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'] },
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
},
},
},
},
Expand Down Expand Up @@ -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'],
},
},
},
},
Expand Down Expand Up @@ -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: ['!!!'] },
},
},
},
},
Expand Down
Loading
Loading