@prisma-next/sql-contract-ts 0.14.0-dev.9 → 0.14.0-dev.90

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,30 +1,30 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract-ts",
3
- "version": "0.14.0-dev.9",
3
+ "version": "0.14.0-dev.90",
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.9",
10
- "@prisma-next/contract": "0.14.0-dev.9",
11
- "@prisma-next/contract-authoring": "0.14.0-dev.9",
12
- "@prisma-next/framework-components": "0.14.0-dev.9",
13
- "@prisma-next/sql-contract": "0.14.0-dev.9",
14
- "@prisma-next/utils": "0.14.0-dev.9",
15
- "arktype": "^2.2.0",
9
+ "@prisma-next/config": "0.14.0-dev.90",
10
+ "@prisma-next/contract": "0.14.0-dev.90",
11
+ "@prisma-next/contract-authoring": "0.14.0-dev.90",
12
+ "@prisma-next/framework-components": "0.14.0-dev.90",
13
+ "@prisma-next/sql-contract": "0.14.0-dev.90",
14
+ "@prisma-next/utils": "0.14.0-dev.90",
15
+ "arktype": "^2.2.2",
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.9",
21
- "@prisma-next/tsconfig": "0.14.0-dev.9",
20
+ "@prisma-next/test-utils": "0.14.0-dev.90",
21
+ "@prisma-next/tsconfig": "0.14.0-dev.90",
22
22
  "@types/pg": "8.20.0",
23
- "pg": "8.21.0",
24
- "@prisma-next/tsdown": "0.14.0-dev.9",
25
- "tsdown": "0.22.1",
23
+ "pg": "8.22.0",
24
+ "@prisma-next/tsdown": "0.14.0-dev.90",
25
+ "tsdown": "0.22.3",
26
26
  "typescript": "5.9.3",
27
- "vitest": "4.1.8"
27
+ "vitest": "4.1.10"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "typescript": ">=5.9"
@@ -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;
@@ -2,6 +2,7 @@ import type {
2
2
  AuthoringArgumentDescriptor,
3
3
  AuthoringFieldPresetDescriptor,
4
4
  } from '@prisma-next/framework-components/authoring';
5
+ import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec';
5
6
  import type { ScalarFieldBuilder, ScalarFieldState } from './contract-dsl';
6
7
 
7
8
  export type UnionToIntersection<U> = (U extends unknown ? (value: U) => void : never) extends (
@@ -114,9 +115,11 @@ export type FieldBuilderFromPresetDescriptor<
114
115
  ConstraintName extends string | undefined = undefined,
115
116
  > = ScalarFieldBuilder<
116
117
  ScalarFieldState<
117
- ResolveTemplateValue<Descriptor['output']['codecId'], Args> extends string
118
- ? ResolveTemplateValue<Descriptor['output']['codecId'], Args>
119
- : string,
118
+ ColumnTypeDescriptor<
119
+ ResolveTemplateValue<Descriptor['output']['codecId'], Args> extends string
120
+ ? ResolveTemplateValue<Descriptor['output']['codecId'], Args>
121
+ : string
122
+ >,
120
123
  undefined,
121
124
  ResolveTemplateValue<Descriptor['output']['nullable'], Args> extends true ? true : false,
122
125
  undefined,
@@ -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,176 @@ 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 CollectedColumnEntities = Record<string, Record<string, Record<string, unknown>>>;
285
+
286
+ /**
287
+ * Records a deferred column's entity-ref into the namespace-scoped collection
288
+ * accumulator (`namespaceId → entityKind → entityName`) — folded into the same
289
+ * namespace assembly `deriveEntityValueSets`/`entries.<kind>` step as the
290
+ * entities-channel attachments, so a column-collected entity gets its
291
+ * value-set the same way an entities-channel 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 collectEntityFromColumn(
301
+ collected: CollectedColumnEntities,
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 a namespace's entities-channel attachments (lowered from the
320
+ * `entities` handle list, carried on `ContractDefinition.attachedEntities`)
321
+ * with the entities collected from that namespace's deferred entity-ref
322
+ * columns. A column-collected entity that shadows a *different* attached
323
+ * entity of the same kind+name (or vice-versa) is the same name-collision bug
324
+ * `collectEntityFromColumn` guards against across columns, so it is rejected
325
+ * the same way — by entity identity, so the same handle attached and used by
326
+ * a column does not throw.
327
+ */
328
+ function mergeColumnAndAttachedEntities(
329
+ namespaceId: string,
330
+ attached: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
331
+ columnCollected: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
332
+ ): Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined {
333
+ if (attached === undefined) return columnCollected;
334
+ if (columnCollected === undefined) return attached;
335
+ const kinds = new Set([...Object.keys(attached), ...Object.keys(columnCollected)]);
336
+ const result: Record<string, Readonly<Record<string, unknown>>> = {};
337
+ for (const kind of kinds) {
338
+ const attachedForKind = attached[kind];
339
+ const columnForKind = columnCollected[kind];
340
+ for (const [name, entity] of Object.entries(columnForKind ?? {})) {
341
+ const existing = attachedForKind?.[name];
342
+ if (existing !== undefined && existing !== entity) {
343
+ throw new Error(
344
+ `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.`,
345
+ );
346
+ }
347
+ }
348
+ result[kind] = { ...attachedForKind, ...columnForKind };
349
+ }
350
+ return result;
351
+ }
352
+
163
353
  const JSONB_CODEC_ID = 'pg/jsonb@1';
164
354
  const JSONB_NATIVE_TYPE = 'jsonb';
165
355
 
@@ -235,28 +425,28 @@ function buildStorageColumn(
235
425
  };
236
426
  }
