@prisma-next/sql-contract-ts 0.14.0-dev.6 → 0.14.0-dev.61

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.
package/package.json CHANGED
@@ -1,27 +1,27 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract-ts",
3
- "version": "0.14.0-dev.6",
3
+ "version": "0.14.0-dev.61",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "SQL-specific TypeScript contract authoring surface for Prisma Next",
8
8
  "dependencies": {
9
- "@prisma-next/config": "0.14.0-dev.6",
10
- "@prisma-next/contract": "0.14.0-dev.6",
11
- "@prisma-next/contract-authoring": "0.14.0-dev.6",
12
- "@prisma-next/framework-components": "0.14.0-dev.6",
13
- "@prisma-next/sql-contract": "0.14.0-dev.6",
14
- "@prisma-next/utils": "0.14.0-dev.6",
9
+ "@prisma-next/config": "0.14.0-dev.61",
10
+ "@prisma-next/contract": "0.14.0-dev.61",
11
+ "@prisma-next/contract-authoring": "0.14.0-dev.61",
12
+ "@prisma-next/framework-components": "0.14.0-dev.61",
13
+ "@prisma-next/sql-contract": "0.14.0-dev.61",
14
+ "@prisma-next/utils": "0.14.0-dev.61",
15
15
  "arktype": "^2.2.0",
16
16
  "pathe": "^2.0.3",
17
17
  "ts-toolbelt": "^9.6.0"
18
18
  },
19
19
  "devDependencies": {
20
- "@prisma-next/test-utils": "0.14.0-dev.6",
21
- "@prisma-next/tsconfig": "0.14.0-dev.6",
20
+ "@prisma-next/test-utils": "0.14.0-dev.61",
21
+ "@prisma-next/tsconfig": "0.14.0-dev.61",
22
22
  "@types/pg": "8.20.0",
23
23
  "pg": "8.21.0",
24
- "@prisma-next/tsdown": "0.14.0-dev.6",
24
+ "@prisma-next/tsdown": "0.14.0-dev.61",
25
25
  "tsdown": "0.22.1",
26
26
  "typescript": "5.9.3",
27
27
  "vitest": "4.1.8"
@@ -63,7 +63,7 @@ export function createTypeHelpersFromNamespace(
63
63
  continue;
64
64
  }
65
65
 
66
- helpers[key] = createTypeHelpersFromNamespace(value as AuthoringTypeNamespace, currentPath);
66
+ helpers[key] = createTypeHelpersFromNamespace(value, currentPath);
67
67
  }
68
68
 
69
69
  return helpers;
@@ -133,11 +133,7 @@ export function createFieldHelpersFromNamespace(
133
133
  continue;
134
134
  }
135
135
 
136
- helpers[key] = createFieldHelpersFromNamespace(
137
- value as AuthoringFieldNamespace,
138
- createLeafHelper,
139
- currentPath,
140
- );
136
+ helpers[key] = createFieldHelpersFromNamespace(value, createLeafHelper, currentPath);
141
137
  }
142
138
 
143
139
  return helpers;
@@ -22,9 +22,16 @@ import {
22
22
  type ValueSetRef,
23
23
  } from '@prisma-next/contract/types';
24
24
  import { type CapabilityMatrix, mergeCapabilityMatrices } from '@prisma-next/contract-authoring';
25
- import type { CodecLookup } from '@prisma-next/framework-components/codec';
25
+ import type {
26
+ AuthoringContributions,
27
+ AuthoringEntityTypeDescriptor,
28
+ AuthoringEntityTypeNamespace,
29
+ } from '@prisma-next/framework-components/authoring';
30
+ import { isAuthoringEntityTypeDescriptor } from '@prisma-next/framework-components/authoring';
31
+ import type { CodecLookup, ColumnTypeDescriptor } from '@prisma-next/framework-components/codec';
26
32
  import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
27
33
  import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks';
34
+ import { tableEntityKind, valueSetEntityKind } from '@prisma-next/sql-contract/entity-kinds';
28
35
  import { validateIndexTypes } from '@prisma-next/sql-contract/index-type-validation';
