@prisma-next/sql-contract-ts 0.12.0-dev.57 → 0.12.0-dev.59

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.12.0-dev.57",
3
+ "version": "0.12.0-dev.59",
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.12.0-dev.57",
10
- "@prisma-next/contract": "0.12.0-dev.57",
11
- "@prisma-next/contract-authoring": "0.12.0-dev.57",
12
- "@prisma-next/framework-components": "0.12.0-dev.57",
13
- "@prisma-next/sql-contract": "0.12.0-dev.57",
14
- "@prisma-next/utils": "0.12.0-dev.57",
9
+ "@prisma-next/config": "0.12.0-dev.59",
10
+ "@prisma-next/contract": "0.12.0-dev.59",
11
+ "@prisma-next/contract-authoring": "0.12.0-dev.59",
12
+ "@prisma-next/framework-components": "0.12.0-dev.59",
13
+ "@prisma-next/sql-contract": "0.12.0-dev.59",
14
+ "@prisma-next/utils": "0.12.0-dev.59",
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.12.0-dev.57",
21
- "@prisma-next/tsconfig": "0.12.0-dev.57",
20
+ "@prisma-next/test-utils": "0.12.0-dev.59",
21
+ "@prisma-next/tsconfig": "0.12.0-dev.59",
22
22
  "@types/pg": "8.20.0",
23
- "pg": "8.20.0",
24
- "@prisma-next/tsdown": "0.12.0-dev.57",
25
- "tsdown": "0.22.0",
23
+ "pg": "8.21.0",
24
+ "@prisma-next/tsdown": "0.12.0-dev.59",
25
+ "tsdown": "0.22.1",
26
26
  "typescript": "5.9.3",