237
427
 
238
- if (field.many) {
239
- return {
240
- nativeType: JSONB_NATIVE_TYPE,
241
- codecId: JSONB_CODEC_ID,
242
- nullable: field.nullable,
243
- };
244
- }
245
-
246
428
  const codecId = field.descriptor.codecId;
247
429
  const encodedDefault =
248
430
  field.default !== undefined
249
- ? encodeColumnDefault(field.default, codecId, codecLookup)
431
+ ? encodeColumnDefault(field.default, codecId, codecLookup, field.many === true)
250
432
  : undefined;
251
433
 
434
+ // `storageValueSetRef` (derived from an `enumTypeHandle`) takes precedence
435
+ // when present — the established domain-enum path. `field.descriptor.valueSet`
436
+ // is the fallback: set by an entity-ref type constructor (e.g. `pg.enum(Ref)`)
437
+ // that resolved the field's type against a value-set-deriving entity with no
438
+ // domain enum involved. A field carries at most one of the two in practice.
439
+ const valueSet = storageValueSetRef ?? field.descriptor.valueSet;
440
+
252
441
  return {
253
442
  nativeType: field.descriptor.nativeType,
254
443
  codecId,
255
444
  nullable: field.nullable,
445
+ ...(field.many ? { many: true as const } : {}),
256
446
  ...ifDefined('typeParams', field.descriptor.typeParams),
257
447
  ...ifDefined('default', encodedDefault),
258
448
  ...ifDefined('typeRef', field.descriptor.typeRef),
259
- ...ifDefined('valueSet', storageValueSetRef),
449
+ ...ifDefined('valueSet', valueSet),
260
450
  };
261
451
  }
262
452
 
@@ -298,9 +488,122 @@ function collectStorageNamespaceCoordinateIds(definition: ContractDefinition): S
298
488
  ids.add(model.namespaceId);
299
489
  }
300
490
  }
491
+ for (const id of Object.keys(definition.attachedEntities ?? {})) {
492
+ if (id.length > 0) {
493
+ ids.add(id);
494
+ }
495
+ }
301
496
  return ids;
302
497
  }
303
498
 