29
36
  import {
30
37
  createIndexTypeRegistry,
@@ -33,9 +40,8 @@ import {
33
40
  } from '@prisma-next/sql-contract/index-types';
34
41
  import {
35
42
  applyFkDefaults,
36
- buildSqlNamespace,
37
43
  type CheckConstraintInput,
38
- type SqlNamespaceTablesInput,
44
+ type SqlNamespaceInput,
39
45
  SqlStorage,
40
46
  type SqlStorageInput,
41
47
  type StorageColumn,
@@ -45,6 +51,7 @@ import {
45
51
  toStorageTypeInstance,
46
52
  } from '@prisma-next/sql-contract/types';
47
53
  import { validateStorageSemantics } from '@prisma-next/sql-contract/validators';
54
+ import { deriveValueSetFromEntity } from '@prisma-next/sql-contract/value-set-derivation-hook';
48
55
  import { blindCast } from '@prisma-next/utils/casts';
49
56
  import { ifDefined } from '@prisma-next/utils/defined';
50
57
  import type {
@@ -74,10 +81,23 @@ function encodeColumnDefault(
74
81
  defaultInput: ColumnDefault,
75
82
  codecId: string,
76
83
  codecLookup?: CodecLookup,
84
+ many = false,
77
85
  ): ColumnDefault {
78
86
  if (defaultInput.kind === 'function') {
79
87
  return { kind: 'function', expression: defaultInput.expression };
80
88
  }
89
+ if (many) {
90
+ if (!Array.isArray(defaultInput.value)) {
91
+ throw new Error(
92
+ `Literal default on a list column must be an array; received ${typeof defaultInput.value}. ` +
93
+ 'A scalar default on a list field must be rejected at the authoring surface.',
94
+ );
95
+ }
96
+ return {
97
+ kind: 'literal',
98
+ value: defaultInput.value.map((element) => encodeViaCodec(element, codecId, codecLookup)),
99
+ };
100
+ }
81
101
  return {
82
102
  kind: 'literal',
83
103
  value: encodeViaCodec(defaultInput.value, codecId, codecLookup),
@@ -160,6 +180,174 @@ function isValueObjectField(
160
180
  return 'valueObjectName' in field;
161
181
  }
162
182
 
183
+ /**
184
+ * Resolves a deferred entity-ref column descriptor (e.g. a `pg.enum(handle)`
185
+ * column) against the field's now-known owning namespace: attaches the
186
+ * storage `valueSet` ref the collected entity's derived value-set is stored
187
+ * under. `nativeType` / `typeParams.typeName` stay bare here — schema
188
+ * qualification (e.g. `auth.aal_level`) is a target concern applied in the
189
+ * next step, `qualifyColumnDescriptor`. A descriptor with no `entityRef` (the
190
+ * ordinary case) passes through unchanged.
191
+ */
192
+ function resolveEntityRefDescriptor(
193
+ descriptor: ColumnTypeDescriptor,
194
+ namespaceId: string,
195
+ ): ColumnTypeDescriptor {
196
+ const entityRef = descriptor.entityRef;
197
+ if (entityRef === undefined) return descriptor;
198
+
199
+ return {
200
+ ...descriptor,
201
+ valueSet: {
202
+ plane: 'storage',
203
+ entityKind: 'valueSet',
204
+ namespaceId,
205
+ entityName: entityRef.entityName,
206
+ },
207
+ };
208
+ }
209
+
210
+ /**
211
+ * A target's contract-construction-time column-type qualifier, contributed
212
+ * through `target.authoring.qualifyColumnType`. Given a column's bare type
213
+ * info and its owning `namespaceId`, it returns the type info the target's
214
+ * schema semantics require (e.g. Postgres schema-qualifies a native-enum
215
+ * column's type name to `auth.aal_level`). The dispatch keys off the codec
216
+ * id, so every codec — including ones needing no change — is passed through
217
+ * and the caller stays codec-blind. Targets without the hook leave every
218
+ * column bare.
219
+ */
220
+ type ColumnTypeQualifier = (
221
+ input: {
222
+ readonly codecId: string;
223
+ readonly nativeType: string;
224
+ readonly typeParams?: Record<string, unknown>;
225
+ },
226
+ namespaceId: string,
227
+ ) => { readonly nativeType: string; readonly typeParams?: Record<string, unknown> };
228
+
229
+ /**
230
+ * Structural check for a target that contributes a `qualifyColumnType` hook
231
+ * on its authoring contributions. Duck-typed (mirroring
232
+ * `contract-psl`'s `hasColumnFromEntityHook`) so the SQL family stays blind
233
+ * to the target's qualification logic and no framework/family interface has
234
+ * to name the hook.
235
+ */
236
+ function hasColumnTypeQualifier(
237
+ authoring: AuthoringContributions,
238
+ ): authoring is AuthoringContributions & { readonly qualifyColumnType: ColumnTypeQualifier } {
239
+ return 'qualifyColumnType' in authoring && typeof authoring.qualifyColumnType === 'function';
240
+ }
241
+
242
+ function resolveColumnTypeQualifier(
243
+ target: ContractDefinition['target'],
244
+ ): ColumnTypeQualifier | undefined {
245
+ const authoring = target.authoring;
246
+ if (authoring === undefined) return undefined;
247
+ return hasColumnTypeQualifier(authoring) ? authoring.qualifyColumnType : undefined;
248
+ }
249
+
250
+ /**
251
+ * Applies the target's `qualifyColumnType` hook to a scalar column descriptor
252
+ * at construction, so the storage column and the domain field (which derives
253
+ * its `type.typeParams` from the storage column) are both built already
254
+ * qualified in a single pass. A descriptor whose codec the target leaves
255
+ * unchanged passes through untouched.
256
+ */
257
+ function qualifyColumnDescriptor(
258
+ descriptor: ColumnTypeDescriptor,
259
+ namespaceId: string,
260
+ qualify: ColumnTypeQualifier | undefined,
261
+ ): ColumnTypeDescriptor {
262
+ if (qualify === undefined) return descriptor;
263
+ const qualified = qualify(
264
+ {
265
+ codecId: descriptor.codecId,
266
+ nativeType: descriptor.nativeType,
267
+ ...ifDefined('typeParams', descriptor.typeParams),
268
+ },
269
+ namespaceId,
270
+ );
271
+ if (
272
+ qualified.nativeType === descriptor.nativeType &&
273
+ qualified.typeParams === descriptor.typeParams
274
+ ) {
275
+ return descriptor;
276
+ }
277
+ return {
278
+ ...descriptor,
279
+ nativeType: qualified.nativeType,
280
+ ...ifDefined('typeParams', qualified.typeParams),
281
+ };
282
+ }
283
+
284
+ type CollectedPackEntities = Record<string, Record<string, Record<string, unknown>>>;
285
+
286
+ /**
287
+ * Records a deferred column's entity-ref into the namespace-scoped collection
288
+ * accumulator, keyed the same way author-declared `packEntities` are
289
+ * (`namespaceId → entityKind → entityName`) — folded into the same namespace
290
+ * assembly `derivePackEntityValueSets`/`entries.<kind>` step, so a collected
291
+ * entity gets its value-set the same way an author-declared one does.
292
+ *
293
+ * The same handle reused by many columns in one namespace is normal (a native
294
+ * enum type backs any number of columns) and records the identical entity once.
295
+ * Two *different* entity instances sharing a name+kind in one namespace is a
296
+ * name collision — the emitted `entries.valueSet.<name>` could only reflect one
297
+ * of them, silently mismatching the other column's type/cast. PSL hard-errors
298
+ * on the equivalent (`PSL_DUPLICATE_DECLARATION`); the TS path rejects it too.
299
+ */
300
+ function collectPackEntityFromColumn(
301
+ collected: CollectedPackEntities,
302
+ namespaceId: string,
303
+ entityRef: NonNullable<ColumnTypeDescriptor['entityRef']>,
304
+ ): void {
305
+ const forNs = collected[namespaceId] ?? {};
306
+ const forKind = forNs[entityRef.entityKind] ?? {};
307
+ const existing = forKind[entityRef.entityName];
308
+ if (existing !== undefined && existing !== entityRef.entity) {
309
+ throw new Error(
310
+ `buildSqlContractFromDefinition: two different "${entityRef.entityKind}" entities named "${entityRef.entityName}" in namespace "${namespaceId}" — pack-entity names must be unique per namespace.`,
311
+ );
312
+ }
313
+ forKind[entityRef.entityName] = entityRef.entity;
314
+ forNs[entityRef.entityKind] = forKind;
315
+ collected[namespaceId] = forNs;
316
+ }
317
+
318
+ /**
319
+ * Merges the author-declared `packEntities` for one namespace with the entities
320
+ * collected from that namespace's deferred entity-ref columns. A collected
321
+ * entity that shadows a *different* declared entity of the same kind+name (or
322
+ * vice-versa) is the same name-collision bug `collectPackEntityFromColumn`
323
+ * guards against across columns, so it is rejected the same way — by entity
324
+ * identity, so the same handle declared and used by a column does not throw.
325
+ */
326
+ function mergeCollectedPackEntities(
327
+ namespaceId: string,
328
+ declared: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
329
+ collected: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
330
+ ): Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined {
331
+ if (declared === undefined) return collected;
332
+ if (collected === undefined) return declared;
333
+ const kinds = new Set([...Object.keys(declared), ...Object.keys(collected)]);
334
+ const result: Record<string, Readonly<Record<string, unknown>>> = {};
335
+ for (const kind of kinds) {
336
+ const declaredForKind = declared[kind];
337
+ const collectedForKind = collected[kind];
338
+ for (const [name, entity] of Object.entries(collectedForKind ?? {})) {
339
+ const existing = declaredForKind?.[name];
340
+ if (existing !== undefined && existing !== entity) {
341
+ throw new Error(
342
+ `buildSqlContractFromDefinition: two different "${kind}" entities named "${name}" in namespace "${namespaceId}" — a collected pack entity conflicts with an author-declared one; pack-entity names must be unique per namespace.`,
343
+ );
344
+ }
345
+ }
346
+ result[kind] = { ...declaredForKind, ...collectedForKind };
347
+ }
348
+ return result;
349
+ }
350
+
163
351
  const JSONB_CODEC_ID = 'pg/jsonb@1';
164
352
  const JSONB_NATIVE_TYPE = 'jsonb';
165
353
 
@@ -235,28 +423,28 @@ function buildStorageColumn(
235
423
  };
236
424
  }
237
425
 
238
- if (field.many) {
239
- return {
240
- nativeType: JSONB_NATIVE_TYPE,
241
- codecId: JSONB_CODEC_ID,
242
- nullable: field.nullable,
243
- };
244
- }
245
-
246
426
  const codecId = field.descriptor.codecId;
247
427
  const encodedDefault =
248
428
  field.default !== undefined
249
- ? encodeColumnDefault(field.default, codecId, codecLookup)
429
+ ? encodeColumnDefault(field.default, codecId, codecLookup, field.many === true)
250
430
  : undefined;
251
431
 
432
+ // `storageValueSetRef` (derived from an `enumTypeHandle`) takes precedence
433
+ // when present — the established domain-enum path. `field.descriptor.valueSet`
434
+ // is the fallback: set by an entity-ref type constructor (e.g. `pg.enum(Ref)`)
435
+ // that resolved the field's type against a value-set-deriving entity with no
436
+ // domain enum involved. A field carries at most one of the two in practice.
437
+ const valueSet = storageValueSetRef ?? field.descriptor.valueSet;
438
+
252
439
  return {
253
440
  nativeType: field.descriptor.nativeType,
254
441
  codecId,
255
442
  nullable: field.nullable,
443
+ ...(field.many ? { many: true as const } : {}),
256
444
  ...ifDefined('typeParams', field.descriptor.typeParams),
257
445
  ...ifDefined('default', encodedDefault),
258
446
  ...ifDefined('typeRef', field.descriptor.typeRef),
259
- ...ifDefined('valueSet', storageValueSetRef),
447
+ ...ifDefined('valueSet', valueSet),
260
448
  };
261
449
  }
262
450
 
@@ -298,9 +486,122 @@ function collectStorageNamespaceCoordinateIds(definition: ContractDefinition): S
298
486
  ids.add(model.namespaceId);
299
487
  }
300
488
  }
489
+ for (const id of Object.keys(definition.packEntities ?? {})) {
490
+ if (id.length > 0) {
491
+ ids.add(id);
492
+ }
493
+ }
301
494
  return ids;
302
495
  }