27
- "vitest": "4.1.6"
27
+ "vitest": "4.1.7"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "typescript": ">=5.9"
@@ -8,6 +8,7 @@ import {
8
8
  type ColumnDefault,
9
9
  type ColumnDefaultLiteralInputValue,
10
10
  type Contract,
11
+ type ContractEnum,
11
12
  type ContractField,
12
13
  type ContractModel,
13
14
  type ContractRelation,
@@ -18,6 +19,7 @@ import {
18
19
  type ExecutionMutationDefault,
19
20
  type JsonValue,
20
21
  type StorageHashBase,
22
+ type ValueSetRef,
21
23
  } from '@prisma-next/contract/types';
22
24
  import { type CapabilityMatrix, mergeCapabilityMatrices } from '@prisma-next/contract-authoring';
23
25
  import type { CodecLookup } from '@prisma-next/framework-components/codec';
@@ -41,6 +43,7 @@ import {
41
43
  StorageTable,
42
44
  type StorageTableInput,
43
45
  type StorageTypeInstance,
46
+ type StorageValueSetInput,
44
47
  toStorageTypeInstance,
45
48
  } from '@prisma-next/sql-contract/types';
46
49
  import { validateStorageSemantics } from '@prisma-next/sql-contract/validators';
@@ -166,6 +169,7 @@ function resolveModelNamespaceId(
166
169
 
167
170
  function buildStorageColumn(
168
171
  field: FieldNode | ValueObjectFieldNode,
172
+ storageValueSetRef: ValueSetRef | undefined,
169
173
  codecLookup?: CodecLookup,
170
174
  ): StorageColumn {
171
175
  if (isValueObjectField(field)) {
@@ -203,12 +207,14 @@ function buildStorageColumn(
203
207
  ...ifDefined('typeParams', field.descriptor.typeParams),
204
208
  ...ifDefined('default', encodedDefault),
205
209
  ...ifDefined('typeRef', field.descriptor.typeRef),
210
+ ...ifDefined('valueSet', storageValueSetRef),
206
211
  };
207
212
  }
208
213
 
209
214
  function buildDomainField(
210
215
  field: FieldNode | ValueObjectFieldNode,
211
216
  column: StorageColumn,
217
+ domainValueSetRef: ValueSetRef | undefined,
212
218
  ): ContractField {
213
219
  if (isValueObjectField(field)) {
214
220
  return {
@@ -226,6 +232,7 @@ function buildDomainField(
226
232
  },
227
233
  nullable: column.nullable,
228
234
  ...(field.many ? { many: true } : {}),
235
+ ...ifDefined('valueSet', domainValueSetRef),
229
236
  };
230
237
  }
231
238
 
@@ -369,11 +376,34 @@ export function buildSqlContractFromDefinition(
369
376
  }
370
377
  }
371
378
 
372
- const column = buildStorageColumn(field, codecLookup);
379
+ const enumHandle = !isValueObjectField(field) ? field.enumTypeHandle : undefined;
380
+ // Authored enums are always registered under the contract's defaultNamespaceId
381
+ // (see the enum registration loop below), so refs must point there regardless
382
+ // of which namespace the consuming model lives in.
383
+ const storageValueSetRef: ValueSetRef | undefined =
384
+ enumHandle !== undefined
385
+ ? {
386
+ plane: 'storage',
387
+ entityKind: 'value-set',
388
+ namespaceId: defaultNamespaceId,
389
+ name: enumHandle.enumName,
390
+ }
391
+ : undefined;
392
+ const domainValueSetRef: ValueSetRef | undefined =
393
+ enumHandle !== undefined
394
+ ? {
395
+ plane: 'domain',
396
+ entityKind: 'enum',
397
+ namespaceId: defaultNamespaceId,
398
+ name: enumHandle.enumName,
399
+ }
400
+ : undefined;
401
+
402
+ const column = buildStorageColumn(field, storageValueSetRef, codecLookup);
373
403
  columns[field.columnName] = column;
374
404
  fieldToColumn[field.fieldName] = field.columnName;
375
405
 
376
- domainFields[field.fieldName] = buildDomainField(field, column);
406
+ domainFields[field.fieldName] = buildDomainField(field, column, domainValueSetRef);
377
407
 
378
408
  if (isValueObjectField(field)) {
379
409
  domainFieldRefs[field.fieldName] = {
@@ -585,6 +615,39 @@ export function buildSqlContractFromDefinition(
585
615
  for (const id of Object.keys(namespaceEnumTypesById)) {
586
616
  namespaceCoordinateIds.add(id);
587
617
  }
618
+
619
+ // Build per-namespace registries for `enumType()` handles.
620
+ // All authored enums target the contract's default namespace.
621
+ const domainEnumsByNs: Record<string, Record<string, ContractEnum>> = {};
622
+ const storageValueSetsByNs: Record<string, Record<string, StorageValueSetInput>> = {};
623
+ for (const [enumName, handle] of Object.entries(definition.enums ?? {})) {
624
+ if (enumName !== handle.enumName) {
625
+ throw new Error(
626
+ `enum declaration key "${enumName}" must match enumType name "${handle.enumName}". Aliases are not supported.`,
627
+ );
628
+ }
629
+ const nsId = defaultNamespaceId;
630
+ let domainSlot = domainEnumsByNs[nsId];
631
+ if (domainSlot === undefined) {
632
+ domainSlot = {};
633
+ domainEnumsByNs[nsId] = domainSlot;
634
+ }
635
+ domainSlot[enumName] = {
636
+ codecId: handle.codecId,
637
+ members: handle.enumMembers,
638
+ };
639
+
640
+ let storageSlot = storageValueSetsByNs[nsId];
641
+ if (storageSlot === undefined) {
642
+ storageSlot = {};
643
+ storageValueSetsByNs[nsId] = storageSlot;
644
+ }
645
+ storageSlot[enumName] = {
646
+ kind: 'value-set',
647
+ values: handle.values,
648
+ };
649
+ }
650
+
588
651
  const { createNamespace } = definition;
589
652
  const namespaces = blindCast<
590
653
  SqlStorageInput['namespaces'],
@@ -593,10 +656,14 @@ export function buildSqlContractFromDefinition(
593
656
  Object.fromEntries(
594
657
  [...namespaceCoordinateIds].sort().map((id) => {
595
658
  const enumTypes = namespaceEnumTypesById[id];
659
+ const valueSetEntries = storageValueSetsByNs[id];
596
660
  const nsInput: SqlNamespaceTablesInput = {
597
661
  id,
598
662
  entries: {
599
663
  table: tablesByNamespace[id] ?? {},
664
+ ...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
665
+ ? { valueSet: valueSetEntries }
666
+ : {}),
600
667
  },
601
668
  };
602
669
  return [
@@ -711,13 +778,22 @@ export function buildSqlContractFromDefinition(
711
778
  if (valueObjects !== undefined) {
712
779
  domainNamespaceIds.add(defaultNamespaceId);
713
780
  }
781
+ for (const nsId of Object.keys(domainEnumsByNs)) {
782
+ domainNamespaceIds.add(nsId);
783
+ }
714
784
  const domainNamespaces = Object.fromEntries(
715
785
  [...domainNamespaceIds].sort().map((namespaceId) => {
716
786
  const modelsInNs = modelsByNamespace[namespaceId] ?? {};
717
- const namespaceSlice =
718
- namespaceId === defaultNamespaceId && valueObjects !== undefined
719
- ? { models: modelsInNs, valueObjects }
720
- : { models: modelsInNs };
787
+ const enumsInNs = domainEnumsByNs[namespaceId];
788
+ const namespaceSlice = {
789
+ models: modelsInNs,
790
+ ...(namespaceId === defaultNamespaceId && valueObjects !== undefined
791
+ ? { valueObjects }
792
+ : {}),
793
+ ...(enumsInNs !== undefined && Object.keys(enumsInNs).length > 0
794
+ ? { enum: enumsInNs }
795
+ : {}),
796
+ };
721
797
  return [namespaceId, namespaceSlice];
722
798
  }),
723
799
  );
@@ -34,6 +34,7 @@ import {
34
34
  } from './contract-dsl';
35
35
  import { buildContractDefinition } from './contract-lowering';
36
36
  import type { SqlContractResult } from './contract-types';
37
+ import type { EnumTypeHandle } from './enum-type';
37
38
 
38
39
  export { buildSqlContractFromDefinition } from './build-contract';
39
40
 
@@ -73,6 +74,7 @@ type ContractDefinition<
73
74
  readonly types?: Types;
74
75
  readonly models?: Models;
75
76
  readonly codecLookup?: CodecLookup;
77
+ readonly enums?: Record<string, EnumTypeHandle>;
76
78
  };
77
79
 
78
80
  type ContractScaffold<
@@ -96,6 +98,7 @@ type ContractScaffold<
96
98
  readonly types?: never;
97
99
  readonly models?: never;
98
100
  readonly codecLookup?: CodecLookup;
101
+ readonly enums?: Record<string, EnumTypeHandle>;
99
102
  };
100
103
 
101
104
  type ContractFactory<
@@ -317,6 +320,7 @@ type BoundDefinitionInput<
317
320
  readonly types?: Types;
318
321
  readonly models?: Models;
319
322
  readonly codecLookup?: CodecLookup;
323
+ readonly enums?: Record<string, EnumTypeHandle>;
320
324
  };
321
325
 
322
326
  // Merges a bound input with the pre-bound family/target to produce a full ContractDefinition.
@@ -13,6 +13,7 @@ import type {
13
13
  SqlNamespaceTablesInput,
14
14
  StorageTypeInstance,
15
15
  } from '@prisma-next/sql-contract/types';
16
+ import type { EnumTypeHandle } from './enum-type';
16
17
 
17
18
  export type { ExecutionMutationDefaultPhases };
18
19
 
@@ -24,6 +25,8 @@ export interface FieldNode {
24
25
  readonly default?: ColumnDefault;
25
26
  readonly executionDefaults?: ExecutionMutationDefaultPhases;
26
27
  readonly many?: boolean;
28
+ /** Present when the field was authored with `field.namedType(enumHandle)`. */
29
+ readonly enumTypeHandle?: EnumTypeHandle;
27
30
  }
28
31
 
29
32
  export interface PrimaryKeyNode {
@@ -163,4 +166,10 @@ export interface ContractDefinition {
163
166
  ) => Namespace;
164
167
  readonly models: readonly ModelNode[];
165
168
  readonly valueObjects?: readonly ValueObjectNode[];
169
+ /**
170
+ * Domain enum handles authored via `enumType()`. Each entry lowers to a
171
+ * domain `enum` entry and a storage `valueSet` entry in the contract's
172
+ * default namespace.
173
+ */
174
+ readonly enums?: Record<string, EnumTypeHandle>;
166
175
  }
@@ -23,6 +23,8 @@ import type {
23
23
  } from '@prisma-next/sql-contract/types';
24
24
  import { ifDefined } from '@prisma-next/utils/defined';
25
25
  import type { NamedConstraintSpec } from './authoring-type-utils';
26
+ import type { EnumTypeHandle } from './enum-type';
27
+ import { isEnumTypeHandle } from './enum-type';
26
28
 
27
29
  export type NamingStrategy = 'identity' | 'snake_case';
28
30
 
@@ -31,7 +33,7 @@ export type NamingConfig = {
31
33
  readonly columns?: NamingStrategy;
32
34
  };
33
35
 
34
- type NamedStorageTypeRef = string | StorageTypeInstance | PostgresEnumStorageEntry;
36
+ type NamedStorageTypeRef = string | StorageTypeInstance | PostgresEnumStorageEntry | EnumTypeHandle;
35
37
 
36
38
  type NamedConstraintNameSpec<Name extends string = string> = {
37
39
  readonly name: Name;
@@ -358,9 +360,19 @@ function namedTypeField<TypeRef extends StorageTypeInstance>(
358
360
  function namedTypeField<TypeRef extends PostgresEnumStorageEntry>(
359
361
  typeRef: TypeRef,
360
362
  ): ScalarFieldBuilder<ScalarFieldState<string, TypeRef, false, undefined>>;
363
+ function namedTypeField<Handle extends EnumTypeHandle>(
364
+ typeRef: Handle,
365
+ ): ScalarFieldBuilder<ScalarFieldState<Handle['codecId'], Handle, false, undefined>>;
361
366
  function namedTypeField(
362
367
  typeRef: NamedStorageTypeRef,
363
368
  ): ScalarFieldBuilder<ScalarFieldState<string, NamedStorageTypeRef, false, undefined>> {
369
+ if (isEnumTypeHandle(typeRef)) {
370
+ return new ScalarFieldBuilder({
371
+ kind: 'scalar',
372
+ typeRef,
373
+ nullable: false,
374
+ });
375
+ }
364
376
  return new ScalarFieldBuilder({
365
377
  kind: 'scalar',
366
378
  typeRef,
@@ -1267,6 +1279,12 @@ export type ContractInput<
1267
1279
  readonly types?: Types;
1268
1280
  readonly models?: Models;
1269
1281
  readonly codecLookup?: CodecLookup;
1282
+ /**
1283
+ * Domain enum handles authored via `enumType()`. Each handle lowers to a
1284
+ * domain `enum` entry and a storage `valueSet` entry in the target's
1285
+ * default namespace. Fields reference the enum via `field.namedType(handle)`.
1286
+ */
1287
+ readonly enums?: Record<string, import('./enum-type').EnumTypeHandle>;
1270
1288
  };
1271
1289
 
1272
1290
  export function model<
@@ -35,6 +35,7 @@ import {
35
35
  emitTypedCrossModelFallbackWarnings,
36
36
  emitTypedNamedTypeFallbackWarnings,
37
37
  } from './contract-warnings';
38
+ import { isEnumTypeHandle } from './enum-type';
38
39
 
39
40
  type RuntimeModel = ContractModelBuilder<
40
41
  string | undefined,
@@ -84,6 +85,13 @@ function resolveFieldDescriptor(
84
85
  }
85
86
 
86
87
  if ('typeRef' in fieldState && fieldState.typeRef) {
88
+ if (isEnumTypeHandle(fieldState.typeRef)) {
89
+ return {
90
+ codecId: fieldState.typeRef.codecId,
91
+ nativeType: fieldState.typeRef.nativeType,
92
+ };
93
+ }
94
+
87
95
  const typeRef =
88
96
  typeof fieldState.typeRef === 'string'
89
97
  ? fieldState.typeRef
@@ -567,6 +575,11 @@ function resolveModelNode(
567
575
  throw new Error(`Column name resolution failed for "${spec.modelName}.${fieldName}"`);
568
576
  }
569
577
 
578
+ const enumHandle =
579
+ 'typeRef' in fieldState && isEnumTypeHandle(fieldState.typeRef)
580
+ ? fieldState.typeRef
581
+ : undefined;
582
+
570
583
  fields.push({
571
584
  fieldName,
572
585
  columnName,
@@ -574,6 +587,7 @@ function resolveModelNode(
574
587
  nullable: fieldState.nullable,
575
588
  ...(fieldState.default ? { default: fieldState.default } : {}),
576
589
  ...(fieldState.executionDefaults ? { executionDefaults: fieldState.executionDefaults } : {}),
590
+ ...(enumHandle !== undefined ? { enumTypeHandle: enumHandle } : {}),
577
591
  });
578
592
  }
579
593
 
@@ -717,6 +731,9 @@ export function buildContractDefinition(definition: ContractInput): ContractDefi
717
731
  : {}),
718
732
  ...(definition.namespaces ? { namespaces: definition.namespaces } : {}),
719
733
  ...(definition.createNamespace ? { createNamespace: definition.createNamespace } : {}),
734
+ ...(definition.enums && Object.keys(definition.enums).length > 0
735
+ ? { enums: definition.enums }
736
+ : {}),
720
737
  models,
721
738
  };
722
739
  }
@@ -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
+ }
@@ -23,3 +23,5 @@ export type {
23
23
  RelationNode,
24
24
  UniqueConstraintNode,
25
25
  } from '../contract-definition';
26
+ export type { EnumMember, EnumTypeHandle } from '../enum-type';
27
+ export { enumType, member } from '../enum-type';