@prisma-next/sql-contract-ts 0.14.0 → 0.15.0-dev.1

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.
@@ -22,9 +22,20 @@ 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';
35
+ import {
36
+ type ForeignKeyAuthoringInput,
37
+ materializeForeignKeysAndIndexes,
38
+ } from '@prisma-next/sql-contract/foreign-key-materialization';
28
39
  import { validateIndexTypes } from '@prisma-next/sql-contract/index-type-validation';
29
40
  import {
30
41
  createIndexTypeRegistry,
@@ -33,9 +44,8 @@ import {
33
44
  } from '@prisma-next/sql-contract/index-types';
34
45
  import {
35
46
  applyFkDefaults,
36
- buildSqlNamespace,
37
47
  type CheckConstraintInput,
38
- type SqlNamespaceTablesInput,
48
+ type SqlNamespaceInput,
39
49
  SqlStorage,
40
50
  type SqlStorageInput,
41
51
  type StorageColumn,
@@ -45,6 +55,7 @@ import {
45
55
  toStorageTypeInstance,
46
56
  } from '@prisma-next/sql-contract/types';
47
57
  import { validateStorageSemantics } from '@prisma-next/sql-contract/validators';
58
+ import { deriveValueSetFromEntity } from '@prisma-next/sql-contract/value-set-derivation-hook';
48
59
  import { blindCast } from '@prisma-next/utils/casts';
49
60
  import { ifDefined } from '@prisma-next/utils/defined';
50
61
  import type {
@@ -74,10 +85,23 @@ function encodeColumnDefault(
74
85
  defaultInput: ColumnDefault,
75
86
  codecId: string,
76
87
  codecLookup?: CodecLookup,
88
+ many = false,
77
89
  ): ColumnDefault {
78
90
  if (defaultInput.kind === 'function') {
79
91
  return { kind: 'function', expression: defaultInput.expression };
80
92
  }
93
+ if (many) {
94
+ if (!Array.isArray(defaultInput.value)) {
95
+ throw new Error(
96
+ `Literal default on a list column must be an array; received ${typeof defaultInput.value}. ` +
97
+ 'A scalar default on a list field must be rejected at the authoring surface.',
98
+ );
99
+ }
100
+ return {
101
+ kind: 'literal',
102
+ value: defaultInput.value.map((element) => encodeViaCodec(element, codecId, codecLookup)),
103
+ };
104
+ }
81
105
  return {
82
106
  kind: 'literal',
83
107
  value: encodeViaCodec(defaultInput.value, codecId, codecLookup),
@@ -160,6 +184,176 @@ function isValueObjectField(
160
184
  return 'valueObjectName' in field;
161
185
  }
162
186
 
187
+ /**
188
+ * Resolves a deferred entity-ref column descriptor (e.g. a `pg.enum(handle)`
189
+ * column) against the field's now-known owning namespace: attaches the
190
+ * storage `valueSet` ref the collected entity's derived value-set is stored
191
+ * under. `nativeType` / `typeParams.typeName` stay bare here — schema
192
+ * qualification (e.g. `auth.aal_level`) is a target concern applied in the
193
+ * next step, `qualifyColumnDescriptor`. A descriptor with no `entityRef` (the
194
+ * ordinary case) passes through unchanged.
195
+ */
196
+ function resolveEntityRefDescriptor(
197
+ descriptor: ColumnTypeDescriptor,
198
+ namespaceId: string,
199
+ ): ColumnTypeDescriptor {
200
+ const entityRef = descriptor.entityRef;
201
+ if (entityRef === undefined) return descriptor;
202
+
203
+ return {
204
+ ...descriptor,
205
+ valueSet: {
206
+ plane: 'storage',
207
+ entityKind: 'valueSet',
208
+ namespaceId,
209
+ entityName: entityRef.entityName,
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
215
+ * A target's contract-construction-time column-type qualifier, contributed
216
+ * through `target.authoring.qualifyColumnType`. Given a column's bare type
217
+ * info and its owning `namespaceId`, it returns the type info the target's
218
+ * schema semantics require (e.g. Postgres schema-qualifies a native-enum
219
+ * column's type name to `auth.aal_level`). The dispatch keys off the codec
220
+ * id, so every codec — including ones needing no change — is passed through
221
+ * and the caller stays codec-blind. Targets without the hook leave every
222
+ * column bare.
223
+ */
224
+ type ColumnTypeQualifier = (
225
+ input: {
226
+ readonly codecId: string;
227
+ readonly nativeType: string;
228
+ readonly typeParams?: Record<string, unknown>;
229
+ },
230
+ namespaceId: string,
231
+ ) => { readonly nativeType: string; readonly typeParams?: Record<string, unknown> };
232
+
233
+ /**
234
+ * Structural check for a target that contributes a `qualifyColumnType` hook
235
+ * on its authoring contributions. Duck-typed (mirroring
236
+ * `contract-psl`'s `hasColumnFromEntityHook`) so the SQL family stays blind
237
+ * to the target's qualification logic and no framework/family interface has
238
+ * to name the hook.
239
+ */
240
+ function hasColumnTypeQualifier(
241
+ authoring: AuthoringContributions,
242
+ ): authoring is AuthoringContributions & { readonly qualifyColumnType: ColumnTypeQualifier } {
243
+ return 'qualifyColumnType' in authoring && typeof authoring.qualifyColumnType === 'function';
244
+ }
245
+
246
+ function resolveColumnTypeQualifier(
247
+ target: ContractDefinition['target'],
248
+ ): ColumnTypeQualifier | undefined {
249
+ const authoring = target.authoring;
250
+ if (authoring === undefined) return undefined;
251
+ return hasColumnTypeQualifier(authoring) ? authoring.qualifyColumnType : undefined;
252
+ }
253
+
254
+ /**
255
+ * Applies the target's `qualifyColumnType` hook to a scalar column descriptor
256
+ * at construction, so the storage column and the domain field (which derives
257
+ * its `type.typeParams` from the storage column) are both built already
258
+ * qualified in a single pass. A descriptor whose codec the target leaves
259
+ * unchanged passes through untouched.
260
+ */
261
+ function qualifyColumnDescriptor(
262
+ descriptor: ColumnTypeDescriptor,
263
+ namespaceId: string,
264
+ qualify: ColumnTypeQualifier | undefined,
265
+ ): ColumnTypeDescriptor {
266
+ if (qualify === undefined) return descriptor;
267
+ const qualified = qualify(
268
+ {
269
+ codecId: descriptor.codecId,
270
+ nativeType: descriptor.nativeType,
271
+ ...ifDefined('typeParams', descriptor.typeParams),
272
+ },
273
+ namespaceId,
274
+ );
275
+ if (
276
+ qualified.nativeType === descriptor.nativeType &&
277
+ qualified.typeParams === descriptor.typeParams
278
+ ) {
279
+ return descriptor;
280
+ }
281
+ return {
282
+ ...descriptor,
283
+ nativeType: qualified.nativeType,
284
+ ...ifDefined('typeParams', qualified.typeParams),
285
+ };
286
+ }
287
+
288
+ type CollectedColumnEntities = Record<string, Record<string, Record<string, unknown>>>;
289
+
290
+ /**
291
+ * Records a deferred column's entity-ref into the namespace-scoped collection
292
+ * accumulator (`namespaceId → entityKind → entityName`) — folded into the same
293
+ * namespace assembly `deriveEntityValueSets`/`entries.<kind>` step as the
294
+ * entities-channel attachments, so a column-collected entity gets its
295
+ * value-set the same way an entities-channel one does.
296
+ *
297
+ * The same handle reused by many columns in one namespace is normal (a native
298
+ * enum type backs any number of columns) and records the identical entity once.
299
+ * Two *different* entity instances sharing a name+kind in one namespace is a
300
+ * name collision — the emitted `entries.valueSet.<name>` could only reflect one
301
+ * of them, silently mismatching the other column's type/cast. PSL hard-errors
302
+ * on the equivalent (`PSL_DUPLICATE_DECLARATION`); the TS path rejects it too.
303
+ */
304
+ function collectEntityFromColumn(
305
+ collected: CollectedColumnEntities,
306
+ namespaceId: string,
307
+ entityRef: NonNullable<ColumnTypeDescriptor['entityRef']>,
308
+ ): void {
309
+ const forNs = collected[namespaceId] ?? {};
310
+ const forKind = forNs[entityRef.entityKind] ?? {};
311
+ const existing = forKind[entityRef.entityName];
312
+ if (existing !== undefined && existing !== entityRef.entity) {
313
+ throw new Error(
314
+ `buildSqlContractFromDefinition: two different "${entityRef.entityKind}" entities named "${entityRef.entityName}" in namespace "${namespaceId}" — pack-entity names must be unique per namespace.`,
315
+ );
316
+ }
317
+ forKind[entityRef.entityName] = entityRef.entity;
318
+ forNs[entityRef.entityKind] = forKind;
319
+ collected[namespaceId] = forNs;
320
+ }
321
+
322
+ /**
323
+ * Merges a namespace's entities-channel attachments (lowered from the
324
+ * `entities` handle list, carried on `ContractDefinition.attachedEntities`)
325
+ * with the entities collected from that namespace's deferred entity-ref
326
+ * columns. A column-collected entity that shadows a *different* attached
327
+ * entity of the same kind+name (or vice-versa) is the same name-collision bug
328
+ * `collectEntityFromColumn` guards against across columns, so it is rejected
329
+ * the same way — by entity identity, so the same handle attached and used by
330
+ * a column does not throw.
331
+ */
332
+ function mergeColumnAndAttachedEntities(
333
+ namespaceId: string,
334
+ attached: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
335
+ columnCollected: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
336
+ ): Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined {
337
+ if (attached === undefined) return columnCollected;
338
+ if (columnCollected === undefined) return attached;
339
+ const kinds = new Set([...Object.keys(attached), ...Object.keys(columnCollected)]);
340
+ const result: Record<string, Readonly<Record<string, unknown>>> = {};
341
+ for (const kind of kinds) {
342
+ const attachedForKind = attached[kind];
343
+ const columnForKind = columnCollected[kind];
344
+ for (const [name, entity] of Object.entries(columnForKind ?? {})) {
345
+ const existing = attachedForKind?.[name];
346
+ if (existing !== undefined && existing !== entity) {
347
+ throw new Error(
348
+ `buildSqlContractFromDefinition: two different "${kind}" entities named "${name}" in namespace "${namespaceId}" — a column-referenced entity conflicts with an attached one; pack-entity names must be unique per namespace.`,
349
+ );
350
+ }
351
+ }
352
+ result[kind] = { ...attachedForKind, ...columnForKind };
353
+ }
354
+ return result;
355
+ }
356
+
163
357
  const JSONB_CODEC_ID = 'pg/jsonb@1';
164
358
  const JSONB_NATIVE_TYPE = 'jsonb';
165
359
 
@@ -235,28 +429,28 @@ function buildStorageColumn(
235
429
  };
236
430
  }
237
431
 
238
- if (field.many) {
239
- return {
240
- nativeType: JSONB_NATIVE_TYPE,
241
- codecId: JSONB_CODEC_ID,
242
- nullable: field.nullable,
243
- };
244
- }
245
-
246
432
  const codecId = field.descriptor.codecId;
247
433
  const encodedDefault =
248
434
  field.default !== undefined
249
- ? encodeColumnDefault(field.default, codecId, codecLookup)
435
+ ? encodeColumnDefault(field.default, codecId, codecLookup, field.many === true)
250
436
  : undefined;
251
437
 
438
+ // `storageValueSetRef` (derived from an `enumTypeHandle`) takes precedence
439
+ // when present — the established domain-enum path. `field.descriptor.valueSet`
440
+ // is the fallback: set by an entity-ref type constructor (e.g. `pg.enum(Ref)`)
441
+ // that resolved the field's type against a value-set-deriving entity with no
442
+ // domain enum involved. A field carries at most one of the two in practice.
443
+ const valueSet = storageValueSetRef ?? field.descriptor.valueSet;
444
+
252
445
  return {
253
446
  nativeType: field.descriptor.nativeType,
254
447
  codecId,
255
448
  nullable: field.nullable,
449
+ ...(field.many ? { many: true as const } : {}),
256
450
  ...ifDefined('typeParams', field.descriptor.typeParams),
257
451
  ...ifDefined('default', encodedDefault),
258
452
  ...ifDefined('typeRef', field.descriptor.typeRef),
259
- ...ifDefined('valueSet', storageValueSetRef),
453
+ ...ifDefined('valueSet', valueSet),
260
454
  };
261
455
  }
262
456
 
@@ -298,9 +492,122 @@ function collectStorageNamespaceCoordinateIds(definition: ContractDefinition): S
298
492
  ids.add(model.namespaceId);
299
493
  }
300
494
  }
495
+ for (const id of Object.keys(definition.attachedEntities ?? {})) {
496
+ if (id.length > 0) {
497
+ ids.add(id);
498
+ }
499
+ }
301
500
  return ids;
302
501
  }
303
502
 
503
+ /**
504
+ * Entry kinds the framework assembler itself manages (`table` from models,
505
+ * `valueSet` from `enums` and attached-entity value-set derivation). A
506
+ * pack-attached entity claiming one of these would silently clobber or be
507
+ * clobbered by the managed slot, so it is rejected outright.
508
+ */
509
+ const MANAGED_ENTRY_KINDS = new Set([tableEntityKind.kind, valueSetEntityKind.kind]);
510
+
511
+ function assertNoManagedEntityKinds(
512
+ namespaceId: string,
513
+ entitiesForNs: Readonly<Record<string, unknown>> | undefined,
514
+ ): void {
515
+ if (entitiesForNs === undefined) return;
516
+ for (const kind of Object.keys(entitiesForNs)) {
517
+ if (MANAGED_ENTRY_KINDS.has(kind)) {
518
+ throw new Error(
519
+ `buildSqlContractFromDefinition: attached entity in namespace "${namespaceId}" declares entry kind "${kind}", which is managed by the framework (table/valueSet) and cannot be attached.`,
520
+ );
521
+ }
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Walks the flat `entityTypes` namespace tree contributed by the target pack
527
+ * and every extension pack, indexing descriptors by their `discriminator` —
528
+ * the same string a pack entity's entries-map key (`entries.<kind>`) uses.
529
+ * Mirrors `contract-psl`'s `buildEntityTypesByDiscriminator`, recomposed here
530
+ * from the packs `ContractDefinition` already carries (`target` +
531
+ * `extensionPacks`) since the TS assembler has no single pre-merged
532
+ * `AuthoringContributions` input to read the way the PSL interpreter does.
533
+ */
534
+ function collectEntityTypeDescriptorsByDiscriminator(
535
+ definition: ContractDefinition,
536
+ ): ReadonlyMap<string, AuthoringEntityTypeDescriptor> {
537
+ const result = new Map<string, AuthoringEntityTypeDescriptor>();
538
+ const walk = (namespace: AuthoringEntityTypeNamespace): void => {
539
+ for (const value of Object.values(namespace)) {
540
+ if (isAuthoringEntityTypeDescriptor(value)) {
541
+ result.set(value.discriminator, value);
542
+ } else {
543
+ walk(value);
544
+ }
545
+ }
546
+ };
547
+ const components = [definition.target, ...Object.values(definition.extensionPacks ?? {})];
548
+ for (const component of components) {
549
+ const entityTypes = component.authoring?.entityTypes;
550
+ if (entityTypes !== undefined) {
551
+ walk(entityTypes);
552
+ }
553
+ }
554
+ return result;
555
+ }
556
+
557
+ /**
558
+ * Derives value-sets for every pack entity declared in one namespace,
559
+ * reusing the same `SqlValueSetDerivingEntityTypeOutput.deriveValueSet` hook
560
+ * `contract-psl`'s `lowerExtensionBlocksForNamespace` folds into
561
+ * `entries.valueSet` on the PSL path — so a TS-attached entity (e.g. a
562
+ * native enum) gets its value-set the same way. Entity kinds with no
563
+ * registered descriptor, or whose descriptor output doesn't derive a
564
+ * value-set, contribute nothing.
565
+ */
566
+ function deriveEntityValueSets(
567
+ entitiesForNs: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
568
+ entityTypesByDiscriminator: ReadonlyMap<string, AuthoringEntityTypeDescriptor>,
569
+ ): Record<string, StorageValueSetInput> | undefined {
570
+ if (entitiesForNs === undefined) return undefined;
571
+ let result: Record<string, StorageValueSetInput> | undefined;
572
+ for (const [kind, entitiesByName] of Object.entries(entitiesForNs)) {
573
+ const descriptor = entityTypesByDiscriminator.get(kind);
574
+ if (descriptor === undefined) continue;
575
+ for (const [name, entity] of Object.entries(entitiesByName)) {
576
+ const derivedValueSet = deriveValueSetFromEntity(descriptor.output, entity);
577
+ if (derivedValueSet === undefined) continue;
578
+ result ??= {};
579
+ result[name] = derivedValueSet;
580
+ }
581
+ }
582
+ return result;
583
+ }
584
+
585
+ /**
586
+ * Merges a namespace's `enumType()`-derived value-sets with its pack-entity-
587
+ * derived value-sets. Both land in the same `entries.valueSet[name]` slot —
588
+ * which drives value-set → codec typing and the domain-enum CHECK — so a
589
+ * same-named entry in both would let one silently overwrite the other and
590
+ * corrupt whichever column resolves against it. The same collision class the
591
+ * `mergeColumnAndAttachedEntities` guard rejects; the PSL path already hard-errors
592
+ * on the equivalent (`interpretPslDocumentToSqlContract`). Reject it here too.
593
+ */
594
+ function mergeNamespaceValueSets(
595
+ namespaceId: string,
596
+ enumValueSets: Record<string, StorageValueSetInput> | undefined,
597
+ packValueSets: Record<string, StorageValueSetInput> | undefined,
598
+ ): Record<string, StorageValueSetInput> {
599
+ if (enumValueSets !== undefined && packValueSets !== undefined) {
600
+ for (const name of Object.keys(packValueSets)) {
601
+ if (Object.hasOwn(enumValueSets, name)) {
602
+ throw new Error(
603
+ `buildSqlContractFromDefinition: value-set "${name}" in namespace "${namespaceId}" is derived from both an enum and a pack entity — names must be unique per namespace.`,
604
+ );
605
+ }
606
+ }
607
+ }
608
+ return { ...enumValueSets, ...packValueSets };
609
+ }
610
+
304
611
  function ensureUnboundNamespaceSlot(
305
612
  namespaces: SqlStorageInput['namespaces'],
306
613
  createNamespace: ContractDefinition['createNamespace'],
@@ -308,18 +615,15 @@ function ensureUnboundNamespaceSlot(
308
615
  if (Object.hasOwn(namespaces, UNBOUND_NAMESPACE_ID)) {
309
616
  return namespaces;
310
617
  }
311
- const unboundInput: SqlNamespaceTablesInput = {
618
+ const unboundInput: SqlNamespaceInput = {
312
619
  id: UNBOUND_NAMESPACE_ID,
313
620
  entries: { table: {} },
314
621
  };
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
- >({
622
+ const unbound = createNamespace(unboundInput);
623
+ return {
320
624
  [UNBOUND_NAMESPACE_ID]: unbound,
321
625
  ...namespaces,
322
- });
626
+ };
323
627
  }
324
628
 
325
629
  export function buildSqlContractFromDefinition(
@@ -328,6 +632,7 @@ export function buildSqlContractFromDefinition(
328
632
  ): Contract<SqlStorage> {
329
633
  const target = definition.target.targetId;
330
634
  const defaultNamespaceId = definition.target.defaultNamespaceId;
635
+ const qualifyColumnType = resolveColumnTypeQualifier(definition.target);
331
636
  const targetFamily = 'sql';
332
637
  const resolveNamespaceId = (m: ModelNode): string =>
333
638
  m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId;
@@ -346,6 +651,7 @@ export function buildSqlContractFromDefinition(
346
651
  const modelNameToNamespaceId = new Map<string, string>();
347
652
  const executionDefaults: ExecutionMutationDefault[] = [];
348
653
  const modelsByNamespace: Record<string, Record<string, ContractModel>> = {};
654
+ const collectedColumnEntities: CollectedColumnEntities = {};
349
655
  const rootEntries: Array<{
350
656
  readonly tableName: string;
351
657
  readonly namespaceId: string;
@@ -375,6 +681,7 @@ export function buildSqlContractFromDefinition(
375
681
  const fieldToColumn: Record<string, string> = {};
376
682
  const domainFields: Record<string, ContractField> = {};
377
683
  const domainFieldRefs: Record<string, DomainFieldRef> = {};
684
+ const checksForTable: CheckConstraintInput[] = [];
378
685
 
379
686
  for (const field of semanticModel.fields) {
380
687
  const executionDefaultPhases =
@@ -417,10 +724,58 @@ export function buildSqlContractFromDefinition(
417
724
  }
418
725
  : undefined;
419
726
 
420
- const column = buildStorageColumn(field, storageValueSetRef, codecLookup);
727
+ // A field authored through a deferred entity-ref column helper (e.g.
728
+ // `pg.enum(handle)`) carries `descriptor.entityRef`: the referenced
729
+ // entity is collected into `collectedColumnEntities` (folded into the
730
+ // same `entries.<kind>` + `entries.valueSet` assembly an entities-channel
731
+ // attachment goes through) and the descriptor is resolved
732
+ // against this field's now-known `namespaceId` — the builder call that
733
+ // produced it ran before the enclosing model associated one. The
734
+ // descriptor is then handed to the target's `qualifyColumnType` hook,
735
+ // which schema-qualifies a native-enum column's type name for its
736
+ // namespace. Keying off the codec id (inside the hook) catches both the
737
+ // TS `pg.enum(handle)` path (via `entityRef`) and the PSL `pg.enum(Ref)`
738
+ // path (resolved inline in the interpreter, no `entityRef`). Because the
739
+ // storage column is built from this qualified descriptor and the domain
740
+ // field derives its `type.typeParams` from that column, both come out
741
+ // qualified in this single pass.
742
+ let resolvedField: FieldNode | ValueObjectFieldNode = field;
743
+ if (!isValueObjectField(field)) {
744
+ let descriptor = field.descriptor;
745
+ const entityRef = descriptor.entityRef;
746
+ if (entityRef !== undefined) {
747
+ collectEntityFromColumn(collectedColumnEntities, namespaceId, entityRef);
748
+ descriptor = resolveEntityRefDescriptor(descriptor, namespaceId);
749
+ }
750
+ descriptor = qualifyColumnDescriptor(descriptor, namespaceId, qualifyColumnType);
751
+ if (descriptor !== field.descriptor) {
752
+ resolvedField = { ...field, descriptor };
753
+ }
754
+ }
755
+
756
+ const column = buildStorageColumn(resolvedField, storageValueSetRef, codecLookup);
421
757
  columns[field.columnName] = column;
422
758
  fieldToColumn[field.fieldName] = field.columnName;
423
759
 
760
+ // A domain enum (`storageValueSetRef`, from an `enumType()` handle) is
761
+ // stored as a plain scalar column (`text`, `int4`, …) with no native
762
+ // type of its own to enforce membership, so it needs an explicit
763
+ // CHECK — scalar or array, since a `text[]` array has no element-level
764
+ // enforcement either. A value set resolved by an entity-ref type
765
+ // constructor (`field.descriptor.valueSet`, e.g. `pg.enum(Ref)`) binds
766
+ // the column to a codec/native-type pairing that IS the storage-level
767
+ // enforcement (a Postgres native enum type, or another target's
768
+ // equivalent) — including array columns, since the target enforces
769
+ // membership on every element of a native-typed array — so no CHECK
770
+ // for those.
771
+ if (column.valueSet !== undefined && storageValueSetRef !== undefined) {
772
+ checksForTable.push({
773
+ name: `${tableName}_${field.columnName}_check`,
774
+ column: field.columnName,
775
+ valueSet: column.valueSet,
776
+ });
777
+ }
778
+
424
779
  domainFields[field.fieldName] = buildDomainField(field, column, domainValueSetRef);
425
780
 
426
781
  if (isValueObjectField(field)) {
@@ -442,7 +797,9 @@ export function buildSqlContractFromDefinition(
442
797
  }
443
798
  }
444
799
 
445
- const foreignKeys = (semanticModel.foreignKeys ?? []).map((fk) => {
800
+ const authoringForeignKeys: readonly ForeignKeyAuthoringInput[] = (
801
+ semanticModel.foreignKeys ?? []
802
+ ).map((fk) => {
446
803
  if (fk.references.spaceId !== undefined) {
447
804
  // Cross-space FK: the target lives in a different contract space.
448
805
  // Skip local model lookup and carry the spaceId coordinate through.
@@ -511,37 +868,43 @@ export function buildSqlContractFromDefinition(
511
868
  // materialised onto the base `ModelNode`, so the variant builds a domain
512
869
  // model (below) but no storage table of its own.
513
870
  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
- },
871
+ const uniques = (semanticModel.uniques ?? []).map((u) => ({
872
+ columns: u.columns,
873
+ ...ifDefined('name', u.name),
874
+ }));
875
+ const declaredIndexes = (semanticModel.indexes ?? []).map((i) => ({
876
+ columns: i.columns,
877
+ ...ifDefined('name', i.name),
878
+ ...ifDefined('type', i.type),
879
+ ...ifDefined('options', i.options),
880
+ }));
881
+ const primaryKey = semanticModel.id
882
+ ? { columns: semanticModel.id.columns, ...ifDefined('name', semanticModel.id.name) }
883
+ : undefined;
884
+ // FK1: lower each FK's `constraint`/`index` authoring intent into
885
+ // discrete persisted entities here — the one place a table's full
886
+ // constraint context (its own declared indexes/uniques/primary key)
887
+ // is available. A `constraint: false` FK contributes no
888
+ // `foreignKeys[]` entry; an `index: true` FK not already backed by a
889
+ // declared index/unique/primary-key contributes a named `indexes[]`
890
+ // entry. This authoring pipeline is shared by both the TS DSL and the
891
+ // PSL interpreter (which calls `buildSqlContractFromDefinition`
892
+ // directly), so both authoring surfaces materialize identically.
893
+ const { foreignKeys, indexes } = materializeForeignKeysAndIndexes(
894
+ tableName,
895
+ authoringForeignKeys,
896
+ declaredIndexes,
897
+ uniques,
898
+ primaryKey,
521
899
  );
522
900
 
523
901
  const tableInput: StorageTableInput = {
524
902
  columns,
525
903
  ...ifDefined('control', semanticModel.control),
526
- uniques: (semanticModel.uniques ?? []).map((u) => ({
527
- columns: u.columns,
528
- ...ifDefined('name', u.name),
529
- })),
530
- indexes: (semanticModel.indexes ?? []).map((i) => ({
531
- columns: i.columns,
532
- ...ifDefined('name', i.name),
533
- ...ifDefined('type', i.type),
534
- ...ifDefined('options', i.options),
535
- })),
904
+ uniques,
905
+ indexes,
536
906
  foreignKeys,
537
- ...(semanticModel.id
538
- ? {
539
- primaryKey: {
540
- columns: semanticModel.id.columns,
541
- ...ifDefined('name', semanticModel.id.name),
542
- },
543
- }
544
- : {}),
907
+ ...(primaryKey ? { primaryKey } : {}),
545
908
  ...(checksForTable.length > 0 ? { checks: checksForTable } : {}),
546
909
  };
547
910
 
@@ -729,25 +1092,35 @@ export function buildSqlContractFromDefinition(
729
1092
  }
730
1093
 
731
1094
  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
- ),
1095
+ const entityTypesByDiscriminator = collectEntityTypeDescriptorsByDiscriminator(definition);
1096
+ const namespaces: SqlStorageInput['namespaces'] = Object.fromEntries(
1097
+ [...namespaceCoordinateIds].sort().map((id) => {
1098
+ const entitiesForNs = mergeColumnAndAttachedEntities(
1099
+ id,
1100
+ definition.attachedEntities?.[id],
1101
+ collectedColumnEntities[id],
1102
+ );
1103
+ assertNoManagedEntityKinds(id, entitiesForNs);
1104
+
1105
+ const enumValueSetEntries = storageValueSetsByNs[id];
1106
+ const packValueSetEntries = deriveEntityValueSets(entitiesForNs, entityTypesByDiscriminator);
1107
+ const valueSetEntries =
1108
+ enumValueSetEntries !== undefined || packValueSetEntries !== undefined
1109
+ ? mergeNamespaceValueSets(id, enumValueSetEntries, packValueSetEntries)
1110
+ : undefined;
1111
+
1112
+ const nsInput: SqlNamespaceInput = {
1113
+ id,
1114
+ entries: {
1115
+ table: tablesByNamespace[id] ?? {},
1116
+ ...entitiesForNs,
1117
+ ...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
1118
+ ? { valueSet: valueSetEntries }
1119
+ : {}),
1120
+ },
1121
+ };
1122
+ return [id, createNamespace(nsInput)];
1123
+ }),
751
1124
  );
752
1125
  const storageWithoutHash = {
753
1126
  ...(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;