303
496
 
497
+ /**
498
+ * Entry kinds the framework assembler itself manages (`table` from models,
499
+ * `valueSet` from `enums` and pack-entity value-set derivation). An
500
+ * author-declared pack entity claiming one of these would silently clobber
501
+ * or be clobbered by the managed slot, so it is rejected outright.
502
+ */
503
+ const MANAGED_ENTRY_KINDS = new Set([tableEntityKind.kind, valueSetEntityKind.kind]);
504
+
505
+ function assertNoManagedPackEntityKinds(
506
+ namespaceId: string,
507
+ packEntitiesForNs: Readonly<Record<string, unknown>> | undefined,
508
+ ): void {
509
+ if (packEntitiesForNs === undefined) return;
510
+ for (const kind of Object.keys(packEntitiesForNs)) {
511
+ if (MANAGED_ENTRY_KINDS.has(kind)) {
512
+ throw new Error(
513
+ `buildSqlContractFromDefinition: packEntities in namespace "${namespaceId}" declares entry kind "${kind}", which is managed by the framework (table/valueSet) and cannot be supplied via packEntities.`,
514
+ );
515
+ }
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Walks the flat `entityTypes` namespace tree contributed by the target pack
521
+ * and every extension pack, indexing descriptors by their `discriminator` —
522
+ * the same string a pack entity's entries-map key (`entries.<kind>`) uses.
523
+ * Mirrors `contract-psl`'s `buildEntityTypesByDiscriminator`, recomposed here
524
+ * from the packs `ContractDefinition` already carries (`target` +
525
+ * `extensionPacks`) since the TS assembler has no single pre-merged
526
+ * `AuthoringContributions` input to read the way the PSL interpreter does.
527
+ */
528
+ function collectEntityTypeDescriptorsByDiscriminator(
529
+ definition: ContractDefinition,
530
+ ): ReadonlyMap<string, AuthoringEntityTypeDescriptor> {
531
+ const result = new Map<string, AuthoringEntityTypeDescriptor>();
532
+ const walk = (namespace: AuthoringEntityTypeNamespace): void => {
533
+ for (const value of Object.values(namespace)) {
534
+ if (isAuthoringEntityTypeDescriptor(value)) {
535
+ result.set(value.discriminator, value);
536
+ } else {
537
+ walk(value);
538
+ }
539
+ }
540
+ };
541
+ const components = [definition.target, ...Object.values(definition.extensionPacks ?? {})];
542
+ for (const component of components) {
543
+ const entityTypes = component.authoring?.entityTypes;
544
+ if (entityTypes !== undefined) {
545
+ walk(entityTypes);
546
+ }
547
+ }
548
+ return result;
549
+ }
550
+
551
+ /**
552
+ * Derives value-sets for every pack entity declared in one namespace,
553
+ * reusing the same `SqlValueSetDerivingEntityTypeOutput.deriveValueSet` hook
554
+ * `contract-psl`'s `lowerExtensionBlocksForNamespace` folds into
555
+ * `entries.valueSet` on the PSL path — so a TS-attached entity (e.g. a
556
+ * native enum) gets its value-set the same way. Entity kinds with no
557
+ * registered descriptor, or whose descriptor output doesn't derive a
558
+ * value-set, contribute nothing.
559
+ */
560
+ function derivePackEntityValueSets(
561
+ packEntitiesForNs: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
562
+ entityTypesByDiscriminator: ReadonlyMap<string, AuthoringEntityTypeDescriptor>,
563
+ ): Record<string, StorageValueSetInput> | undefined {
564
+ if (packEntitiesForNs === undefined) return undefined;
565
+ let result: Record<string, StorageValueSetInput> | undefined;
566
+ for (const [kind, entitiesByName] of Object.entries(packEntitiesForNs)) {
567
+ const descriptor = entityTypesByDiscriminator.get(kind);
568
+ if (descriptor === undefined) continue;
569
+ for (const [name, entity] of Object.entries(entitiesByName)) {
570
+ const derivedValueSet = deriveValueSetFromEntity(descriptor.output, entity);
571
+ if (derivedValueSet === undefined) continue;
572
+ result ??= {};
573
+ result[name] = derivedValueSet;
574
+ }
575
+ }
576
+ return result;
577
+ }
578
+
579
+ /**
580
+ * Merges a namespace's `enumType()`-derived value-sets with its pack-entity-
581
+ * derived value-sets. Both land in the same `entries.valueSet[name]` slot —
582
+ * which drives value-set → codec typing and the domain-enum CHECK — so a
583
+ * same-named entry in both would let one silently overwrite the other and
584
+ * corrupt whichever column resolves against it. The same collision class the
585
+ * `mergeCollectedPackEntities` guard rejects; the PSL path already hard-errors
586
+ * on the equivalent (`interpretPslDocumentToSqlContract`). Reject it here too.
587
+ */
588
+ function mergeNamespaceValueSets(
589
+ namespaceId: string,
590
+ enumValueSets: Record<string, StorageValueSetInput> | undefined,
591
+ packValueSets: Record<string, StorageValueSetInput> | undefined,
592
+ ): Record<string, StorageValueSetInput> {
593
+ if (enumValueSets !== undefined && packValueSets !== undefined) {
594
+ for (const name of Object.keys(packValueSets)) {
595
+ if (Object.hasOwn(enumValueSets, name)) {
596
+ throw new Error(
597
+ `buildSqlContractFromDefinition: value-set "${name}" in namespace "${namespaceId}" is derived from both an enum and a pack entity — names must be unique per namespace.`,
598
+ );
599
+ }
600
+ }
601
+ }
602
+ return { ...enumValueSets, ...packValueSets };
603
+ }
604
+
304
605
  function ensureUnboundNamespaceSlot(
305
606
  namespaces: SqlStorageInput['namespaces'],
306
607
  createNamespace: ContractDefinition['createNamespace'],
@@ -308,18 +609,15 @@ function ensureUnboundNamespaceSlot(
308
609
  if (Object.hasOwn(namespaces, UNBOUND_NAMESPACE_ID)) {
309
610
  return namespaces;
310
611
  }
311
- const unboundInput: SqlNamespaceTablesInput = {
612
+ const unboundInput: SqlNamespaceInput = {
312
613
  id: UNBOUND_NAMESPACE_ID,
313
614
  entries: { table: {} },
314
615
  };
315
- const unbound = createNamespace ? createNamespace(unboundInput) : buildSqlNamespace(unboundInput);
316
- return blindCast<
317
- SqlStorageInput['namespaces'],
318
- 'createNamespace may return a target namespace concretion; the unbound slot matches SqlNamespace at runtime'
319
- >({
616
+ const unbound = createNamespace(unboundInput);
617
+ return {
320
618
  [UNBOUND_NAMESPACE_ID]: unbound,
321
619
  ...namespaces,
322
- });
620
+ };
323
621
  }
324
622
 
325
623
  export function buildSqlContractFromDefinition(
@@ -328,6 +626,7 @@ export function buildSqlContractFromDefinition(
328
626
  ): Contract<SqlStorage> {
329
627
  const target = definition.target.targetId;
330
628
  const defaultNamespaceId = definition.target.defaultNamespaceId;
629
+ const qualifyColumnType = resolveColumnTypeQualifier(definition.target);
331
630
  const targetFamily = 'sql';
332
631
  const resolveNamespaceId = (m: ModelNode): string =>
333
632
  m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId;
@@ -346,6 +645,7 @@ export function buildSqlContractFromDefinition(
346
645
  const modelNameToNamespaceId = new Map<string, string>();
347
646
  const executionDefaults: ExecutionMutationDefault[] = [];
348
647
  const modelsByNamespace: Record<string, Record<string, ContractModel>> = {};
648
+ const collectedPackEntities: CollectedPackEntities = {};
349
649
  const rootEntries: Array<{
350
650
  readonly tableName: string;
351
651
  readonly namespaceId: string;
@@ -375,6 +675,7 @@ export function buildSqlContractFromDefinition(
375
675
  const fieldToColumn: Record<string, string> = {};
376
676
  const domainFields: Record<string, ContractField> = {};
377
677
  const domainFieldRefs: Record<string, DomainFieldRef> = {};
678
+ const checksForTable: CheckConstraintInput[] = [];
378
679
 
379
680
  for (const field of semanticModel.fields) {
380
681
  const executionDefaultPhases =
@@ -417,10 +718,58 @@ export function buildSqlContractFromDefinition(
417
718
  }
418
719
  : undefined;
419
720
 
420
- const column = buildStorageColumn(field, storageValueSetRef, codecLookup);
721
+ // A field authored through a deferred entity-ref column helper (e.g.
722
+ // `pg.enum(handle)`) carries `descriptor.entityRef`: the referenced
723
+ // entity is collected into `collectedPackEntities` (folded into the
724
+ // same `entries.<kind>` + `entries.valueSet` assembly an author-declared
725
+ // `packEntities` entry goes through) and the descriptor is resolved
726
+ // against this field's now-known `namespaceId` — the builder call that
727
+ // produced it ran before the enclosing model associated one. The
728
+ // descriptor is then handed to the target's `qualifyColumnType` hook,
729
+ // which schema-qualifies a native-enum column's type name for its
730
+ // namespace. Keying off the codec id (inside the hook) catches both the
731
+ // TS `pg.enum(handle)` path (via `entityRef`) and the PSL `pg.enum(Ref)`
732
+ // path (resolved inline in the interpreter, no `entityRef`). Because the
733
+ // storage column is built from this qualified descriptor and the domain
734
+ // field derives its `type.typeParams` from that column, both come out
735
+ // qualified in this single pass.
736
+ let resolvedField: FieldNode | ValueObjectFieldNode = field;
737
+ if (!isValueObjectField(field)) {
738
+ let descriptor = field.descriptor;
739
+ const entityRef = descriptor.entityRef;
740
+ if (entityRef !== undefined) {
741
+ collectPackEntityFromColumn(collectedPackEntities, namespaceId, entityRef);
742
+ descriptor = resolveEntityRefDescriptor(descriptor, namespaceId);
743
+ }
744
+ descriptor = qualifyColumnDescriptor(descriptor, namespaceId, qualifyColumnType);
745
+ if (descriptor !== field.descriptor) {
746
+ resolvedField = { ...field, descriptor };
747
+ }
748
+ }
749
+
750
+ const column = buildStorageColumn(resolvedField, storageValueSetRef, codecLookup);
421
751
  columns[field.columnName] = column;
422
752
  fieldToColumn[field.fieldName] = field.columnName;
423
753
 
754
+ // A domain enum (`storageValueSetRef`, from an `enumType()` handle) is
755
+ // stored as a plain scalar column (`text`, `int4`, …) with no native
756
+ // type of its own to enforce membership, so it needs an explicit
757
+ // CHECK — scalar or array, since a `text[]` array has no element-level
758
+ // enforcement either. A value set resolved by an entity-ref type
759
+ // constructor (`field.descriptor.valueSet`, e.g. `pg.enum(Ref)`) binds
760
+ // the column to a codec/native-type pairing that IS the storage-level
761
+ // enforcement (a Postgres native enum type, or another target's
762
+ // equivalent) — including array columns, since the target enforces
763
+ // membership on every element of a native-typed array — so no CHECK
764
+ // for those.
765
+ if (column.valueSet !== undefined && storageValueSetRef !== undefined) {
766
+ checksForTable.push({
767
+ name: `${tableName}_${field.columnName}_check`,
768
+ column: field.columnName,
769
+ valueSet: column.valueSet,
770
+ });
771
+ }
772
+
424
773
  domainFields[field.fieldName] = buildDomainField(field, column, domainValueSetRef);
425
774
 
426
775
  if (isValueObjectField(field)) {
@@ -511,15 +860,6 @@ export function buildSqlContractFromDefinition(
511
860
  // materialised onto the base `ModelNode`, so the variant builds a domain
512
861
  // model (below) but no storage table of its own.
513
862
  if (!semanticModel.sharesBaseTable) {
514
- const checksForTable: CheckConstraintInput[] = Object.entries(columns).flatMap(
515
- ([columnName, col]) => {
516
- const valueSet = col.valueSet;
517
- return valueSet === undefined
518
- ? []
519
- : [{ name: `${tableName}_${columnName}_check`, column: columnName, valueSet }];
520
- },
521
- );
522
-
523
863
  const tableInput: StorageTableInput = {
524
864
  columns,
525
865
  ...ifDefined('control', semanticModel.control),
@@ -729,25 +1069,38 @@ export function buildSqlContractFromDefinition(
729
1069
  }
730
1070
 
731
1071
  const { createNamespace } = definition;
732
- const namespaces = blindCast<
733
- SqlStorageInput['namespaces'],
734
- 'contract authoring materialises each namespace coordinate from the model set and explicit namespace list'
735
- >(
736
- Object.fromEntries(
737
- [...namespaceCoordinateIds].sort().map((id) => {
738
- const valueSetEntries = storageValueSetsByNs[id];
739
- const nsInput: SqlNamespaceTablesInput = {
740
- id,
741
- entries: {
742
- table: tablesByNamespace[id] ?? {},
743
- ...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
744
- ? { valueSet: valueSetEntries }
745
- : {}),
746
- },
747
- };
748
- return [id, createNamespace ? createNamespace(nsInput) : buildSqlNamespace(nsInput)];
749
- }),
750
- ),
1072
+ const entityTypesByDiscriminator = collectEntityTypeDescriptorsByDiscriminator(definition);
1073
+ const namespaces: SqlStorageInput['namespaces'] = Object.fromEntries(
1074
+ [...namespaceCoordinateIds].sort().map((id) => {
1075
+ const packEntitiesForNs = mergeCollectedPackEntities(
1076
+ id,
1077
+ definition.packEntities?.[id],
1078
+ collectedPackEntities[id],
1079
+ );
1080
+ assertNoManagedPackEntityKinds(id, packEntitiesForNs);
1081
+
1082
+ const enumValueSetEntries = storageValueSetsByNs[id];
1083
+ const packValueSetEntries = derivePackEntityValueSets(
1084
+ packEntitiesForNs,
1085
+ entityTypesByDiscriminator,
1086
+ );
1087
+ const valueSetEntries =
1088
+ enumValueSetEntries !== undefined || packValueSetEntries !== undefined
1089
+ ? mergeNamespaceValueSets(id, enumValueSetEntries, packValueSetEntries)
1090
+ : undefined;
1091
+
1092
+ const nsInput: SqlNamespaceInput = {
1093
+ id,
1094
+ entries: {
1095
+ table: tablesByNamespace[id] ?? {},
1096
+ ...packEntitiesForNs,
1097
+ ...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
1098
+ ? { valueSet: valueSetEntries }
1099
+ : {}),
1100
+ },
1101
+ };
1102
+ return [id, createNamespace(nsInput)];
1103
+ }),
751
1104
  );
752
1105
  const storageWithoutHash = {
753
1106
  ...(Object.keys(documentTypes).length > 0 ? { types: documentTypes } : {}),
@@ -13,9 +13,6 @@ import type {
13
13
  } from '@prisma-next/framework-components/authoring';
14
14
  import {
15
15
  assertNoCrossRegistryCollisions,
16
- isAuthoringEntityTypeDescriptor,
17
- isAuthoringFieldPresetDescriptor,
18
- isAuthoringTypeConstructorDescriptor,
19
16
  mergeAuthoringNamespaces,
20
17
  } from '@prisma-next/framework-components/authoring';
21
18
  import type {
@@ -183,7 +180,7 @@ function composeTypeNamespace(components: readonly AuthoringComponent[]): Author
183
180
  for (const component of components) {
184
181
  const ns = extractTypeNamespace(component);
185
182
  if (Object.keys(ns).length > 0) {
186
- mergeAuthoringNamespaces(merged, ns, [], isAuthoringTypeConstructorDescriptor, 'type');
183
+ mergeAuthoringNamespaces(merged, ns, [], 'typeConstructor', 'type');
187
184
  }
188
185
  }
189
186
  return merged as AuthoringTypeNamespace;
@@ -194,7 +191,7 @@ function composeFieldNamespace(components: readonly AuthoringComponent[]): Autho
194
191
  for (const component of components) {
195
192
  const ns = extractFieldNamespace(component);
196
193
  if (Object.keys(ns).length > 0) {
197
- mergeAuthoringNamespaces(merged, ns, [], isAuthoringFieldPresetDescriptor, 'field');
194
+ mergeAuthoringNamespaces(merged, ns, [], 'fieldPreset', 'field');
198
195
  }
199
196
  }
200
197
  return merged as AuthoringFieldNamespace;
@@ -207,7 +204,7 @@ function composeEntityNamespace(
207
204
  for (const component of components) {
208
205
  const ns = extractEntitiesNamespace(component);
209
206
  if (Object.keys(ns).length > 0) {
210
- mergeAuthoringNamespaces(merged, ns, [], isAuthoringEntityTypeDescriptor, 'entity');
207
+ mergeAuthoringNamespaces(merged, ns, [], 'entity', 'entity');
211
208
  }
212
209
  }
213
210
  return merged as AuthoringEntityTypeNamespace;