@prisma-next/family-sql 0.8.0 → 0.9.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -5
  2. package/dist/control-adapter.d.mts +11 -1
  3. package/dist/control-adapter.d.mts.map +1 -1
  4. package/dist/control.d.mts +3 -3
  5. package/dist/control.d.mts.map +1 -1
  6. package/dist/control.mjs +37 -30
  7. package/dist/control.mjs.map +1 -1
  8. package/dist/ir.d.mts +135 -0
  9. package/dist/ir.d.mts.map +1 -0
  10. package/dist/ir.mjs +37 -0
  11. package/dist/ir.mjs.map +1 -0
  12. package/dist/migration.d.mts +1 -1
  13. package/dist/runtime.mjs +1 -1
  14. package/dist/schema-verify.d.mts +2 -1
  15. package/dist/schema-verify.d.mts.map +1 -1
  16. package/dist/schema-verify.mjs +1 -1
  17. package/dist/sql-contract-serializer-qUQCnP-k.mjs +125 -0
  18. package/dist/sql-contract-serializer-qUQCnP-k.mjs.map +1 -0
  19. package/dist/{timestamp-now-generator-BWp8S2sa.mjs → timestamp-now-generator-r7BP5n3l.mjs} +1 -1
  20. package/dist/{timestamp-now-generator-BWp8S2sa.mjs.map → timestamp-now-generator-r7BP5n3l.mjs.map} +1 -1
  21. package/dist/{types-Da-eOg20.d.mts → types-DMINfGUO.d.mts} +37 -25
  22. package/dist/types-DMINfGUO.d.mts.map +1 -0
  23. package/dist/{verify-pRYxnpiG.mjs → verify-Crewz6hG.mjs} +1 -1
  24. package/dist/{verify-pRYxnpiG.mjs.map → verify-Crewz6hG.mjs.map} +1 -1
  25. package/dist/{verify-sql-schema-DV-UsTG9.mjs → verify-sql-schema-BXw7yx6L.mjs} +53 -7
  26. package/dist/verify-sql-schema-BXw7yx6L.mjs.map +1 -0
  27. package/dist/{verify-sql-schema-CPHiuYHR.d.mts → verify-sql-schema-Bfvz07Ik.d.mts} +14 -2
  28. package/dist/verify-sql-schema-Bfvz07Ik.d.mts.map +1 -0
  29. package/dist/verify.mjs +1 -1
  30. package/package.json +22 -21
  31. package/src/core/control-adapter.ts +14 -0
  32. package/src/core/control-instance.ts +33 -52
  33. package/src/core/ir/sql-contract-serializer-base.ts +136 -0
  34. package/src/core/ir/sql-contract-serializer.ts +18 -0
  35. package/src/core/ir/sql-schema-verifier-base.ts +56 -0
  36. package/src/core/migrations/contract-to-schema-ir.ts +71 -22
  37. package/src/core/migrations/types.ts +25 -5
  38. package/src/core/schema-verify/verify-sql-schema.ts +117 -21
  39. package/src/exports/control.ts +1 -1
  40. package/src/exports/ir.ts +6 -0
  41. package/dist/types-Da-eOg20.d.mts.map +0 -1
  42. package/dist/verify-sql-schema-CPHiuYHR.d.mts.map +0 -1
  43. package/dist/verify-sql-schema-DV-UsTG9.mjs.map +0 -1
