@prisma-next/mongo-contract-psl 0.14.0-dev.5 → 0.14.0-dev.51

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.
@@ -5,12 +5,25 @@ import type {
5
5
  import { computeProfileHash, computeStorageHash } from '@prisma-next/contract/hashing';
6
6
  import {
7
7
  type Contract,
8
+ type ContractEnum,
8
9
  type ContractField,
9
10
  type ContractReferenceRelation,
10
11
  type ContractValueObject,
11
12
  type CrossReference,
12
13
  crossRef,
14
+ type JsonValue,
15
+ type ValueSetRef,
13
16
  } from '@prisma-next/contract/types';
17
+ import type { EnumTypeHandle } from '@prisma-next/contract-authoring';
18
+ import { errorEnumCodecNotInPackStack } from '@prisma-next/errors/control';
19
+ import type {
20
+ AuthoringContributions,
21
+ AuthoringEntityContext,
22
+ } from '@prisma-next/framework-components/authoring';
23
+ import {
24
+ instantiateAuthoringEntityType,
25
+ isAuthoringEntityTypeDescriptor,
26
+ } from '@prisma-next/framework-components/authoring';
14
27
  import type { CodecLookup } from '@prisma-next/framework-components/codec';
15
28
  import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
16
29
  import {
@@ -20,17 +33,25 @@ import {
20
33
  MongoIndex,
21
34
  type MongoIndexKeyDirection,
22
35
  MongoStorage,
36
+ type MongoValueSetInput,
23
37
  } from '@prisma-next/mongo-contract';
24
38
  import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks';
25
39
  import type { CollationOptions } from '@prisma-next/mongo-value/mongodb-types';
26
40
  import type {
27
- ParsePslDocumentResult,
28
- PslField,
29
- PslModel,
30
- PslNamespace,
41
+ CompositeTypeSymbol,
42
+ FieldSymbol,
43
+ ModelSymbol,
44
+ NamespaceSymbol,
45
+ PslExtensionBlock,
31
46
  PslSpan,
47
+ ResolvedAttribute,
48
+ SymbolTable,
32
49
  } from '@prisma-next/psl-parser';
50
+ import { nodePslSpan } from '@prisma-next/psl-parser';
51
+ import type { SourceFile } from '@prisma-next/psl-parser/syntax';
52
+ import { assertDefined } from '@prisma-next/utils/assertions';
33
53
  import { blindCast } from '@prisma-next/utils/casts';
54
+ import { ifDefined } from '@prisma-next/utils/defined';
34
55
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
35
56
  import { deriveJsonSchema, derivePolymorphicJsonSchema } from './derive-json-schema';
36
57
  import {
@@ -44,42 +65,49 @@ import {
44
65
  parseRelationAttribute,
45
66
  } from './psl-helpers';
46
67
 
68
+ /**
69
+ * Encode an authored enum value to its codec-encoded JSON form via the codec resolved by id from the
70
+ * contract's codec lookup, so a non-identity `encodeJson` (permitted by the `mongoCodec` factory) is
71
+ * respected. Matches the TS builder's `encodeEnumValue`: the lookup is always threaded in production,
72
+ * and a codecId the lookup cannot resolve is a hard error — the enum uses a codec that is not part of
73
+ * the contract's pack stack.
74
+ */
75
+ function encodeEnumValue(value: unknown, codecId: string, codecLookup: CodecLookup): JsonValue {
76
+ const codec = codecLookup.get(codecId);
77
+ if (!codec) {
78
+ throw errorEnumCodecNotInPackStack({ codecId });
79
+ }
80
+ return codec.encodeJson(value);
81
+ }
82
+
47
83
  export interface InterpretPslDocumentToMongoContractInput {
48
- readonly document: ParsePslDocumentResult;
84
+ readonly symbolTable: SymbolTable;
85
+ readonly sourceFile: SourceFile;
86
+ readonly sourceId: string;
49
87
  readonly scalarTypeDescriptors: ReadonlyMap<string, string>;
50
88
  readonly codecLookup?: CodecLookup;
89
+ readonly seedDiagnostics?: readonly ContractSourceDiagnostic[];
90
+ readonly authoringContributions?: AuthoringContributions;
91
+ /** The target's default codec ids for an `enum` block that omits `@@type`. */
92
+ readonly enumInferenceCodecs?: { readonly text: string; readonly int: string };
51
93
  }
52
94
 
53
95
  /**
54
- * Name of the framework-parser synthesised bucket for top-level
55
- * declarations. Re-declared locally so the interpreter does not have to
56
- * import from `@prisma-next/framework-components/psl-ast`.
57
- */
58
- const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
59
-
60
- /**
61
- * Mongo FR16c validation: Mongo's authoring DSL exposes the connection's
62
- * database as the only namespace surface today, so the PSL interpreter
63
- * rejects every explicit `namespace { … }` block. The implicit
64
- * `__unspecified__` bucket (top-level declarations) is the only
65
- * namespace Mongo accepts. `namespace unbound { … }` is rejected too —
66
- * Mongo has no late-binding namespace concept on the PSL surface (the
67
- * database name comes from the connection string, not from PSL).
96
+ * Mongo's PSL surface binds the database from the connection string, so every
97
+ * explicit namespace block is invalid, including `namespace unbound { … }`.
68
98
  */
69
99
  function validateNamespaceBlocksForMongoTarget(input: {
70
- readonly namespaces: readonly PslNamespace[];
100
+ readonly namespaces: readonly NamespaceSymbol[];
71
101
  readonly sourceId: string;
102
+ readonly sourceFile: SourceFile;
72
103
  readonly diagnostics: ContractSourceDiagnostic[];
73
104
  }): void {
74
105
  for (const namespace of input.namespaces) {
75
- if (namespace.name === UNSPECIFIED_PSL_NAMESPACE_NAME) {
76
- continue;
77
- }
78
106
  input.diagnostics.push({
79
107
  code: 'PSL_UNSUPPORTED_NAMESPACE_BLOCK',
80
108
  message: `Mongo does not support \`namespace ${namespace.name} { … }\` blocks (the database is bound by the connection string; declare models at the document top level instead).`,
81
109
  sourceId: input.sourceId,
82
- span: namespace.span,
110
+ span: nodePslSpan(namespace.node.syntax, input.sourceFile),
83
111
  });
84
112
  }
85
113
  }
@@ -101,16 +129,16 @@ function fkRelationPairKey(declaringModel: string, targetModel: string): string
101
129
  return `${declaringModel}::${targetModel}`;
102
130
  }
103
131
 
104
- function resolveFieldMappings(model: PslModel): FieldMappings {
132
+ function resolveFieldMappings(model: ModelSymbol): FieldMappings {
105
133
  const pslNameToMapped = new Map<string, string>();
106
- for (const field of model.fields) {
134
+ for (const field of Object.values(model.fields)) {
107
135
  const mapped = getMapName(field.attributes) ?? field.name;
108
136
  pslNameToMapped.set(field.name, mapped);
109
137
  }
110
138
  return { pslNameToMapped };
111
139
  }
112
140
 
113
- function resolveCollectionName(model: PslModel): string {
141
+ function resolveCollectionName(model: ModelSymbol): string {
114
142
  return getMapName(model.attributes) ?? lowerFirst(model.name);
115
143
  }
116
144
 
@@ -123,12 +151,12 @@ interface MongoModelEntry {
123
151
  readonly base?: CrossReference;
124
152
  }
125
153
 
126
- type DiscriminatorDeclaration = { readonly fieldName: string; readonly span: PslModel['span'] };
154
+ type DiscriminatorDeclaration = { readonly fieldName: string; readonly span: PslSpan };
127
155
  type BaseDeclaration = {
128
156
  readonly baseName: string;
129
157
  readonly value: string;
130
158
  readonly collectionName: string;
131
- readonly span: PslModel['span'];
159
+ readonly span: PslSpan;
132
160
  };
133
161
 
134
162
  function mongoCrossRef(modelName: string): CrossReference {
@@ -136,7 +164,7 @@ function mongoCrossRef(modelName: string): CrossReference {
136
164
  }
137
165
 
138
166
  function collectPolymorphismDeclarations(
139
- document: ParsePslDocumentResult,
167
+ models: readonly ModelSymbol[],
140
168
  sourceId: string,
141
169
  diagnostics: ContractSourceDiagnostic[],
142
170
  ): {
@@ -145,32 +173,31 @@ function collectPolymorphismDeclarations(
145
173
  } {
146
174
  const discriminatorDeclarations = new Map<string, DiscriminatorDeclaration>();
147
175
  const baseDeclarations = new Map<string, BaseDeclaration>();
148
- const allPslModels = document.ast.namespaces.flatMap((ns) => ns.models);
149
176
 
150
- for (const pslModel of allPslModels) {
151
- for (const attr of pslModel.attributes) {
177
+ for (const model of models) {
178
+ for (const attr of model.attributes) {
152
179
  if (attr.name === 'discriminator') {
153
180
  const fieldName = getPositionalArgument(attr);
154
181
  if (!fieldName) {
155
182
  diagnostics.push({
156
183
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
157
- message: `Model "${pslModel.name}" @@discriminator requires a field name argument`,
184
+ message: `Model "${model.name}" @@discriminator requires a field name argument`,
158
185
  sourceId,
159
186
  span: attr.span,
160
187
  });
161
188
  continue;
162
189
  }
163
- const discField = pslModel.fields.find((f) => f.name === fieldName);
190
+ const discField = model.fields[fieldName];
164
191
  if (discField && discField.typeName !== 'String') {
165
192
  diagnostics.push({
166
193
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
167
- message: `Discriminator field "${fieldName}" on model "${pslModel.name}" must be of type String, but is "${discField.typeName}"`,
194
+ message: `Discriminator field "${fieldName}" on model "${model.name}" must be of type String, but is "${discField.typeName}"`,
168
195
  sourceId,
169
196
  span: attr.span,
170
197
  });
171
198
  continue;
172
199
  }
173
- discriminatorDeclarations.set(pslModel.name, { fieldName, span: attr.span });
200
+ discriminatorDeclarations.set(model.name, { fieldName, span: attr.span });
174
201
  }
175
202
  if (attr.name === 'base') {
176
203
  const baseName = getPositionalArgument(attr, 0);
@@ -178,7 +205,7 @@ function collectPolymorphismDeclarations(
178
205
  if (!baseName || !rawValue) {
179
206
  diagnostics.push({
180
207
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
181
- message: `Model "${pslModel.name}" @@base requires two arguments: base model name and discriminator value`,
208
+ message: `Model "${model.name}" @@base requires two arguments: base model name and discriminator value`,
182
209
  sourceId,
183
210
  span: attr.span,
184
211
  });
@@ -188,14 +215,14 @@ function collectPolymorphismDeclarations(
188
215
  if (value === undefined) {
189
216
  diagnostics.push({
190
217
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
191
- message: `Model "${pslModel.name}" @@base discriminator value must be a quoted string literal`,
218
+ message: `Model "${model.name}" @@base discriminator value must be a quoted string literal`,
192
219
  sourceId,
193
220
  span: attr.span,
194
221
  });
195
222
  continue;
196
223
  }
197
- const collectionName = resolveCollectionName(pslModel);
198
- baseDeclarations.set(pslModel.name, { baseName, value, collectionName, span: attr.span });
224
+ const collectionName = resolveCollectionName(model);
225
+ baseDeclarations.set(model.name, { baseName, value, collectionName, span: attr.span });
199
226
  }
200
227
  }
201
228
  }
@@ -207,7 +234,7 @@ function resolvePolymorphism(input: {
207
234
  models: Record<string, MongoModelEntry>;
208
235
  roots: Record<string, CrossReference>;
209
236
  collections: Record<string, Record<string, unknown>>;
210
- document: ParsePslDocumentResult;
237
+ allModels: readonly ModelSymbol[];
211
238
  discriminatorDeclarations: Map<string, DiscriminatorDeclaration>;
212
239
  baseDeclarations: Map<string, BaseDeclaration>;
213
240
  modelNames: ReadonlySet<string>;
@@ -225,11 +252,10 @@ function resolvePolymorphism(input: {
225
252
  baseDeclarations,
226
253
  modelNames,
227
254
  sourceId,
228
- document,
255
+ allModels: allModelViews,
229
256
  indexSpans,
230
257
  modelIndexesByName,
231
258
  } = input;
232
- const allPslModels = document.ast.namespaces.flatMap((ns) => ns.models);
233
259
  let patched = input.models;
234
260
  let roots = input.roots;
235
261
  let collections = input.collections;
@@ -249,9 +275,9 @@ function resolvePolymorphism(input: {
249
275
  const model = patched[modelName];
250
276
  if (!model) continue;
251
277
 
252
- const pslModel = allPslModels.find((m) => m.name === modelName);
253
- const mappedDiscriminatorField = pslModel
254
- ? (resolveFieldMappings(pslModel).pslNameToMapped.get(decl.fieldName) ?? decl.fieldName)
278
+ const modelView = allModelViews.find((m) => m.name === modelName);
279
+ const mappedDiscriminatorField = modelView
280
+ ? (resolveFieldMappings(modelView).pslNameToMapped.get(decl.fieldName) ?? decl.fieldName)
255
281
  : decl.fieldName;
256
282
 
257
283
  if (!Object.hasOwn(model.fields, mappedDiscriminatorField)) {
@@ -312,9 +338,9 @@ function resolvePolymorphism(input: {
312
338
  }
313
339
 
314
340
  const baseModel = patched[baseDecl.baseName];
315
- const variantPslModel = allPslModels.find((m) => m.name === variantName);
316
- if (!variantPslModel) continue;
317
- const hasExplicitMap = getMapName(variantPslModel.attributes) !== undefined;
341
+ const variantModelView = allModelViews.find((m) => m.name === variantName);
342
+ if (!variantModelView) continue;
343
+ const hasExplicitMap = getMapName(variantModelView.attributes) !== undefined;
318
344
 
319
345
  if (hasExplicitMap && baseModel && baseDecl.collectionName !== baseModel.storage.collection) {
320
346
  diagnostics.push({
@@ -339,7 +365,7 @@ function resolvePolymorphism(input: {
339
365
  };
340
366
  }
341
367
 
342
- const variantCollectionName = resolveCollectionName(variantPslModel);
368
+ const variantCollectionName = resolveCollectionName(variantModelView);
343
369
  if (roots[variantCollectionName]?.model === variantName) {
344
370
  if (variantCollectionName === baseCollection && baseModel) {
345
371
  roots = { ...roots, [variantCollectionName]: mongoCrossRef(baseDecl.baseName) };
@@ -483,9 +509,7 @@ function parseJsonArg(raw: string | undefined): Record<string, unknown> | undefi
483
509
  return undefined;
484
510
  }
485
511
 
486
- function parseCollation(
487
- attr: import('@prisma-next/psl-parser').PslAttribute,
488
- ): CollationOptions | null | undefined {
512
+ function parseCollation(attr: ResolvedAttribute): CollationOptions | null | undefined {
489
513
  const locale = stripQuotesHelper(getNamedArgument(attr, 'collationLocale'));
490
514
  if (!locale) {
491
515
  const hasAnyCollationArg =
@@ -545,7 +569,7 @@ function parseProjectionList(
545
569
  }
546
570
 
547
571
  function collectIndexes(
548
- pslModel: PslModel,
572
+ pslModel: ModelSymbol,
549
573
  fieldMappings: FieldMappings,
550
574
  modelNames: ReadonlySet<string>,
551
575
  sourceId: string,
@@ -561,12 +585,12 @@ function collectIndexes(
561
585
  // rather than fieldMappings.pslNameToMapped because the latter contains
562
586
  // every PSL field including relation fields.
563
587
  const indexableFieldNames = new Set<string>();
564
- for (const f of pslModel.fields) {
588
+ for (const f of Object.values(pslModel.fields)) {
565
589
  if (modelNames.has(f.typeName)) continue;
566
590
  indexableFieldNames.add(f.name);
567
591
  }
568
592
 
569
- for (const field of pslModel.fields) {
593
+ for (const field of Object.values(pslModel.fields)) {
570
594
  if (modelNames.has(field.typeName)) continue;
571
595
  const uniqueAttr = getAttribute(field.attributes, 'unique');
572
596
  if (!uniqueAttr) continue;
@@ -800,7 +824,7 @@ function collectIndexes(
800
824
  return indexes;
801
825
  }
802
826
 
803
- function isRelationField(field: PslField, modelNames: ReadonlySet<string>): boolean {
827
+ function isRelationField(field: FieldSymbol, modelNames: ReadonlySet<string>): boolean {
804
828
  return modelNames.has(field.typeName);
805
829
  }
806
830
 
@@ -808,17 +832,18 @@ function isRelationField(field: PslField, modelNames: ReadonlySet<string>): bool
808
832
  const MONGO_OBJECT_ID_PSL_TYPE = 'ObjectId';
809
833
 
810
834
  function resolveFieldCodecId(
811
- field: PslField,
835
+ field: FieldSymbol,
812
836
  scalarTypeDescriptors: ReadonlyMap<string, string>,
813
837
  ): string | undefined {
814
838
  return scalarTypeDescriptors.get(field.typeName);
815
839
  }
816
840
 
817
841
  function resolveNonRelationField(
818
- field: PslField,
842
+ field: FieldSymbol,
819
843
  ownerName: string,
820
844
  compositeTypeNames: ReadonlySet<string>,
821
845
  scalarTypeDescriptors: ReadonlyMap<string, string>,
846
+ codecIdByEnumName: ReadonlyMap<string, string>,
822
847
  sourceId: string,
823
848
  diagnostics: ContractSourceDiagnostic[],
824
849
  ): ContractField | undefined {
@@ -830,6 +855,29 @@ function resolveNonRelationField(
830
855
  return field.list ? { ...result, many: true } : result;
831
856
  }
832
857
 
858
+ // If this field's declared type is a known enum name, treat the field as a scalar
859
+ // with that enum's codec and stamp the domain valueSet ref.
860
+ const enumCodecId = codecIdByEnumName.get(field.typeName);
861
+ if (enumCodecId !== undefined) {
862
+ const valueSet: ValueSetRef = {
863
+ plane: 'domain',
864
+ entityKind: 'enum',
865
+ namespaceId: UNBOUND_NAMESPACE_ID,
866
+ entityName: field.typeName,
867
+ };
868
+ const result: ContractField = {
869
+ type: { kind: 'scalar', codecId: enumCodecId },
870
+ nullable: field.optional,
871
+ valueSet,
872
+ };
873
+ return field.list ? { ...result, many: true } : result;
874
+ }
875
+
876
+ // Avoid cascading unsupported-type diagnostics after invalid qualification.
877
+ if (field.malformedType) {
878
+ return undefined;
879
+ }
880
+
833
881
  const codecId = resolveFieldCodecId(field, scalarTypeDescriptors);
834
882
  if (!codecId) {
835
883
  diagnostics.push({
@@ -848,27 +896,106 @@ function resolveNonRelationField(
848
896
  return field.list ? { ...result, many: true } : result;
849
897
  }
850
898
 
899
+ function processEnumDeclarations(input: {
900
+ readonly enumBlocks: readonly PslExtensionBlock[];
901
+ readonly sourceId: string;
902
+ readonly authoringContributions: AuthoringContributions | undefined;
903
+ readonly entityContext: AuthoringEntityContext;
904
+ readonly diagnostics: ContractSourceDiagnostic[];
905
+ }): Record<string, ContractEnum> {
906
+ const builtEnums: Record<string, ContractEnum> = {};
907
+
908
+ if (input.enumBlocks.length === 0) return builtEnums;
909
+
910
+ const enumDescriptor =
911
+ input.authoringContributions?.entityTypes?.['enum'] !== undefined &&
912
+ isAuthoringEntityTypeDescriptor(input.authoringContributions.entityTypes['enum'])
913
+ ? input.authoringContributions.entityTypes['enum']
914
+ : undefined;
915
+
916
+ if (!enumDescriptor) {
917
+ for (const decl of input.enumBlocks) {
918
+ input.diagnostics.push({
919
+ code: 'PSL_ENUM_MISSING_FACTORY',
920
+ message: `enum "${decl.name}" requires an "enum" entityType factory in the active authoring contributions`,
921
+ sourceId: input.sourceId,
922
+ span: decl.span,
923
+ });
924
+ }
925
+ return builtEnums;
926
+ }
927
+
928
+ for (const decl of input.enumBlocks) {
929
+ const handle = instantiateAuthoringEntityType<EnumTypeHandle | undefined>(
930
+ 'enum',
931
+ enumDescriptor,
932
+ [decl],
933
+ input.entityContext,
934
+ );
935
+
936
+ if (handle === undefined || handle === null) continue;
937
+
938
+ builtEnums[decl.name] = {
939
+ codecId: handle.codecId,
940
+ members: handle.enumMembers.map((m) => ({
941
+ name: m.name,
942
+ value: blindCast<JsonValue, 'factory-validated enum members are JsonValue-compatible'>(
943
+ m.value,
944
+ ),
945
+ })),
946
+ };
947
+ }
948
+
949
+ return builtEnums;
950
+ }
951
+
851
952
  export function interpretPslDocumentToMongoContract(
852
953
  input: InterpretPslDocumentToMongoContractInput,
853
954
  ): Result<Contract, ContractSourceDiagnostics> {
854
- const { document, scalarTypeDescriptors, codecLookup } = input;
855
- const sourceId = document.ast.sourceId;
856
- const diagnostics: ContractSourceDiagnostic[] = [];
955
+ const { symbolTable, sourceFile, scalarTypeDescriptors, codecLookup } = input;
956
+ const sourceId = input.sourceId;
957
+ const diagnostics: ContractSourceDiagnostic[] = [...(input.seedDiagnostics ?? [])];
958
+ const topLevel = symbolTable.topLevel;
857
959
  validateNamespaceBlocksForMongoTarget({
858
- namespaces: document.ast.namespaces,
960
+ namespaces: Object.values(topLevel.namespaces),
859
961
  sourceId,
962
+ sourceFile,
860
963
  diagnostics,
861
964
  });
862
- // Mongo lowers only the implicit `__unspecified__` bucket today —
863
- // explicit `namespace { … }` blocks were rejected above. The IR
864
- // collection map remains flat (and the Mongo target represents
865
- // databases via `MongoTargetDatabase`, populated at compose time by
866
- // the connection string rather than authoring-time PSL).
867
- const allModels = document.ast.namespaces.flatMap((ns) => ns.models);
868
- const allCompositeTypes = document.ast.namespaces.flatMap((ns) => ns.compositeTypes);
965
+ const allModels: ModelSymbol[] = Object.values(topLevel.models);
966
+ const allCompositeTypes: CompositeTypeSymbol[] = Object.values(topLevel.compositeTypes);
869
967
  const modelNames = new Set(allModels.map((m) => m.name));
870
968
  const compositeTypeNames = new Set(allCompositeTypes.map((ct) => ct.name));
871
969
 
970
+ const topLevelEnumBlocks = Object.values(topLevel.blocks)
971
+ .filter((b) => b.keyword === 'enum')
972
+ .map((b) => b.block);
973
+
974
+ const builtEnums = processEnumDeclarations({
975
+ enumBlocks: topLevelEnumBlocks,
976
+ sourceId,
977
+ authoringContributions: input.authoringContributions,
978
+ entityContext: {
979
+ family: 'mongo',
980
+ target: 'mongo',
981
+ ...ifDefined('enumInferenceCodecs', input.enumInferenceCodecs),
982
+ ...ifDefined('codecLookup', codecLookup),
983
+ sourceId,
984
+ diagnostics: {
985
+ push: (d) => {
986
+ diagnostics.push(
987
+ blindCast<ContractSourceDiagnostic, 'sink diagnostics are span-compatible'>(d),
988
+ );
989
+ },
990
+ },
991
+ },
992
+ diagnostics,
993
+ });
994
+
995
+ const codecIdByEnumName: Map<string, string> = new Map(
996
+ Object.entries(builtEnums).map(([name, e]) => [name, e.codecId]),
997
+ );
998
+
872
999
  const models: Record<string, MongoModelEntry> = {};
873
1000
  const collections: Record<string, Record<string, unknown>> = {};
874
1001
  const roots: Record<string, CrossReference> = {};
@@ -882,7 +1009,7 @@ export function interpretPslDocumentToMongoContract(
882
1009
  readonly targetModelName: string;
883
1010
  readonly relationName?: string;
884
1011
  readonly cardinality: '1:1' | '1:N';
885
- readonly field: PslField;
1012
+ readonly field: FieldSymbol;
886
1013
  }
887
1014
  const backrelationCandidates: BackrelationCandidate[] = [];
888
1015
 
@@ -893,7 +1020,7 @@ export function interpretPslDocumentToMongoContract(
893
1020
  const fields: Record<string, ContractField> = {};
894
1021
  const relations: Record<string, ContractReferenceRelation> = {};
895
1022
 
896
- for (const field of pslModel.fields) {
1023
+ for (const field of Object.values(pslModel.fields)) {
897
1024
  if (isRelationField(field, modelNames)) {
898
1025
  const relation = parseRelationAttribute(field.attributes);
899
1026
 
@@ -902,9 +1029,7 @@ export function interpretPslDocumentToMongoContract(
902
1029
  modelName: pslModel.name,
903
1030
  fieldName: field.name,
904
1031
  targetModelName: field.typeName,
905
- ...(relation?.relationName !== undefined
906
- ? { relationName: relation.relationName }
907
- : {}),
1032
+ ...ifDefined('relationName', relation?.relationName),
908
1033
  cardinality: field.list ? '1:N' : '1:1',
909
1034
  field,
910
1035
  });
@@ -933,7 +1058,7 @@ export function interpretPslDocumentToMongoContract(
933
1058
  declaringModel: pslModel.name,
934
1059
  fieldName: field.name,
935
1060
  targetModel: field.typeName,
936
- ...(relation.relationName !== undefined ? { relationName: relation.relationName } : {}),
1061
+ ...ifDefined('relationName', relation.relationName),
937
1062
  localFields: localMapped,
938
1063
  targetFields: targetMapped,
939
1064
  });
@@ -946,6 +1071,7 @@ export function interpretPslDocumentToMongoContract(
946
1071
  pslModel.name,
947
1072
  compositeTypeNames,
948
1073
  scalarTypeDescriptors,
1074
+ codecIdByEnumName,
949
1075
  sourceId,
950
1076
  diagnostics,
951
1077
  );
@@ -956,7 +1082,9 @@ export function interpretPslDocumentToMongoContract(
956
1082
  }
957
1083
 
958
1084
  const isVariantModel = pslModel.attributes.some((attr) => attr.name === 'base');
959
- const hasIdField = pslModel.fields.some((f) => getAttribute(f.attributes, 'id') !== undefined);
1085
+ const hasIdField = Object.values(pslModel.fields).some(
1086
+ (f) => getAttribute(f.attributes, 'id') !== undefined,
1087
+ );
960
1088
  // Variant models inherit the base's identity and are validated through their base.
961
1089
  if (!isVariantModel) {
962
1090
  if (!hasIdField) {
@@ -1011,12 +1139,13 @@ export function interpretPslDocumentToMongoContract(
1011
1139
  const valueObjects: Record<string, ContractValueObject> = {};
1012
1140
  for (const compositeType of allCompositeTypes) {
1013
1141
  const fields: Record<string, ContractField> = {};
1014
- for (const field of compositeType.fields) {
1142
+ for (const field of Object.values(compositeType.fields)) {
1015
1143
  const resolved = resolveNonRelationField(
1016
1144
  field,
1017
1145
  compositeType.name,
1018
1146
  compositeTypeNames,
1019
1147
  scalarTypeDescriptors,
1148
+ codecIdByEnumName,
1020
1149
  sourceId,
1021
1150
  diagnostics,
1022
1151
  );
@@ -1078,7 +1207,7 @@ export function interpretPslDocumentToMongoContract(
1078
1207
  }
1079
1208
 
1080
1209
  const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
1081
- document,
1210
+ allModels,
1082
1211
  sourceId,
1083
1212
  diagnostics,
1084
1213
  );
@@ -1086,7 +1215,7 @@ export function interpretPslDocumentToMongoContract(
1086
1215
  models,
1087
1216
  roots,
1088
1217
  collections,
1089
- document,
1218
+ allModels,
1090
1219
  discriminatorDeclarations,
1091
1220
  baseDeclarations,
1092
1221
  modelNames,
@@ -1105,6 +1234,28 @@ export function interpretPslDocumentToMongoContract(
1105
1234
  const resolvedModels = polyResult.models;
1106
1235
  const resolvedCollections = polyResult.collections;
1107
1236
 
1237
+ // The storage value set is the source of truth for both the emit typing and the validator's
1238
+ // `enum` keyword. Built once, ahead of validator derivation, from each enum's codec-encoded member
1239
+ // values (mirroring SQL's build-contract). Encoding needs the codec lookup; production always
1240
+ // threads it (the CLI control stack supplies it), so its absence when enums exist is a wiring bug,
1241
+ // not a runtime input to tolerate.
1242
+ const storageValueSets: Record<string, MongoValueSetInput> = {};
1243
+ const enumEntries = Object.entries(builtEnums);
1244
+ if (enumEntries.length > 0) {
1245
+ assertDefined(
1246
+ codecLookup,
1247
+ 'Mongo PSL interpretation requires a codec lookup to encode enum values',
1248
+ );
1249
+ for (const [enumName, builtEnum] of enumEntries) {
1250
+ storageValueSets[enumName] = {
1251
+ kind: 'valueSet',
1252
+ values: builtEnum.members.map((m) =>
1253
+ encodeEnumValue(m.value, builtEnum.codecId, codecLookup),
1254
+ ),
1255
+ };
1256
+ }
1257
+ }
1258
+
1108
1259
  for (const [, modelEntry] of Object.entries(resolvedModels)) {
1109
1260
  if (modelEntry.base) continue;
1110
1261
 
@@ -1125,9 +1276,15 @@ export function interpretPslDocumentToMongoContract(
1125
1276
  variantEntries,
1126
1277
  valueObjects,
1127
1278
  codecLookup,
1279
+ storageValueSets,
1128
1280
  );
1129
1281
  } else {
1130
- coll['validator'] = deriveJsonSchema(modelEntry.fields, valueObjects, codecLookup);
1282
+ coll['validator'] = deriveJsonSchema(
1283
+ modelEntry.fields,
1284
+ valueObjects,
1285
+ codecLookup,
1286
+ storageValueSets,
1287
+ );
1131
1288
  }
1132
1289
  }
1133
1290
 
@@ -1144,18 +1301,27 @@ export function interpretPslDocumentToMongoContract(
1144
1301
  'arktype-validated JSON shapes satisfy MongoCollectionInput by construction'
1145
1302
  >(raw);
1146
1303
  }
1304
+ const hasValueSets = Object.keys(storageValueSets).length > 0;
1305
+
1147
1306
  const unboundNamespace = buildMongoNamespace({
1148
1307
  id: UNBOUND_NAMESPACE_ID,
1149
- entries: { collection: collectionInputs },
1308
+ entries: {
1309
+ collection: collectionInputs,
1310
+ ...(hasValueSets ? { valueSet: storageValueSets } : {}),
1311
+ },
1150
1312
  });
1151
- // Hash the constructed (normalized) collection map, not the raw input
1152
- // literals — persisted storageHash values were computed over the
1153
- // constructed form.
1313
+ // Hash the constructed (normalized) entries, not the raw input literals —
1314
+ // persisted storageHash values were computed over the constructed form.
1154
1315
  const storageWithoutHash = {
1155
1316
  namespaces: {
1156
1317
  [UNBOUND_NAMESPACE_ID]: {
1157
1318
  id: UNBOUND_NAMESPACE_ID,
1158
- entries: { collection: unboundNamespace.entries.collection },
1319
+ entries: {
1320
+ collection: unboundNamespace.entries.collection,
1321
+ ...(unboundNamespace.entries.valueSet !== undefined
1322
+ ? { valueSet: unboundNamespace.entries.valueSet }
1323
+ : {}),
1324
+ },
1159
1325
  },
1160
1326
  },
1161
1327
  };
@@ -1173,6 +1339,8 @@ export function interpretPslDocumentToMongoContract(
1173
1339
  }) as Contract['storage'];
1174
1340
  const capabilities: Record<string, Record<string, boolean>> = {};
1175
1341
 
1342
+ const hasEnums = Object.keys(builtEnums).length > 0;
1343
+
1176
1344
  return ok({
1177
1345
  targetFamily,
1178
1346
  target,
@@ -1182,6 +1350,7 @@ export function interpretPslDocumentToMongoContract(
1182
1350
  [UNBOUND_NAMESPACE_ID]: {
1183
1351
  models: polyResult.models,
1184
1352
  ...(Object.keys(valueObjects).length > 0 ? { valueObjects } : {}),
1353
+ ...(hasEnums ? { enum: builtEnums } : {}),
1185
1354
  },
1186
1355
  },
1187
1356
  },