@prisma-next/sql-contract-ts 0.12.0 → 0.13.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec';
2
+ import type { ExtensionPackRef } from '@prisma-next/framework-components/components';
2
3
  import {
3
4
  isPostgresEnumStorageEntry,
4
5
  type PostgresEnumStorageEntry,
@@ -35,6 +36,7 @@ import {
35
36
  emitTypedCrossModelFallbackWarnings,
36
37
  emitTypedNamedTypeFallbackWarnings,
37
38
  } from './contract-warnings';
39
+ import { isEnumTypeHandle } from './enum-type';
38
40
 
39
41
  type RuntimeModel = ContractModelBuilder<
40
42
  string | undefined,
@@ -84,6 +86,13 @@ function resolveFieldDescriptor(
84
86
  }
85
87
 
86
88
  if ('typeRef' in fieldState && fieldState.typeRef) {
89
+ if (isEnumTypeHandle(fieldState.typeRef)) {
90
+ return {
91
+ codecId: fieldState.typeRef.codecId,
92
+ nativeType: fieldState.typeRef.nativeType,
93
+ };
94
+ }
95
+
87
96
  const typeRef =
88
97
  typeof fieldState.typeRef === 'string'
89
98
  ? fieldState.typeRef
@@ -253,6 +262,41 @@ function resolveRelationForeignKeys(
253
262
  }
254
263
 
255
264
  const targetModelName = resolveRelationModelName(relation.toModel);
265
+
266
+ // F-relfk: cross-space relations carry a spaceId; skip the local spec lookup
267
+ // and include cross-space coordinates so resolveForeignKeyNodes routes the FK
268
+ // through the cross-space path.
269
+ if (relation.spaceId !== undefined) {
270
+ const fields = normalizeRelationFieldNames(relation.from);
271
+ const targetFields = normalizeRelationFieldNames(relation.to);
272
+ assertRelationFieldArity({
273
+ modelName: spec.modelName,
274
+ relationName,
275
+ leftLabel: 'source',
276
+ leftFields: fields,
277
+ rightLabel: 'target',
278
+ rightFields: targetFields,
279
+ });
280
+
281
+ foreignKeys.push({
282
+ kind: 'fk',
283
+ fields,
284
+ targetModel: targetModelName,
285
+ targetFields,
286
+ targetSpaceId: relation.spaceId,
287
+ ...(relation.namespaceId !== undefined ? { targetNamespaceId: relation.namespaceId } : {}),
288
+ ...(relation.tableName !== undefined ? { targetTableName: relation.tableName } : {}),
289
+ ...(relation.sql.fk.name ? { name: relation.sql.fk.name } : {}),
290
+ ...(relation.sql.fk.onDelete ? { onDelete: relation.sql.fk.onDelete } : {}),
291
+ ...(relation.sql.fk.onUpdate ? { onUpdate: relation.sql.fk.onUpdate } : {}),
292
+ ...(relation.sql.fk.constraint !== undefined
293
+ ? { constraint: relation.sql.fk.constraint }
294
+ : {}),
295
+ ...(relation.sql.fk.index !== undefined ? { index: relation.sql.fk.index } : {}),
296
+ });
297
+ continue;
298
+ }
299
+
256
300
  if (!allSpecs.has(targetModelName)) {
257
301
  throw new Error(
258
302
  `Relation "${spec.modelName}.${relationName}" references unknown model "${targetModelName}"`,
@@ -308,17 +352,12 @@ function lowerBelongsToRelation(
308
352
  relation: Extract<RelationState, { kind: 'belongsTo' }>,
309
353
  currentSpec: RuntimeModelSpec,
310
354
  allSpecs: ReadonlyMap<string, RuntimeModelSpec>,
355
+ extensionPacks?: Record<string, ExtensionPackRef<'sql', string>>,
311
356
  ): RelationNode {
312
357
  const targetModelName = resolveRelationModelName(relation.toModel);
313
- const targetSpec = allSpecs.get(targetModelName);
314
- if (!targetSpec) {
315
- throw new Error(
316
- `Relation "${currentSpec.modelName}.${relationName}" references unknown model "${targetModelName}"`,
317
- );
318
- }
319
-
320
358
  const fromFields = normalizeRelationFieldNames(relation.from);
321
359
  const toFields = normalizeRelationFieldNames(relation.to);
360
+
322
361
  assertRelationFieldArity({
323
362
  modelName: currentSpec.modelName,
324
363
  relationName,
@@ -328,6 +367,48 @@ function lowerBelongsToRelation(
328
367
  rightFields: toFields,
329
368
  });
330
369
 
370
+ // Cross-space path: the target lives in a different contract space.
371
+ // Resolve from the brand carried on the BelongsToRelation instead of
372
+ // requiring a local model spec — matching how the FK lowering works.
373
+ if (relation.spaceId !== undefined) {
374
+ assertKnownExtensionPack(
375
+ extensionPacks,
376
+ relation.spaceId,
377
+ `Relation "${currentSpec.modelName}.${relationName}"`,
378
+ );
379
+ const targetTable = relation.tableName ?? targetModelName.toLowerCase();
380
+ const parentColumns = mapFieldNamesToColumnNames(
381
+ currentSpec.modelName,
382
+ fromFields,
383
+ currentSpec.fieldToColumn,
384
+ );
385
+ // For cross-space relations, the `to` field names map directly to column
386
+ // names because we have no fieldToColumn map for the remote model.
387
+ // (The brand carries the table name; field→column resolution on the remote
388
+ // side is deferred to the planner which has access to the remote contract.)
389
+ return {
390
+ fieldName: relationName,
391
+ toModel: targetModelName,
392
+ toTable: targetTable,
393
+ cardinality: 'N:1',
394
+ spaceId: relation.spaceId,
395
+ ...(relation.namespaceId !== undefined ? { namespaceId: relation.namespaceId } : {}),
396
+ on: {
397
+ parentTable: currentSpec.tableName,
398
+ parentColumns,
399
+ childTable: targetTable,
400
+ childColumns: toFields,
401
+ },
402
+ };
403
+ }
404
+
405
+ const targetSpec = allSpecs.get(targetModelName);
406
+ if (!targetSpec) {
407
+ throw new Error(
408
+ `Relation "${currentSpec.modelName}.${relationName}" references unknown model "${targetModelName}"`,
409
+ );
410
+ }
411
+
331
412
  return {
332
413
  fieldName: relationName,
333
414
  toModel: targetModelName,
@@ -472,9 +553,10 @@ function resolveRelationNode(
472
553
  relation: RelationState,
473
554
  currentSpec: RuntimeModelSpec,
474
555
  allSpecs: ReadonlyMap<string, RuntimeModelSpec>,
556
+ extensionPacks?: Record<string, ExtensionPackRef<'sql', string>>,
475
557
  ): RelationNode {
476
558
  if (relation.kind === 'belongsTo') {
477
- return lowerBelongsToRelation(relationName, relation, currentSpec, allSpecs);
559
+ return lowerBelongsToRelation(relationName, relation, currentSpec, allSpecs, extensionPacks);
478
560
  }
479
561
 
480
562
  if (relation.kind === 'hasMany' || relation.kind === 'hasOne') {
@@ -484,7 +566,7 @@ function resolveRelationNode(
484
566
  return lowerManyToManyRelation(relationName, relation, currentSpec, allSpecs);
485
567
  }
486
568
 
487
- function lowerForeignKeyNode(
569
+ function lowerLocalForeignKeyNode(
488
570
  spec: RuntimeModelSpec,
489
571
  targetSpec: RuntimeModelSpec,
490
572
  foreignKey: {
@@ -516,11 +598,74 @@ function lowerForeignKeyNode(
516
598
  };
517
599
  }
518
600
 
601
+ function lowerCrossSpaceForeignKeyNode(
602
+ spec: RuntimeModelSpec,
603
+ foreignKey: {
604
+ readonly fields: readonly string[];
605
+ readonly targetFields: readonly string[];
606
+ readonly targetModel: string;
607
+ readonly targetSpaceId: string;
608
+ readonly targetNamespaceId?: string;
609
+ readonly targetTableName?: string;
610
+ readonly name?: string | undefined;
611
+ readonly onDelete?: ForeignKeyConstraint['onDelete'] | undefined;
612
+ readonly onUpdate?: ForeignKeyConstraint['onUpdate'] | undefined;
613
+ readonly constraint?: boolean | undefined;
614
+ readonly index?: boolean | undefined;
615
+ },
616
+ ): ForeignKeyNode {
617
+ return {
618
+ columns: mapFieldNamesToColumnNames(spec.modelName, foreignKey.fields, spec.fieldToColumn),
619
+ references: {
620
+ model: foreignKey.targetModel,
621
+ table: foreignKey.targetTableName ?? foreignKey.targetModel.toLowerCase(),
622
+ columns: foreignKey.targetFields,
623
+ ...(foreignKey.targetNamespaceId !== undefined
624
+ ? { namespaceId: foreignKey.targetNamespaceId }
625
+ : {}),
626
+ spaceId: foreignKey.targetSpaceId,
627
+ },
628
+ ...(foreignKey.name ? { name: foreignKey.name } : {}),
629
+ ...(foreignKey.onDelete ? { onDelete: foreignKey.onDelete } : {}),
630
+ ...(foreignKey.onUpdate ? { onUpdate: foreignKey.onUpdate } : {}),
631
+ ...(foreignKey.constraint !== undefined ? { constraint: foreignKey.constraint } : {}),
632
+ ...(foreignKey.index !== undefined ? { index: foreignKey.index } : {}),
633
+ };
634
+ }
635
+
636
+ function assertKnownExtensionPack(
637
+ extensionPacks: Record<string, ExtensionPackRef<'sql', string>> | undefined,
638
+ spaceId: string,
639
+ context: string,
640
+ ): void {
641
+ if (extensionPacks !== undefined && Object.hasOwn(extensionPacks, spaceId)) {
642
+ return;
643
+ }
644
+ throw new Error(
645
+ `${context} references contract space "${spaceId}" but "${spaceId}" is not declared in extensionPacks. Add the pack to extensionPacks.`,
646
+ );
647
+ }
648
+
519
649
  function resolveForeignKeyNodes(
520
650
  spec: RuntimeModelSpec,
521
651
  allSpecs: ReadonlyMap<string, RuntimeModelSpec>,
652
+ extensionPacks?: Record<string, ExtensionPackRef<'sql', string>>,
522
653
  ): readonly ForeignKeyNode[] {
523
654
  const relationForeignKeys = resolveRelationForeignKeys(spec, allSpecs).map((foreignKey) => {
655
+ // F-relfk: relation-derived FKs for cross-space targets carry targetSpaceId;
656
+ // route them through the cross-space path, just like explicit sql() FKs.
657
+ if (foreignKey.targetSpaceId !== undefined) {
658
+ assertKnownExtensionPack(
659
+ extensionPacks,
660
+ foreignKey.targetSpaceId,
661
+ `Relation-derived foreign key on "${spec.modelName}"`,
662
+ );
663
+ return lowerCrossSpaceForeignKeyNode(spec, {
664
+ ...foreignKey,
665
+ targetSpaceId: foreignKey.targetSpaceId,
666
+ });
667
+ }
668
+
524
669
  const targetSpec = allSpecs.get(foreignKey.targetModel);
525
670
  if (!targetSpec) {
526
671
  throw new Error(
@@ -528,10 +673,22 @@ function resolveForeignKeyNodes(
528
673
  );
529
674
  }
530
675
 
531
- return lowerForeignKeyNode(spec, targetSpec, foreignKey);
676
+ return lowerLocalForeignKeyNode(spec, targetSpec, foreignKey);
532
677
  });
533
678
 
534
679
  const sqlForeignKeys = (spec.sqlSpec?.foreignKeys ?? []).map((foreignKey) => {
680
+ if (foreignKey.targetSpaceId !== undefined) {
681
+ assertKnownExtensionPack(
682
+ extensionPacks,
683
+ foreignKey.targetSpaceId,
684
+ `Foreign key on "${spec.modelName}"`,
685
+ );
686
+ return lowerCrossSpaceForeignKeyNode(spec, {
687
+ ...foreignKey,
688
+ targetSpaceId: foreignKey.targetSpaceId,
689
+ });
690
+ }
691
+
535
692
  const targetSpec = allSpecs.get(foreignKey.targetModel);
536
693
  if (!targetSpec) {
537
694
  throw new Error(
@@ -539,7 +696,7 @@ function resolveForeignKeyNodes(
539
696
  );
540
697
  }
541
698
 
542
- return lowerForeignKeyNode(spec, targetSpec, foreignKey);
699
+ return lowerLocalForeignKeyNode(spec, targetSpec, foreignKey);
543
700
  });
544
701
 
545
702
  return [...relationForeignKeys, ...sqlForeignKeys];
@@ -550,6 +707,7 @@ function resolveModelNode(
550
707
  allSpecs: ReadonlyMap<string, RuntimeModelSpec>,
551
708
  storageTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry>,
552
709
  storageTypeReverseLookup: ReadonlyMap<StorageTypeInstance | PostgresEnumStorageEntry, string>,
710
+ extensionPacks?: Record<string, ExtensionPackRef<'sql', string>>,
553
711
  ): ModelNode {
554
712
  const fields: FieldNode[] = [];
555
713
 
@@ -567,6 +725,11 @@ function resolveModelNode(
567
725
  throw new Error(`Column name resolution failed for "${spec.modelName}.${fieldName}"`);
568
726
  }
569
727
 
728
+ const enumHandle =
729
+ 'typeRef' in fieldState && isEnumTypeHandle(fieldState.typeRef)
730
+ ? fieldState.typeRef
731
+ : undefined;
732
+
570
733
  fields.push({
571
734
  fieldName,
572
735
  columnName,
@@ -574,6 +737,7 @@ function resolveModelNode(
574
737
  nullable: fieldState.nullable,
575
738
  ...(fieldState.default ? { default: fieldState.default } : {}),
576
739
  ...(fieldState.executionDefaults ? { executionDefaults: fieldState.executionDefaults } : {}),
740
+ ...(enumHandle !== undefined ? { enumTypeHandle: enumHandle } : {}),
577
741
  });
578
742
  }
579
743
 
@@ -588,9 +752,9 @@ function resolveModelNode(
588
752
  ...ifDefined('type', index.type),
589
753
  ...ifDefined('options', index.options),
590
754
  })) satisfies readonly IndexNode[];
591
- const foreignKeys = resolveForeignKeyNodes(spec, allSpecs);
755
+ const foreignKeys = resolveForeignKeyNodes(spec, allSpecs, extensionPacks);
592
756
  const relations = Object.entries(spec.relations).map(([relationName, relationBuilder]) =>
593
- resolveRelationNode(relationName, relationBuilder.build(), spec, allSpecs),
757
+ resolveRelationNode(relationName, relationBuilder.build(), spec, allSpecs, extensionPacks),
594
758
  );
595
759
 
596
760
  return {
@@ -614,6 +778,7 @@ function resolveModelNode(
614
778
  ...(indexes.length > 0 ? { indexes } : {}),
615
779
  ...(foreignKeys.length > 0 ? { foreignKeys } : {}),
616
780
  ...(relations.length > 0 ? { relations } : {}),
781
+ ...ifDefined('control', spec.sqlSpec?.control),
617
782
  };
618
783
  }
619
784
 
@@ -687,7 +852,10 @@ function collectRuntimeModelSpecs(definition: ContractInput): RuntimeCollection
687
852
  };
688
853
  }
689
854
 
690
- function lowerModels(collection: RuntimeCollection): readonly ModelNode[] {
855
+ function lowerModels(
856
+ collection: RuntimeCollection,
857
+ extensionPacks?: Record<string, ExtensionPackRef<'sql', string>>,
858
+ ): readonly ModelNode[] {
691
859
  emitTypedCrossModelFallbackWarnings(collection);
692
860
 
693
861
  const storageTypeReverseLookup = buildStorageTypeReverseLookup(collection.storageTypes);
@@ -697,16 +865,18 @@ function lowerModels(collection: RuntimeCollection): readonly ModelNode[] {
697
865
  collection.modelSpecs,
698
866
  collection.storageTypes,
699
867
  storageTypeReverseLookup,
868
+ extensionPacks,
700
869
  ),
701
870
  );
702
871
  }
703
872
 
704
873
  export function buildContractDefinition(definition: ContractInput): ContractDefinition {
705
874
  const collection = collectRuntimeModelSpecs(definition);
706
- const models = lowerModels(collection);
875
+ const models = lowerModels(collection, definition.extensionPacks);
707
876
 
708
877
  return {
709
878
  target: definition.target,
879
+ ...ifDefined('defaultControlPolicy', definition.defaultControlPolicy),
710
880
  ...(definition.extensionPacks ? { extensionPacks: definition.extensionPacks } : {}),
711
881
  ...(definition.storageHash ? { storageHash: definition.storageHash } : {}),
712
882
  ...(definition.foreignKeyDefaults ? { foreignKeyDefaults: definition.foreignKeyDefaults } : {}),
@@ -715,6 +885,9 @@ export function buildContractDefinition(definition: ContractInput): ContractDefi
715
885
  : {}),
716
886
  ...(definition.namespaces ? { namespaces: definition.namespaces } : {}),
717
887
  ...(definition.createNamespace ? { createNamespace: definition.createNamespace } : {}),
888
+ ...(definition.enums && Object.keys(definition.enums).length > 0
889
+ ? { enums: definition.enums }
890
+ : {}),
718
891
  models,
719
892
  };
720
893
  }
@@ -6,11 +6,11 @@ import type {
6
6
  StorageHashBase,
7
7
  } from '@prisma-next/contract/types';
8
8
  import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components';
9
+ import type { StorageType } from '@prisma-next/framework-components/ir';
9
10
  import type { IndexTypeRegistration } from '@prisma-next/sql-contract/index-types';
10
11
  import type {
11
12
  ContractWithTypeMaps,
12
13
  Index,
13
- PostgresEnumStorageEntry,
14
14
  ReferentialAction,
15
15
  StorageTypeInstance,
16
16
  TypeMaps,
@@ -144,10 +144,7 @@ type DefinitionNamespaces<Definition> = Definition extends {
144
144
  type DefinitionTypes<Definition> = Definition extends {
145
145
  readonly types?: unknown;
146
146
  }
147
- ? Present<Definition['types']> extends Record<
148
- string,
149
- StorageTypeInstance | PostgresEnumStorageEntry
150
- >
147
+ ? Present<Definition['types']> extends Record<string, StorageType>
151
148
  ? Present<Definition['types']>
152
149
  : Record<never, never>
153
150
  : Record<never, never>;
@@ -311,10 +308,7 @@ type DescriptorTypeRef<Descriptor> = Descriptor extends {
311
308
  ? TypeRef
312
309
  : undefined;
313
310
 
314
- type LookupNamedStorageTypeKeyByValue<
315
- Definition,
316
- TypeRef extends StorageTypeInstance | PostgresEnumStorageEntry,
317
- > = {
311
+ type LookupNamedStorageTypeKeyByValue<Definition, TypeRef extends StorageType> = {
318
312
  [TypeName in keyof DefinitionTypes<Definition> & string]: [TypeRef] extends [
319
313
  DefinitionTypes<Definition>[TypeName],
320
314
  ]
@@ -326,7 +320,7 @@ type LookupNamedStorageTypeKeyByValue<
326
320
 
327
321
  type ResolveNamedStorageTypeKey<Definition, TypeRef> = TypeRef extends string
328
322
  ? TypeRef
329
- : TypeRef extends StorageTypeInstance | PostgresEnumStorageEntry
323
+ : TypeRef extends StorageType
330
324
  ? [LookupNamedStorageTypeKeyByValue<Definition, TypeRef>] extends [never]
331
325
  ? string
332
326
  : LookupNamedStorageTypeKeyByValue<Definition, TypeRef>
@@ -540,6 +534,7 @@ type BuiltStorageTables<Definition> = {
540
534
  };
541
535
  readonly target: {
542
536
  readonly namespaceId: NamespaceId;
537
+ readonly spaceId?: string;
543
538
  readonly tableName: string;
544
539
  readonly columns: readonly string[];
545
540
  };
@@ -564,9 +559,9 @@ type BuiltStorageTables<Definition> = {
564
559
  };
565
560
 
566
561
  type BuiltDocumentScopedTypes<Definition> = {
567
- readonly [K in keyof DefinitionTypes<Definition> as DefinitionTypes<Definition>[K] extends PostgresEnumStorageEntry
568
- ? never
569
- : K]: DefinitionTypes<Definition>[K];
562
+ readonly [K in keyof DefinitionTypes<Definition> as DefinitionTypes<Definition>[K] extends StorageTypeInstance
563
+ ? K
564
+ : never]: DefinitionTypes<Definition>[K];
570
565
  };
571
566
 
572
567
  type BuiltDomain<Definition> =
@@ -586,19 +581,20 @@ type BuiltStorage<Definition> = {
586
581
  readonly types?: BuiltDocumentScopedTypes<Definition>;
587
582
  // The primary namespace key is target-specific: Postgres uses `public` (the
588
583
  // default schema), all other SQL targets use `__unbound__`. The namespace
589
- // carries the narrowed tables shape so downstream DSL surfaces keep
584
+ // carries the narrowed `entries.table` shape so downstream DSL surfaces keep
590
585
  // literal-keyed access without an optional-narrowing dance. The shape is
591
586
  // described inline (rather than intersecting with `SqlStorage['namespaces']`)
592
587
  // so its `Readonly<Record<string, Namespace>>` index signature doesn't
593
- // collapse `keyof tables` to `string`. The literal object is still
594
- // structurally assignable to `SqlStorage['namespaces']` because every value
595
- // satisfies the framework `Namespace` interface.
588
+ // collapse slot keys to `string`. The literal object is still structurally
589
+ // assignable to `SqlStorage['namespaces']` because every value satisfies the
590
+ // framework `Namespace` interface.
596
591
  readonly namespaces: {
597
592
  readonly [K in DefaultStorageNamespaceId<Definition>]: {
598
593
  readonly id: K;
599
594
  readonly kind: string;
600
- readonly tables: BuiltStorageTables<Definition>;
601
- readonly enum?: Readonly<Record<string, PostgresEnumStorageEntry>>;
595
+ readonly entries: {
596
+ readonly table: BuiltStorageTables<Definition>;
597
+ };
602
598
  };
603
599
  } & {
604
600
  readonly [Ns in Exclude<
@@ -607,8 +603,9 @@ type BuiltStorage<Definition> = {
607
603
  >]: {
608
604
  readonly id: Ns;
609
605
  readonly kind: string;
610
- readonly tables: Record<never, never>;
611
- readonly enum?: Readonly<Record<string, PostgresEnumStorageEntry>>;
606
+ readonly entries: {
607
+ readonly table: Record<never, never>;
608
+ };
612
609
  };
613
610
  };
614
611
  };
@@ -0,0 +1,236 @@
1
+ import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec';
2
+ import { blindCast } from '@prisma-next/utils/casts';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // EnumMember — a single member declaration with literal type preservation
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * A single enum member produced by `member()`. The `Name` and `Value` generics
10
+ * are preserved as literal types so `enumType()` can carry the ordered value
11
+ * tuple in its return type.
12
+ */
13
+ export interface EnumMember<Name extends string, Value extends string> {
14
+ readonly name: Name;
15
+ readonly value: Value;
16
+ }
17
+
18
+ /**
19
+ * Declare an enum member. The `value` defaults to `name` when omitted.
20
+ * Both generics are preserved as literals so downstream `enumType` can
21
+ * carry the ordered value tuple in its type.
22
+ */
23
+ export function member<const Name extends string>(name: Name): EnumMember<Name, Name>;
24
+ export function member<const Name extends string, const Value extends string>(
25
+ name: Name,
26
+ value: Value,
27
+ ): EnumMember<Name, Value>;
28
+ export function member<const Name extends string, const Value extends string = Name>(
29
+ name: Name,
30
+ value?: Value,
31
+ ): EnumMember<Name, Value> {
32
+ return {
33
+ name,
34
+ value: blindCast<
35
+ Value,
36
+ 'overload signatures enforce Value=Name when value is omitted; default generic Value=Name makes this safe'
37
+ >(value ?? name),
38
+ };
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Internal types for inferring the literal tuple from the members spread
43
+ // ---------------------------------------------------------------------------
44
+
45
+ type MembersToValues<Members extends readonly EnumMember<string, string>[]> = {
46
+ readonly [K in keyof Members]: Members[K] extends EnumMember<string, infer V> ? V : never;
47
+ };
48
+
49
+ type MembersToNames<Members extends readonly EnumMember<string, string>[]> = {
50
+ readonly [K in keyof Members]: Members[K] extends EnumMember<infer N, string> ? N : never;
51
+ };
52
+
53
+ type MembersAccessorMap<Members extends readonly EnumMember<string, string>[]> = {
54
+ readonly [M in Members[number] as M['name']]: M['value'];
55
+ };
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // EnumTypeHandle — the authoring handle returned by enumType()
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Internal brand that identifies an EnumTypeHandle in the lowering pipeline.
63
+ * Not exported — callers only interact with `EnumTypeHandle`.
64
+ */
65
+ export const ENUM_TYPE_HANDLE_BRAND = Symbol('EnumTypeHandle');
66
+
67
+ /**
68
+ * Authoring handle returned by `enumType()`. Carries:
69
+ *
70
+ * - The ordered literal value tuple (`.values`) and name tuple (`.names`)
71
+ * so downstream type-tests can assert literal preservation.
72
+ * - A namespaced member accessor map (`.members`) to avoid collisions with
73
+ * `.values` / `.has` / `.nameOf` / `.ordinalOf`.
74
+ * - Runtime helpers `.has()`, `.nameOf()`, `.ordinalOf()`.
75
+ * - Internal metadata (`enumName`, `codecId`, `nativeType`,
76
+ * `enumMembers`) for the lowering pipeline.
77
+ *
78
+ * The type is generic over the ordered value tuple so callers that assign
79
+ * `const Role = enumType(...)` retain the literal tuple on `.values`.
80
+ */
81
+ export interface EnumTypeHandle<
82
+ Name extends string = string,
83
+ Values extends readonly string[] = readonly string[],
84
+ Names extends readonly string[] = readonly string[],
85
+ MembersMap extends Record<string, string> = Record<string, string>,
86
+ > {
87
+ /** Internal brand for lowering-pipeline detection. */
88
+ readonly [ENUM_TYPE_HANDLE_BRAND]: true;
89
+
90
+ /** The enum's declared name (used as the key in domain `enum` / storage `valueSet`). */
91
+ readonly enumName: Name;
92
+
93
+ /** codecId from the codec passed to `enumType`. */
94
+ readonly codecId: string;
95
+
96
+ /** nativeType from the codec passed to `enumType`. */
97
+ readonly nativeType: string;
98
+
99
+ /** Ordered member list for lowering (name + value pairs). */
100
+ readonly enumMembers: readonly { readonly name: string; readonly value: string }[];
101
+
102
+ /** Ordered literal value tuple. Declaration order is preserved. */
103
+ readonly values: Values;
104
+
105
+ /** Ordered literal name tuple. Declaration order is preserved. */
106
+ readonly names: Names;
107
+
108
+ /**
109
+ * Namespaced accessor map: `Role.members.User === 'user'`.
110
+ * Namespaced under `.members` to avoid collisions with `.values` / `.has`.
111
+ */
112
+ readonly members: MembersMap;
113
+
114
+ /** Returns `true` if `v` is a declared member value. */
115
+ has(v: string): boolean;
116
+
117
+ /** Returns the member name for a value, or `undefined` if not found. */
118
+ nameOf(v: string): string | undefined;
119
+
120
+ /** Returns the zero-based declaration index of a value, or `-1` if not found. */
121
+ ordinalOf(v: string): number;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // enumType()
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Declare a domain enum for use in TS-authoring contracts.
130
+ *
131
+ * - The codec is an explicit required argument — the `codecId` and
132
+ * `nativeType` are taken from the passed `ColumnTypeDescriptor` (e.g.
133
+ * `{ codecId: 'pg/text@1', nativeType: 'text' }` from a field preset
134
+ * output or a direct inline object).
135
+ * - `const` generics on the members spread preserve the ordered literal
136
+ * value tuple so `Role.values` is `readonly ['user','admin']`, not
137
+ * `string[]`.
138
+ * - Well-formedness assertions at construction: non-empty member list;
139
+ * unique names; unique values.
140
+ *
141
+ * The returned handle wires into `field.namedType(handle)` to set
142
+ * `valueSet` refs on both the domain field and the storage column.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * const Role = enumType('Role', { codecId: 'pg/text@1', nativeType: 'text' },
147
+ * member('User', 'user'),
148
+ * member('Admin', 'admin'),
149
+ * );
150
+ * // Role.values → readonly ['user', 'admin']
151
+ * // Role.members.User → 'user'
152
+ * ```
153
+ */
154
+ export function enumType<
155
+ const Name extends string,
156
+ const Codec extends Pick<ColumnTypeDescriptor, 'codecId' | 'nativeType'>,
157
+ const Members extends readonly [EnumMember<string, string>, ...EnumMember<string, string>[]],
158
+ >(
159
+ name: Name,
160
+ codec: Codec,
161
+ ...members: Members
162
+ ): EnumTypeHandle<
163
+ Name,
164
+ MembersToValues<[...Members]>,
165
+ MembersToNames<[...Members]>,
166
+ MembersAccessorMap<[...Members]>
167
+ >;
168
+ export function enumType(
169
+ name: string,
170
+ codec: Pick<ColumnTypeDescriptor, 'codecId' | 'nativeType'>,
171
+ ...members: EnumMember<string, string>[]
172
+ ): EnumTypeHandle;
173
+ export function enumType(
174
+ name: string,
175
+ codec: Pick<ColumnTypeDescriptor, 'codecId' | 'nativeType'>,
176
+ ...members: EnumMember<string, string>[]
177
+ ): EnumTypeHandle {
178
+ if (members.length === 0) {
179
+ throw new Error(`enumType("${name}"): must have at least one member.`);
180
+ }
181
+
182
+ const seenNames = new Set<string>();
183
+ const seenValues = new Set<string>();
184
+ for (const m of members) {
185
+ if (seenNames.has(m.name)) {
186
+ throw new Error(
187
+ `enumType("${name}"): duplicate member name "${m.name}". Member names must be unique.`,
188
+ );
189
+ }
190
+ seenNames.add(m.name);
191
+
192
+ if (seenValues.has(m.value)) {
193
+ throw new Error(
194
+ `enumType("${name}"): duplicate member value "${m.value}". Member values must be unique.`,
195
+ );
196
+ }
197
+ seenValues.add(m.value);
198
+ }
199
+
200
+ const values = Object.freeze(members.map((m) => m.value));
201
+ const names = Object.freeze(members.map((m) => m.name));
202
+ const enumMembers = Object.freeze(members.map((m) => ({ name: m.name, value: m.value })));
203
+
204
+ const membersAccessor = Object.freeze(Object.fromEntries(members.map((m) => [m.name, m.value])));
205
+
206
+ const valueSet = new Set(values);
207
+ const valueToName = new Map(members.map((m) => [m.value, m.name]));
208
+ const valueToOrdinal = new Map(values.map((v, i) => [v, i]));
209
+
210
+ return {
211
+ [ENUM_TYPE_HANDLE_BRAND]: true,
212
+ enumName: name,
213
+ codecId: codec.codecId,
214
+ nativeType: codec.nativeType,
215
+ enumMembers,
216
+ values,
217
+ names,
218
+ members: membersAccessor,
219
+ has: (v: string) => valueSet.has(v),
220
+ nameOf: (v: string) => valueToName.get(v),
221
+ ordinalOf: (v: string) => valueToOrdinal.get(v) ?? -1,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Returns true when the value is an `EnumTypeHandle` produced by
227
+ * `enumType()`. Used in the lowering pipeline to detect enum handles
228
+ * in field state without importing the BRAND symbol at every call site.
229
+ */
230
+ export function isEnumTypeHandle(value: unknown): value is EnumTypeHandle {
231
+ return (
232
+ typeof value === 'object' &&
233
+ value !== null &&
234
+ Reflect.get(value, ENUM_TYPE_HANDLE_BRAND) === true
235
+ );
236
+ }