@@ -0,0 +1,136 @@
1
+ import type { Contract } from '@prisma-next/contract/types';
2
+ import type { ContractSerializer } from '@prisma-next/framework-components/control';
3
+ import { SqlStorage, type SqlStorageTypeEntry } from '@prisma-next/sql-contract/types';
4
+ import { validateSqlContractFully } from '@prisma-next/sql-contract/validators';
5
+ import type { JsonObject } from '@prisma-next/utils/json';
6
+
7
+ export type SqlEntityHydrationFactory = (entry: unknown) => SqlStorageTypeEntry;
8
+
9
+ /**
10
+ * SQL family `ContractSerializer` abstract base. Carries the SQL-shared
11
+ * deserialization pipeline:
12
+ *
13
+ * 1. `parseSqlContractStructure` validates the on-disk JSON envelope
14
+ * against the SQL contract arktype schema (`validateSqlContractFully`)
15
+ * and returns the validated flat-data shape.
16
+ * 2. `hydrateSqlStorage` walks the validated `storage` subtree and
17
+ * constructs the family-shared SQL Contract IR class hierarchy
18
+ * (`SqlStorage` -> `StorageTable` -> `StorageColumn` / `PrimaryKey`
19
+ * / …). The rest of the contract envelope is JSON-clean primitive
20
+ * data and passes through unchanged.
21
+ * 3. `constructTargetContract` is the target-specific extension hook;
22
+ * defaults to identity. Targets that need to attach target-only
23
+ * fields (e.g. target-specific derived storage fields) override it.
24
+ *
25
+ * Default `serializeContract` is identity over the contract — concrete
26
+ * SQL targets ship JSON-clean class instances, so the contract value
27
+ * can be stringified directly. The non-enumerable family-level `kind`
28
+ * discriminator on `SqlNode` instances stays out of the persisted
29
+ * envelope automatically. Targets that need to canonicalize on the way
30
+ * out (key ordering, dropping computed-only fields) override
31
+ * `serializeContract` directly.
32
+ */
33
+ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlStorage>>
34
+ implements ContractSerializer<TContract>
35
+ {
36
+ constructor(
37
+ private readonly entityTypeRegistry: ReadonlyMap<string, SqlEntityHydrationFactory>,
38
+ ) {}
39
+
40
+ deserializeContract(json: unknown): TContract {
41
+ const validated = this.parseSqlContractStructure(json);
42
+ const hydrated = this.hydrateSqlStorage(validated);
43
+ return this.constructTargetContract(hydrated);
44
+ }
45
+
46
+ serializeContract(contract: TContract): JsonObject {
47
+ // Targets that ship enumerable runtime-only fields must override
48
+ // this method (mirroring `MongoTargetContractSerializer.serializeContract`)
49
+ // to construct the persisted envelope explicitly; the default identity
50
+ // works only when every enumerable own property belongs in the JSON.
51
+ return contract as unknown as JsonObject;
52
+ }
53
+
54
+ /**
55
+ * Family-shared validation pipeline (delegates to
56
+ * `validateSqlContractFully` in `@prisma-next/sql-contract/validators`):
57
+ * structural arktype + framework-shared domain + SQL storage
58
+ * logical-consistency + SQL storage semantic + model ↔ storage
59
+ * reference checks. Subclasses override to add target-specific
60
+ * structural checks before hydration; the family default suffices
61
+ * for targets whose contract shape is the SQL-shared shape
62
+ * (Postgres, SQLite today).
63
+ */
64
+ protected parseSqlContractStructure(json: unknown): Contract<SqlStorage> {
65
+ return validateSqlContractFully<Contract<SqlStorage>>(json);
66
+ }
67
+
68
+ /**
69
+ * Family-shared hydration walker. Lifts the validated flat-data
70
+ * `storage` subtree into the SQL Contract IR class hierarchy by
71
+ * constructing a single `SqlStorage` instance — its constructor
72
+ * cascades nested instantiation of `StorageTable`, `StorageColumn`,
73
+ * `PrimaryKey`, `UniqueConstraint`, `Index`, `ForeignKey`,
74
+ * `ForeignKeyReferences`, and `StorageTypeInstance`. The rest of the
75
+ * contract envelope (target identity, hashes, capabilities, models,
76
+ * meta, …) is JSON-clean primitive data and passes through unchanged.
77
+ *
78
+ * Polymorphic `storage.types` entries are normalised before the
79
+ * `SqlStorage` constructor runs: when an entry carries an enumerable
80
+ * string `kind`, the serializer looks up a pack-registered hydration
81
+ * factory for that discriminator and delegates reconstruction. Entries
82
+ * with no registered factory pass through unchanged (codec-typed JSON
83
+ * stays codec-typed until `SqlStorage` normalises it).
84
+ */
85
+ protected hydrateSqlStorage(validated: Contract<SqlStorage>): Contract<SqlStorage> {
86
+ const types = validated.storage.types;
87
+ const hydratedTypes =
88
+ types !== undefined
89
+ ? Object.fromEntries(
90
+ Object.entries(types).map(([name, entry]) => [
91
+ name,
92
+ this.hydrateStorageTypeEntry(entry),
93
+ ]),
94
+ )
95
+ : undefined;
96
+
97
+ return {
98
+ ...validated,
99
+ storage: new SqlStorage({
100
+ ...validated.storage,
101
+ ...(hydratedTypes !== undefined ? { types: hydratedTypes } : {}),
102
+ }),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Per-entry hydration dispatcher for `storage.types`. When `kind` is a
108
+ * string and the constructor registry supplies a factory for that key,
109
+ * the factory returns the hydrated `SqlStorageTypeEntry`. Otherwise the
110
+ * entry passes through unchanged for `SqlStorage` to normalise.
111
+ */
112
+ protected hydrateStorageTypeEntry(entry: SqlStorageTypeEntry): SqlStorageTypeEntry {
113
+ if (typeof entry !== 'object' || entry === null) {
114
+ return entry;
115
+ }
116
+ const kind = (entry as { kind?: unknown }).kind;
117
+ if (typeof kind !== 'string') {
118
+ return entry;
119
+ }
120
+ const factory = this.entityTypeRegistry.get(kind);
121
+ if (factory === undefined) {
122
+ return entry;
123
+ }
124
+ return factory(entry);
125
+ }
126
+
127
+ /**
128
+ * Target-specific construction hook. Defaults to identity; targets
129
+ * that need to wrap the hydrated contract (e.g. attach target-only
130
+ * derived fields, narrow the contract type to a target-specific
131
+ * subtype) override.
132
+ */
133
+ protected constructTargetContract(hydrated: Contract<SqlStorage>): TContract {
134
+ return hydrated as TContract;
135
+ }
136
+ }
@@ -0,0 +1,18 @@
1
+ import type { Contract } from '@prisma-next/contract/types';
2
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
3
+ import { SqlContractSerializerBase } from './sql-contract-serializer-base';
4
+
5
+ /**
6
+ * Default SQL family `ContractSerializer` concretion. Inherits the
7
+ * full SQL-shared deserialization pipeline (structural validation +
8
+ * IR-class hydration) without pack-registered `storage.types`
9
+ * hydration factories — targets that emit polymorphic JSON outside the
10
+ * codec-typed envelope wire a target-specific subclass with a populated
11
+ * registry (see Postgres). Family-level call sites instantiate this
12
+ * default directly when no target serializer is supplied.
13
+ */
14
+ export class SqlContractSerializer extends SqlContractSerializerBase<Contract<SqlStorage>> {
15
+ constructor() {
16
+ super(new Map());
17
+ }
18
+ }
@@ -0,0 +1,56 @@
1
+ import type {
2
+ SchemaIssue,
3
+ SchemaVerifier,
4
+ SchemaVerifyOptions,
5
+ SchemaVerifyResult,
6
+ } from '@prisma-next/framework-components/control';
7
+
8
+ /**
9
+ * SQL family `SchemaVerifier` abstract base. Centralises the SQL-shared
10
+ * walk (table-by-table + column-by-column matching keyed by
11
+ * `(namespace.id, name)`, FK / unique / index comparisons via the
12
+ * existing helpers in `verify-helpers.ts`) and exposes a protected hook
13
+ * for target extensions (Postgres functions, RLS policies, future
14
+ * target-only kinds).
15
+ *
16
+ * The base accumulates issues in a single buffer and returns the
17
+ * combined result; the per-SPI family abstract handles the result
18
+ * envelope shape so concrete subclasses focus on target-specific
19
+ * verification logic.
20
+ *
21
+ * The protected hooks (`verifyCommonSqlSchema`,
22
+ * `verifyTargetExtensions`) carry the stable base API that target
23
+ * subclasses (`PostgresSchemaVerifier`, `SqliteSchemaVerifier`)
24
+ * compile against; the SQL-shared walk implementation will be lifted
25
+ * into this base when the verifier behaviour migrates off the
26
+ * legacy adapter shells.
27
+ */
28
+ export abstract class SqlSchemaVerifierBase<TContract, TSchema>
29
+ implements SchemaVerifier<TContract, TSchema>
30
+ {
31
+ verifySchema(options: SchemaVerifyOptions<TContract, TSchema>): SchemaVerifyResult {
32
+ const issues: SchemaIssue[] = [];
33
+ issues.push(...this.verifyCommonSqlSchema(options));
34
+ issues.push(...this.verifyTargetExtensions(options));
35
+ return { ok: issues.length === 0, issues };
36
+ }
37
+
38
+ /**
39
+ * SQL-shared verification — table/column/FK/unique/index walks keyed
40
+ * by `(namespace.id, name)`. Concrete subclasses provide the
41
+ * family-shared implementation today; a future iteration will lift
42
+ * the shared walk into this base.
43
+ */
44
+ protected abstract verifyCommonSqlSchema(
45
+ options: SchemaVerifyOptions<TContract, TSchema>,
46
+ ): readonly SchemaIssue[];
47
+
48
+ /**
49
+ * Target-specific extensions — e.g. Postgres functions, future RLS
50
+ * policies, namespace-mismatch issues. Returns the empty list when the
51
+ * target ships no extensions over the SQL family alphabet.
52
+ */
53
+ protected abstract verifyTargetExtensions(
54
+ options: SchemaVerifyOptions<TContract, TSchema>,
55
+ ): readonly SchemaIssue[];
56
+ }
@@ -1,13 +1,17 @@
1
1
  import type { ColumnDefault, Contract } from '@prisma-next/contract/types';
2
2
  import type { MigrationPlannerConflict } from '@prisma-next/framework-components/control';
3
- import type {
4
- ForeignKey,
5
- Index,
6
- SqlStorage,
7
- StorageColumn,
8
- StorageTable,
9
- StorageTypeInstance,
10
- UniqueConstraint,
3
+ import {
4
+ type ForeignKey,
5
+ type Index,
6
+ isPostgresEnumStorageEntry,
7
+ isStorageTypeInstance,
8
+ type PostgresEnumStorageEntry,
9
+ type SqlStorage,
10
+ type StorageColumn,
11
+ type StorageTable,
12
+ type StorageTypeInstance,
13
+ toStorageTypeInstance,
14
+ type UniqueConstraint,
11
15
  } from '@prisma-next/sql-contract/types';
12
16
  import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
13
17
  import type {
@@ -51,7 +55,7 @@ export type DefaultRenderer = (def: ColumnDefault, column: StorageColumn) => str
51
55
  function convertColumn(
52
56
  name: string,
53
57
  column: StorageColumn,
54
- storageTypes: Record<string, StorageTypeInstance>,
58
+ storageTypes: ResolvedStorageTypes,
55
59
  expandNativeType: NativeTypeExpander | undefined,
56
60
  renderDefault: DefaultRenderer | undefined,
57
61
  ): SqlColumnIR {
@@ -82,9 +86,21 @@ function convertColumn(
82
86
  };
83
87
  }
84
88
 
89
+ /**
90
+ * `storageTypes` is polymorphic per Decision 18 (Option B) — codec-typed
91
+ * entries match `StorageTypeInstance`; enum entries match the structural
92
+ * `PostgresEnumStorageEntry` shape (Postgres-only; cross-domain layering
93
+ * keeps target IR classes out of the family layer). Both shapes resolve
94
+ * into the same `(codecId, nativeType, typeParams)` triplet at the
95
+ * column-resolution boundary so downstream walks stay uniform.
96
+ */
97
+ type ResolvedStorageTypes = Readonly<
98
+ Record<string, StorageTypeInstance | PostgresEnumStorageEntry>
99
+ >;
100
+
85
101
  function resolveColumnTypeMetadata(
86
102
  column: StorageColumn,
87
- storageTypes: Record<string, StorageTypeInstance>,
103
+ storageTypes: ResolvedStorageTypes,
88
104
  ): Pick<StorageColumn, 'codecId' | 'nativeType' | 'typeParams'> {
89
105
  if (!column.typeRef) {
90
106
  return column;
@@ -95,11 +111,23 @@ function resolveColumnTypeMetadata(
95
111
  `Column references storage type "${column.typeRef}" but it is not defined in storage.types.`,
96
112
  );
97
113
  }
98
- return {
99
- codecId: referenced.codecId,
100
- nativeType: referenced.nativeType,
101
- typeParams: referenced.typeParams,
102
- };
114
+ if (isPostgresEnumStorageEntry(referenced)) {
115
+ return {
116
+ codecId: referenced.codecId,
117
+ nativeType: referenced.nativeType,
118
+ typeParams: { values: referenced.values } as Record<string, unknown>,
119
+ };
120
+ }
121
+ if (isStorageTypeInstance(referenced)) {
122
+ return {
123
+ codecId: referenced.codecId,
124
+ nativeType: referenced.nativeType,
125
+ typeParams: referenced.typeParams,
126
+ };
127
+ }
128
+ throw new Error(
129
+ `Storage type "${column.typeRef}" has an unknown polymorphic kind; expected codec-instance or postgres-enum.`,
130
+ );
103
131
  }
104
132
 
105
133
  function convertUnique(unique: UniqueConstraint): SqlUniqueIR {
@@ -129,7 +157,7 @@ function convertForeignKey(fk: ForeignKey): SqlForeignKeyIR {
129
157
  function convertTable(
130
158
  name: string,
131
159
  table: StorageTable,
132
- storageTypes: Record<string, StorageTypeInstance>,
160
+ storageTypes: ResolvedStorageTypes,
133
161
  expandNativeType: NativeTypeExpander | undefined,
134
162
  renderDefault: DefaultRenderer | undefined,
135
163
  ): SqlTableIR {
@@ -250,7 +278,7 @@ export function contractToSchemaIR(
250
278
  }
251
279
 
252
280
  const storage = contract.storage;
253
- const storageTypes = storage.types ?? {};
281
+ const storageTypes = (storage.types ?? {}) as ResolvedStorageTypes;
254
282
  const tables: Record<string, SqlTableIR> = {};
255
283
  for (const [tableName, tableDef] of Object.entries(storage.tables)) {
256
284
  tables[tableName] = convertTable(
@@ -274,11 +302,32 @@ function deriveAnnotations(
274
302
  storage: SqlStorage,
275
303
  annotationNamespace: string,
276
304
  ): SqlAnnotations | undefined {
277
- if (!storage.types || Object.keys(storage.types).length === 0) return undefined;
278
- // Re-key by nativeType to match the structure produced by introspection
279
- const byNativeType: Record<string, (typeof storage.types)[string]> = {};
280
- for (const typeInstance of Object.values(storage.types)) {
281
- byNativeType[typeInstance.nativeType] = typeInstance;
305
+ const types = storage.types as ResolvedStorageTypes | undefined;
306
+ if (!types || Object.keys(types).length === 0) return undefined;
307
+ // Re-key by nativeType, normalising every variant to the codec-typed
308
+ // annotation shape `{codecId, nativeType, typeParams}` produced by the
309
+ // adapter introspector (`introspectPostgresEnumTypes` writes that shape;
310
+ // see also `enum-planning.ts § readExistingEnumValues`, which reads
311
+ // `existing.codecId` + `existing.typeParams.values`). Without this
312
+ // normalisation, the projector would emit the raw
313
+ // `PostgresEnumStorageEntry` shape (top-level `values`, no `typeParams`)
314
+ // and downstream Schema IR consumers that walk the codec-typed shape
315
+ // would see enum entries as new (e.g. the planner emits a fresh
316
+ // `CreateEnumTypeCall` instead of the rebuild recipe). Unknown future
317
+ // kinds without `nativeType` are skipped rather than crashing.
318
+ const byNativeType: Record<string, StorageTypeInstance> = {};
319
+ for (const typeInstance of Object.values(types)) {
320
+ if (isPostgresEnumStorageEntry(typeInstance)) {
321
+ byNativeType[typeInstance.nativeType] = toStorageTypeInstance({
322
+ codecId: typeInstance.codecId,
323
+ nativeType: typeInstance.nativeType,
324
+ typeParams: { values: typeInstance.values },
325
+ });
326
+ continue;
327
+ }
328
+ if (isStorageTypeInstance(typeInstance)) {
329
+ byNativeType[typeInstance.nativeType] = typeInstance;
330
+ }
282
331
  }
283
332
  return { [annotationNamespace]: { storageTypes: byNativeType } };
284
333
  }
@@ -1,6 +1,7 @@
1
1
  import type { Contract } from '@prisma-next/contract/types';
2
2
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
3
3
  import type {
4
+ ContractSerializer,
4
5
  ContractSpace,
5
6
  ControlAdapterDescriptor,
6
7
  ControlDriverInstance,
@@ -18,6 +19,7 @@ import type {
18
19
  OperationContext,
19
20
  OpFactoryCall,
20
21
  SchemaIssue,
22
+ SchemaVerifier,
21
23
  } from '@prisma-next/framework-components/control';
22
24
  import type {
23
25
  SqlStorage,
@@ -313,9 +315,10 @@ export interface SqlMigrationPlannerPlanOptions {
313
315
  * or `null` for reconciliation flows that have no prior contract.
314
316
  *
315
317
  * Required at every call site so the structural fact "I have a prior
316
- * contract / I don't" is visible in the type. `migration plan` supplies
317
- * the previous bundle's `metadata.toContract`; `db update` / `db init`
318
- * reconcile against the live schema and pass `null`. Strategies that
318
+ * contract / I don't" is visible in the type. `migration plan` reads
319
+ * the predecessor bundle's `end-contract.json` from disk and passes
320
+ * the parsed value; `db update` / `db init` reconcile against the
321
+ * live schema and pass `null`. Strategies that
319
322
  * need from/to column-shape comparisons (unsafe type change, nullability
320
323
  * tightening) use this to decide whether to emit `dataTransform`
321
324
  * placeholders; they short-circuit when it is `null`.
@@ -463,9 +466,26 @@ export interface MultiSpaceRunnerFailure extends SqlMigrationRunnerFailure {
463
466
 
464
467
  export type MultiSpaceRunnerResult = Result<MultiSpaceRunnerSuccessValue, MultiSpaceRunnerFailure>;
465
468
 
466
- export interface SqlControlTargetDescriptor<TTargetId extends string, TTargetDetails>
467
- extends MigratableTargetDescriptor<'sql', TTargetId, SqlControlFamilyInstance> {
469
+ export interface SqlControlTargetDescriptor<
470
+ TTargetId extends string,
471
+ TTargetDetails,
472
+ TContract extends Contract<SqlStorage> = Contract<SqlStorage>,
473
+ > extends MigratableTargetDescriptor<'sql', TTargetId, SqlControlFamilyInstance> {
468
474
  readonly queryOperations?: () => SqlOperationDescriptors;
475
+ /**
476
+ * JSON ⇄ class boundary for the SQL target's contract. The descriptor
477
+ * composes a concrete `SqlContractSerializerBase` subclass; the rest
478
+ * of the control stack reaches `descriptor.contractSerializer` rather
479
+ * than importing a per-target deserialization function.
480
+ */
481
+ readonly contractSerializer: ContractSerializer<TContract>;
482
+ /**
483
+ * Per-target schema verifier walking the contract against
484
+ * `SqlSchemaIR`. The descriptor composes a concrete
485
+ * `SqlSchemaVerifierBase` subclass; the family-shared walk lives on
486
+ * the base, the target-specific dispatch on the subclass.
487
+ */
488
+ readonly schemaVerifier: SchemaVerifier<TContract, SqlSchemaIR>;
469
489
  createPlanner(family: SqlControlFamilyInstance): SqlMigrationPlanner<TTargetDetails>;
470
490
  createRunner(family: SqlControlFamilyInstance): SqlMigrationRunner<TTargetDetails>;
471
491
  }
@@ -14,10 +14,13 @@ import type {
14
14
  SchemaVerificationNode,
15
15
  VerifyDatabaseSchemaResult,
16
16
  } from '@prisma-next/framework-components/control';
17
- import type {
18
- SqlStorage,
19
- StorageColumn,
20
- StorageTypeInstance,
17
+ import {
18
+ isPostgresEnumStorageEntry,
19
+ isStorageTypeInstance,
20
+ type PostgresEnumStorageEntry,
21
+ type SqlStorage,
22
+ type StorageColumn,
23
+ type StorageTypeInstance,
21
24
  } from '@prisma-next/sql-contract/types';
22
25
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
23
26
  import { canonicalStringify } from '@prisma-next/utils/canonical-stringify';
@@ -80,6 +83,21 @@ export interface VerifySqlSchemaOptions {
80
83
  * with contract native types (e.g., Postgres 'varchar' → 'character varying').
81
84
  */
82
85
  readonly normalizeNativeType?: NativeTypeNormalizer;
86
+ /**
87
+ * Bridging adapter that resolves the existing values for a `PostgresEnumStorageEntry`
88
+ * (looked up by its native type) from the introspected schema IR. Targets
89
+ * supply this so the family-level verifier can walk `PostgresEnumStorageEntry` instances
90
+ * natively without reaching into target-specific `schema.annotations`
91
+ * shapes itself.
92
+ *
93
+ * Returning `null` indicates the type is missing from the database; the
94
+ * verifier emits a `type_missing` issue. A non-null array triggers a
95
+ * value-set comparison against the contract's `PostgresEnumStorageEntry.values`.
96
+ */
97
+ readonly resolveExistingEnumValues?: (
98
+ schema: SqlSchemaIR,
99
+ enumType: PostgresEnumStorageEntry,
100
+ ) => readonly string[] | null;
83
101
  }
84
102
 
85
103
  /**
@@ -101,6 +119,7 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
101
119
  typeMetadataRegistry,
102
120
  normalizeDefault,
103
121
  normalizeNativeType,
122
+ resolveExistingEnumValues,
104
123
  } = options;
105
124
  const startTime = Date.now();
106
125
 
@@ -109,7 +128,9 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
109
128
 
110
129
  const { contractStorageHash, contractProfileHash, contractTarget } =
111
130
  extractContractMetadata(contract);
112
- const storageTypes = contract.storage.types ?? {};
131
+ const storageTypes = (contract.storage.types ?? {}) as Readonly<
132
+ Record<string, PostgresEnumStorageEntry | StorageTypeInstance>
133
+ >;
113
134
  const { issues, rootChildren } = verifySchemaTables({
114
135
  contract,
115
136
  schema,
@@ -123,15 +144,28 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
123
144
 
124
145
  validateFrameworkComponentsForExtensions(contract, options.frameworkComponents);
125
146
 
126
- // Verify storage type instances via codec control hooks (pure, deterministic)
147
+ // Verify storage type instances. PostgresEnumStorageEntry entries are walked
148
+ // natively (using the bridging adapter `resolveExistingEnumValues`);
149
+ // remaining codec-typed entries continue to dispatch through the
150
+ // generic codec-hook `verifyType` path.
127
151
  const storageTypeEntries = Object.entries(storageTypes);
128
152
  if (storageTypeEntries.length > 0) {
129
153
  const typeNodes: SchemaVerificationNode[] = [];
130
154
  for (const [typeName, typeInstance] of storageTypeEntries) {
131
- const hook = codecHooks.get(typeInstance.codecId);
132
- const typeIssues = hook?.verifyType
133
- ? hook.verifyType({ typeName, typeInstance, schema })
134
- : [];
155
+ let typeIssues: readonly SchemaIssue[];
156
+ if (isPostgresEnumStorageEntry(typeInstance)) {
157
+ typeIssues = verifyEnumType({
158
+ typeName,
159
+ typeInstance,
160
+ schema,
161
+ resolveExistingEnumValues,
162
+ });
163
+ } else if (isStorageTypeInstance(typeInstance)) {
164
+ const hook = codecHooks.get(typeInstance.codecId);
165
+ typeIssues = hook?.verifyType ? hook.verifyType({ typeName, typeInstance, schema }) : [];
166
+ } else {
167
+ typeIssues = [];
168
+ }
135
169
  if (typeIssues.length > 0) {
136
170
  issues.push(...typeIssues);
137
171
  }
@@ -214,6 +248,56 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
214
248
 
215
249
  type VerificationStatus = 'pass' | 'warn' | 'fail';
216
250
 
251
+ /**
252
+ * Native verification walk for `PostgresEnumStorageEntry` instances (no codec hook).
253
+ *
254
+ * Bridges the native `PostgresEnumStorageEntry.values` against the introspected schema
255
+ * IR via the target-supplied `resolveExistingEnumValues` adapter. Without an
256
+ * adapter, the verifier conservatively reports the enum as missing — there
257
+ * is no other way for the family layer to learn about live enum types.
258
+ */
259
+ function verifyEnumType(options: {
260
+ readonly typeName: string;
261
+ readonly typeInstance: PostgresEnumStorageEntry;
262
+ readonly schema: SqlSchemaIR;
263
+ readonly resolveExistingEnumValues?:
264
+ | ((schema: SqlSchemaIR, enumType: PostgresEnumStorageEntry) => readonly string[] | null)
265
+ | undefined;
266
+ }): readonly SchemaIssue[] {
267
+ const { typeName, typeInstance, schema, resolveExistingEnumValues } = options;
268
+ const desired = typeInstance.values;
269
+ const existing = resolveExistingEnumValues?.(schema, typeInstance) ?? null;
270
+ if (!existing) {
271
+ return [
272
+ {
273
+ kind: 'type_missing',
274
+ typeName,
275
+ message: `Type "${typeName}" is missing from database`,
276
+ },
277
+ ];
278
+ }
279
+ if (arraysEqual(existing, desired)) {
280
+ return [];
281
+ }
282
+ const existingSet = new Set(existing);
283
+ const desiredSet = new Set(desired);
284
+ const addedValues = desired.filter((v) => !existingSet.has(v));
285
+ const removedValues = existing.filter((v) => !desiredSet.has(v));
286
+ const message =
287
+ removedValues.length === 0
288
+ ? `Enum type "${typeName}" needs new values: ${addedValues.join(', ')}`
289
+ : `Enum type "${typeName}" values changed (requires rebuild): +[${addedValues.join(', ')}] -[${removedValues.join(', ')}]`;
290
+ return [
291
+ {
292
+ kind: 'enum_values_changed' as const,
293
+ typeName,
294
+ addedValues,
295
+ removedValues,
296
+ message,
297
+ },
298
+ ];
299
+ }
300
+
217
301
  function extractContractMetadata(contract: Contract<SqlStorage>): {
218
302
  contractStorageHash: SqlStorage['storageHash'];
219
303
  contractProfileHash?: Contract<SqlStorage>['profileHash'] | undefined;
@@ -235,7 +319,7 @@ function verifySchemaTables(options: {
235
319
  strict: boolean;
236
320
  typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
237
321
  codecHooks: Map<string, CodecControlHooks>;
238
- storageTypes: Record<string, StorageTypeInstance>;
322
+ storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
239
323
  normalizeDefault?: DefaultNormalizer;
240
324
  normalizeNativeType?: NativeTypeNormalizer;
241
325
  }): { issues: SchemaIssue[]; rootChildren: SchemaVerificationNode[] } {
@@ -329,7 +413,7 @@ function verifyTableChildren(options: {
329
413
  strict: boolean;
330
414
  typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
331
415
  codecHooks: Map<string, CodecControlHooks>;
332
- storageTypes: Record<string, StorageTypeInstance>;
416
+ storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
333
417
  normalizeDefault?: DefaultNormalizer;
334
418
  normalizeNativeType?: NativeTypeNormalizer;
335
419
  }): SchemaVerificationNode[] {
@@ -487,7 +571,7 @@ function collectContractColumnNodes(options: {
487
571
  strict: boolean;
488
572
  typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
489
573
  codecHooks: Map<string, CodecControlHooks>;
490
- storageTypes: Record<string, StorageTypeInstance>;
574
+ storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
491
575
  normalizeDefault?: DefaultNormalizer;
492
576
  normalizeNativeType?: NativeTypeNormalizer;
493
577
  }): SchemaVerificationNode[] {
@@ -594,7 +678,7 @@ function verifyColumn(options: {
594
678
  strict: boolean;
595
679
  typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
596
680
  codecHooks: Map<string, CodecControlHooks>;
597
- storageTypes: Record<string, StorageTypeInstance>;
681
+ storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
598
682
  normalizeDefault?: DefaultNormalizer;
599
683
  normalizeNativeType?: NativeTypeNormalizer;
600
684
  }): SchemaVerificationNode {
@@ -937,7 +1021,7 @@ function validateFrameworkComponentsForExtensions(
937
1021
  */
938
1022
  function renderExpectedNativeType(
939
1023
  contractColumn: Contract<SqlStorage>['storage']['tables'][string]['columns'][string],
940
- storageTypes: Record<string, StorageTypeInstance>,
1024
+ storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>,
941
1025
  codecHooks: Map<string, CodecControlHooks>,
942
1026
  context?: {
943
1027
  readonly tableName: string;
@@ -967,7 +1051,7 @@ function renderExpectedNativeType(
967
1051
 
968
1052
  function resolveContractColumnTypeMetadata(
969
1053
  contractColumn: StorageColumn,
970
- storageTypes: Record<string, StorageTypeInstance>,
1054
+ storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>,
971
1055
  context?: {
972
1056
  readonly tableName: string;
973
1057
  readonly columnName: string;
@@ -987,11 +1071,23 @@ function resolveContractColumnTypeMetadata(
987
1071
  );
988
1072
  }
989
1073
 
990
- return {
991
- codecId: referencedType.codecId,
992
- nativeType: referencedType.nativeType,
993
- typeParams: referencedType.typeParams,
994
- };
1074
+ if (isPostgresEnumStorageEntry(referencedType)) {
1075
+ return {
1076
+ codecId: referencedType.codecId,
1077
+ nativeType: referencedType.nativeType,
1078
+ typeParams: { values: referencedType.values } as Record<string, unknown>,
1079
+ };
1080
+ }
1081
+ if (isStorageTypeInstance(referencedType)) {
1082
+ return {
1083
+ codecId: referencedType.codecId,
1084
+ nativeType: referencedType.nativeType,
1085
+ typeParams: referencedType.typeParams,
1086
+ };
1087
+ }
1088
+ throw new Error(
1089
+ `Storage type "${contractColumn.typeRef}" has an unknown polymorphic kind; expected codec-instance or postgres-enum.`,
1090
+ );
995
1091
  }
996
1092
 
997
1093
  /**
@@ -13,7 +13,7 @@ export type {
13
13
  } from '@prisma-next/framework-components/control';
14
14
  export { assembleAuthoringContributions } from '@prisma-next/framework-components/control';
15
15
  export { extractCodecControlHooks } from '../core/assembly';
16
- export type { SchemaVerifyOptions, SqlControlFamilyInstance } from '../core/control-instance';
16
+ export type { SqlControlFamilyInstance } from '../core/control-instance';
17
17
  export type {
18
18
  ContractToSchemaIROptions,
19
19
  DefaultRenderer,
@@ -0,0 +1,6 @@
1
+ export { SqlContractSerializer } from '../core/ir/sql-contract-serializer';
2
+ export {
3
+ SqlContractSerializerBase,
4
+ type SqlEntityHydrationFactory,
5
+ } from '../core/ir/sql-contract-serializer-base';
6
+ export { SqlSchemaVerifierBase } from '../core/ir/sql-schema-verifier-base';