499
+ /**
500
+ * Entry kinds the framework assembler itself manages (`table` from models,
501
+ * `valueSet` from `enums` and attached-entity value-set derivation). A
502
+ * pack-attached entity claiming one of these would silently clobber or be
503
+ * clobbered by the managed slot, so it is rejected outright.
504
+ */
505
+ const MANAGED_ENTRY_KINDS = new Set([tableEntityKind.kind, valueSetEntityKind.kind]);
506
+
507
+ function assertNoManagedEntityKinds(
508
+ namespaceId: string,
509
+ entitiesForNs: Readonly<Record<string, unknown>> | undefined,
510
+ ): void {
511
+ if (entitiesForNs === undefined) return;
512
+ for (const kind of Object.keys(entitiesForNs)) {
513
+ if (MANAGED_ENTRY_KINDS.has(kind)) {
514
+ throw new Error(
515
+ `buildSqlContractFromDefinition: attached entity in namespace "${namespaceId}" declares entry kind "${kind}", which is managed by the framework (table/valueSet) and cannot be attached.`,
516
+ );
517
+ }
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Walks the flat `entityTypes` namespace tree contributed by the target pack
523
+ * and every extension pack, indexing descriptors by their `discriminator` —
524
+ * the same string a pack entity's entries-map key (`entries.<kind>`) uses.
525
+ * Mirrors `contract-psl`'s `buildEntityTypesByDiscriminator`, recomposed here
526
+ * from the packs `ContractDefinition` already carries (`target` +
527
+ * `extensionPacks`) since the TS assembler has no single pre-merged
528
+ * `AuthoringContributions` input to read the way the PSL interpreter does.
529
+ */
530
+ function collectEntityTypeDescriptorsByDiscriminator(
531
+ definition: ContractDefinition,
532
+ ): ReadonlyMap<string, AuthoringEntityTypeDescriptor> {
533
+ const result = new Map<string, AuthoringEntityTypeDescriptor>();
534
+ const walk = (namespace: AuthoringEntityTypeNamespace): void => {
535
+ for (const value of Object.values(namespace)) {
536
+ if (isAuthoringEntityTypeDescriptor(value)) {
537
+ result.set(value.discriminator, value);
538
+ } else {
539
+ walk(value);
540
+ }
541
+ }
542
+ };
543
+ const components = [definition.target, ...Object.values(definition.extensionPacks ?? {})];
544
+ for (const component of components) {
545
+ const entityTypes = component.authoring?.entityTypes;
546
+ if (entityTypes !== undefined) {
547
+ walk(entityTypes);
548
+ }
549
+ }
550
+ return result;
551
+ }
552
+
553
+ /**
554
+ * Derives value-sets for every pack entity declared in one namespace,
555
+ * reusing the same `SqlValueSetDerivingEntityTypeOutput.deriveValueSet` hook
556
+ * `contract-psl`'s `lowerExtensionBlocksForNamespace` folds into
557
+ * `entries.valueSet` on the PSL path — so a TS-attached entity (e.g. a
558
+ * native enum) gets its value-set the same way. Entity kinds with no
559
+ * registered descriptor, or whose descriptor output doesn't derive a
560
+ * value-set, contribute nothing.
561
+ */
562
+ function deriveEntityValueSets(
563
+ entitiesForNs: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
564
+ entityTypesByDiscriminator: ReadonlyMap<string, AuthoringEntityTypeDescriptor>,
565
+ ): Record<string, StorageValueSetInput> | undefined {
566
+ if (entitiesForNs === undefined) return undefined;
567
+ let result: Record<string, StorageValueSetInput> | undefined;
568
+ for (const [kind, entitiesByName] of Object.entries(entitiesForNs)) {
569
+ const descriptor = entityTypesByDiscriminator.get(kind);
570
+ if (descriptor === undefined) continue;
571
+ for (const [name, entity] of Object.entries(entitiesByName)) {
572
+ const derivedValueSet = deriveValueSetFromEntity(descriptor.output, entity);
573
+ if (derivedValueSet === undefined) continue;
574
+ result ??= {};
575
+ result[name] = derivedValueSet;
576
+ }
577
+ }
578
+ return result;
579
+ }
580
+
581
+ /**
582
+ * Merges a namespace's `enumType()`-derived value-sets with its pack-entity-
583
+ * derived value-sets. Both land in the same `entries.valueSet[name]` slot —
584
+ * which drives value-set → codec typing and the domain-enum CHECK — so a
585
+ * same-named entry in both would let one silently overwrite the other and
586
+ * corrupt whichever column resolves against it. The same collision class the
587
+ * `mergeColumnAndAttachedEntities` guard rejects; the PSL path already hard-errors
588
+ * on the equivalent (`interpretPslDocumentToSqlContract`). Reject it here too.
589
+ */
590
+ function mergeNamespaceValueSets(
591
+ namespaceId: string,
592
+ enumValueSets: Record<string, StorageValueSetInput> | undefined,
593
+ packValueSets: Record<string, StorageValueSetInput> | undefined,
594
+ ): Record<string, StorageValueSetInput> {
595
+ if (enumValueSets !== undefined && packValueSets !== undefined) {
596
+ for (const name of Object.keys(packValueSets)) {
597
+ if (Object.hasOwn(enumValueSets, name)) {
598
+ throw new Error(
599
+ `buildSqlContractFromDefinition: value-set "${name}" in namespace "${namespaceId}" is derived from both an enum and a pack entity — names must be unique per namespace.`,
600
+ );
601
+ }
602
+ }
603
+ }
604
+ return { ...enumValueSets, ...packValueSets };
605
+ }
606
+
304
607
  function ensureUnboundNamespaceSlot(
305
608
  namespaces: SqlStorageInput['namespaces'],
306
609
  createNamespace: ContractDefinition['createNamespace'],
@@ -308,18 +611,15 @@ function ensureUnboundNamespaceSlot(
308
611
  if (Object.hasOwn(namespaces, UNBOUND_NAMESPACE_ID)) {
309
612
  return namespaces;
310
613
  }
311
- const unboundInput: SqlNamespaceTablesInput = {
614
+ const unboundInput: SqlNamespaceInput = {
312
615
  id: UNBOUND_NAMESPACE_ID,
313
616
  entries: { table: {} },
314
617
  };
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
- >({
618
+ const unbound = createNamespace(unboundInput);
619
+ return {
320
620
  [UNBOUND_NAMESPACE_ID]: unbound,
321
621
  ...namespaces,
322
- });
622
+ };
323
623
  }
324
624
 
325
625
  export function buildSqlContractFromDefinition(
@@ -328,6 +628,7 @@ export function buildSqlContractFromDefinition(
328
628
  ): Contract<SqlStorage> {
329
629
  const target = definition.target.targetId;
330
630
  const defaultNamespaceId = definition.target.defaultNamespaceId;
631
+ const qualifyColumnType = resolveColumnTypeQualifier(definition.target);
331
632
  const targetFamily = 'sql';
332
633
  const resolveNamespaceId = (m: ModelNode): string =>
333
634
  m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId;
@@ -346,6 +647,7 @@ export function buildSqlContractFromDefinition(
346
647
  const modelNameToNamespaceId = new Map<string, string>();
347
648
  const executionDefaults: ExecutionMutationDefault[] = [];
348
649
  const modelsByNamespace: Record<string, Record<string, ContractModel>> = {};
650
+ const collectedColumnEntities: CollectedColumnEntities = {};
349
651
  const rootEntries: Array<{
350
652
  readonly tableName: string;
351
653
  readonly namespaceId: string;
@@ -375,6 +677,7 @@ export function buildSqlContractFromDefinition(
375
677
  const fieldToColumn: Record<string, string> = {};
376
678
  const domainFields: Record<string, ContractField> = {};
377
679
  const domainFieldRefs: Record<string, DomainFieldRef> = {};
680
+ const checksForTable: CheckConstraintInput[] = [];
378
681
 
379
682
  for (const field of semanticModel.fields) {
380
683
  const executionDefaultPhases =
@@ -417,10 +720,58 @@ export function buildSqlContractFromDefinition(
417
720
  }
418
721
  : undefined;
419
722
 
420
- const column = buildStorageColumn(field, storageValueSetRef, codecLookup);
723
+ // A field authored through a deferred entity-ref column helper (e.g.
724
+ // `pg.enum(handle)`) carries `descriptor.entityRef`: the referenced
725
+ // entity is collected into `collectedColumnEntities` (folded into the
726
+ // same `entries.<kind>` + `entries.valueSet` assembly an entities-channel
727
+ // attachment goes through) and the descriptor is resolved
728
+ // against this field's now-known `namespaceId` — the builder call that
729
+ // produced it ran before the enclosing model associated one. The
730
+ // descriptor is then handed to the target's `qualifyColumnType` hook,
731
+ // which schema-qualifies a native-enum column's type name for its
732
+ // namespace. Keying off the codec id (inside the hook) catches both the
733
+ // TS `pg.enum(handle)` path (via `entityRef`) and the PSL `pg.enum(Ref)`
734
+ // path (resolved inline in the interpreter, no `entityRef`). Because the
735
+ // storage column is built from this qualified descriptor and the domain
736
+ // field derives its `type.typeParams` from that column, both come out
737
+ // qualified in this single pass.
738
+ let resolvedField: FieldNode | ValueObjectFieldNode = field;
739
+ if (!isValueObjectField(field)) {
740
+ let descriptor = field.descriptor;
741
+ const entityRef = descriptor.entityRef;
742
+ if (entityRef !== undefined) {
743
+ collectEntityFromColumn(collectedColumnEntities, namespaceId, entityRef);
744
+ descriptor = resolveEntityRefDescriptor(descriptor, namespaceId);
745
+ }
746
+ descriptor = qualifyColumnDescriptor(descriptor, namespaceId, qualifyColumnType);
747
+ if (descriptor !== field.descriptor) {
748
+ resolvedField = { ...field, descriptor };
749
+ }
750
+ }
751
+
752
+ const column = buildStorageColumn(resolvedField, storageValueSetRef, codecLookup);
421
753
  columns[field.columnName] = column;
422
754
  fieldToColumn[field.fieldName] = field.columnName;
423
755
 
756
+ // A domain enum (`storageValueSetRef`, from an `enumType()` handle) is
757
+ // stored as a plain scalar column (`text`, `int4`, …) with no native
758
+ // type of its own to enforce membership, so it needs an explicit
759
+ // CHECK — scalar or array, since a `text[]` array has no element-level
760
+ // enforcement either. A value set resolved by an entity-ref type
761
+ // constructor (`field.descriptor.valueSet`, e.g. `pg.enum(Ref)`) binds
762
+ // the column to a codec/native-type pairing that IS the storage-level
763
+ // enforcement (a Postgres native enum type, or another target's
764
+ // equivalent) — including array columns, since the target enforces
765
+ // membership on every element of a native-typed array — so no CHECK
766
+ // for those.
767
+ if (column.valueSet !== undefined && storageValueSetRef !== undefined) {
768
+ checksForTable.push({
769
+ name: `${tableName}_${field.columnName}_check`,
770
+ column: field.columnName,
771
+ valueSet: column.valueSet,
772
+ });
773
+ }
774
+
424
775
  domainFields[field.fieldName] = buildDomainField(field, column, domainValueSetRef);
425
776
 
426
777
  if (isValueObjectField(field)) {
@@ -511,15 +862,6 @@ export function buildSqlContractFromDefinition(
511
862
  // materialised onto the base `ModelNode`, so the variant builds a domain
512
863
  // model (below) but no storage table of its own.
513
864
  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
865
  const tableInput: StorageTableInput = {
524
866
  columns,
525
867
  ...ifDefined('control', semanticModel.control),
@@ -729,25 +1071,35 @@ export function buildSqlContractFromDefinition(
729
1071
  }
730
1072
 
731
1073
  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
- ),
1074
+ const entityTypesByDiscriminator = collectEntityTypeDescriptorsByDiscriminator(definition);
1075
+ const namespaces: SqlStorageInput['namespaces'] = Object.fromEntries(
1076
+ [...namespaceCoordinateIds].sort().map((id) => {
1077
+ const entitiesForNs = mergeColumnAndAttachedEntities(
1078
+ id,
1079
+ definition.attachedEntities?.[id],
1080
+ collectedColumnEntities[id],
1081
+ );
1082
+ assertNoManagedEntityKinds(id, entitiesForNs);
1083
+
1084
+ const enumValueSetEntries = storageValueSetsByNs[id];
1085
+ const packValueSetEntries = deriveEntityValueSets(entitiesForNs, entityTypesByDiscriminator);
1086
+ const valueSetEntries =
1087
+ enumValueSetEntries !== undefined || packValueSetEntries !== undefined
1088
+ ? mergeNamespaceValueSets(id, enumValueSetEntries, packValueSetEntries)
1089
+ : undefined;
1090
+
1091
+ const nsInput: SqlNamespaceInput = {
1092
+ id,
1093
+ entries: {
1094
+ table: tablesByNamespace[id] ?? {},
1095
+ ...entitiesForNs,
1096
+ ...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
1097
+ ? { valueSet: valueSetEntries }
1098
+ : {}),
1099
+ },
1100
+ };
1101
+ return [id, createNamespace(nsInput)];
1102
+ }),
751
1103
  );
752
1104
  const storageWithoutHash = {
753
1105
  ...(Object.keys(documentTypes).length > 0 ? { types: documentTypes } : {}),