@prisma-next/sql-contract-psl 0.13.0 → 0.14.0-dev.10

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.
@@ -14,9 +14,14 @@ import { crossRef } from '@prisma-next/contract/types';
14
14
  import type {
15
15
  AuthoringContributions,
16
16
  AuthoringEntityContext,
17
- AuthoringEntityTypeDescriptor,
17
+ AuthoringPslBlockDescriptorNamespace,
18
+ PslExtensionBlock,
18
19
  } from '@prisma-next/framework-components/authoring';
19
- import { instantiateAuthoringEntityType } from '@prisma-next/framework-components/authoring';
20
+ import {
21
+ instantiateAuthoringEntityType,
22
+ isAuthoringPslBlockDescriptor,
23
+ } from '@prisma-next/framework-components/authoring';
24
+ import type { CodecLookup } from '@prisma-next/framework-components/codec';
20
25
  import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components';
21
26
  import type {
22
27
  ControlMutationDefaultRegistry,
@@ -24,25 +29,24 @@ import type {
24
29
  MutationDefaultGeneratorDescriptor,
25
30
  } from '@prisma-next/framework-components/control';
26
31
  import type { Namespace } from '@prisma-next/framework-components/ir';
27
- import type {
28
- ParsePslDocumentResult,
29
- PslAttribute,
30
- PslCompositeType,
31
- PslEnum,
32
- PslField,
33
- PslModel,
34
- PslNamedTypeDeclaration,
35
- PslNamespace,
36
- } from '@prisma-next/psl-parser';
37
32
  import {
38
- isPostgresEnumStorageEntry,
39
- type PostgresEnumStorageEntry,
40
- type SqlModelStorage,
41
- type SqlNamespaceTablesInput,
42
- type StorageTypeInstance,
43
- } from '@prisma-next/sql-contract/types';
33
+ type BlockSymbol,
34
+ type CompositeTypeSymbol,
35
+ type FieldSymbol,
36
+ keywordPslSpan,
37
+ type ModelSymbol,
38
+ type NamespaceSymbol,
39
+ nodePslSpan,
40
+ type ResolvedAttribute,
41
+ type ScalarSymbol,
42
+ type SymbolTable,
43
+ type TypeAliasSymbol,
44
+ } from '@prisma-next/psl-parser';
45
+ import type { SourceFile } from '@prisma-next/psl-parser/syntax';
46
+ import type { SqlModelStorage, SqlNamespaceTablesInput } from '@prisma-next/sql-contract/types';
44
47
  import {
45
48
  buildSqlContractFromDefinition,
49
+ type EnumTypeHandle,
46
50
  type FieldNode,
47
51
  type ForeignKeyNode,
48
52
  type IndexNode,
@@ -51,9 +55,11 @@ import {
51
55
  type RelationNode,
52
56
  type UniqueConstraintNode,
53
57
  } from '@prisma-next/sql-contract-ts/contract-builder';
58
+ import { invariant } from '@prisma-next/utils/assertions';
54
59
  import { blindCast } from '@prisma-next/utils/casts';
55
60
  import { ifDefined } from '@prisma-next/utils/defined';
56
61
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
62
+
57
63
  import {
58
64
  findDuplicateFieldName,
59
65
  getAttribute,
@@ -63,7 +69,6 @@ import {
63
69
  parseAttributeFieldList,
64
70
  parseConstraintMapArgument,
65
71
  parseControlPolicyAttribute,
66
- parseMapName,
67
72
  parseObjectLiteralStringMap,
68
73
  parseQuotedStringLiteral,
69
74
  } from './psl-attribute-parsing';
@@ -71,12 +76,8 @@ import type { ColumnDescriptor } from './psl-column-resolution';
71
76
  import {
72
77
  checkUncomposedNamespace,
73
78
  getAuthoringEntity,
74
- instantiatePslTypeConstructor,
75
79
  reportUncomposedNamespace,
76
- resolveDbNativeTypeAttribute,
77
80
  resolveFieldTypeDescriptor,
78
- resolvePslTypeConstructorDescriptor,
79
- toNamedTypeFieldDescriptor,
80
81
  } from './psl-column-resolution';
81
82
  import {
82
83
  buildModelMappings,
@@ -86,6 +87,7 @@ import {
86
87
  modelCoordinateKey,
87
88
  type ResolvedField,
88
89
  } from './psl-field-resolution';
90
+ import { resolveNamedTypeDeclarations } from './psl-named-type-resolution';
89
91
  import {
90
92
  applyBackrelationCandidates,
91
93
  type FkRelationMetadata,
@@ -96,8 +98,12 @@ import {
96
98
  validateNavigationListFieldAttributes,
97
99
  } from './psl-relation-resolution';
98
100
 
101
+ type NamedTypeSymbol = ScalarSymbol | TypeAliasSymbol;
102
+
99
103
  export interface InterpretPslDocumentToSqlContractInput {
100
- readonly document: ParsePslDocumentResult;
104
+ readonly symbolTable: SymbolTable;
105
+ readonly sourceFile: SourceFile;
106
+ readonly sourceId: string;
101
107
  readonly target: TargetPackRef<'sql', string>;
102
108
  readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
103
109
  readonly composedExtensionPacks?: readonly string[];
@@ -124,6 +130,8 @@ export interface InterpretPslDocumentToSqlContractInput {
124
130
  * `SqlUnboundNamespace` singleton.
125
131
  */
126
132
  readonly createNamespace?: (input: SqlNamespaceTablesInput) => Namespace;
133
+ readonly codecLookup?: CodecLookup;
134
+ readonly seedDiagnostics?: readonly ContractSourceDiagnostic[];
127
135
  }
128
136
 
129
137
  function buildComposedExtensionPackRefs(
@@ -152,27 +160,6 @@ function buildComposedExtensionPackRefs(
152
160
  );
153
161
  }
154
162
 
155
- function diagnosticDedupKey(diagnostic: ContractSourceDiagnostic): string {
156
- const span = diagnostic.span;
157
- const spanKey = span
158
- ? `${span.start.offset}:${span.end.offset}:${span.start.line}:${span.end.line}`
159
- : '';
160
- return `${diagnostic.code}\u0000${diagnostic.sourceId}\u0000${spanKey}\u0000${diagnostic.message}`;
161
- }
162
-
163
- function dedupeDiagnostics(
164
- diagnostics: readonly ContractSourceDiagnostic[],
165
- ): ContractSourceDiagnostic[] {
166
- const seen = new Map<string, ContractSourceDiagnostic>();
167
- for (const diagnostic of diagnostics) {
168
- const key = diagnosticDedupKey(diagnostic);
169
- if (!seen.has(key)) {
170
- seen.set(key, diagnostic);
171
- }
172
- }
173
- return [...seen.values()];
174
- }
175
-
176
163
  function compareStrings(left: string, right: string): -1 | 0 | 1 {
177
164
  if (left < right) {
178
165
  return -1;
@@ -183,15 +170,6 @@ function compareStrings(left: string, right: string): -1 | 0 | 1 {
183
170
  return 0;
184
171
  }
185
172
 
186
- function mapParserDiagnostics(document: ParsePslDocumentResult): ContractSourceDiagnostic[] {
187
- return document.diagnostics.map((diagnostic) => ({
188
- code: diagnostic.code,
189
- message: diagnostic.message,
190
- sourceId: diagnostic.sourceId,
191
- span: diagnostic.span,
192
- }));
193
- }
194
-
195
173
  /**
196
174
  * Name of the framework-parser synthesised bucket for top-level
197
175
  * declarations. Re-declared here so the per-target dispatch does not
@@ -263,337 +241,113 @@ function resolveNamespaceIdForSqlTarget(input: {
263
241
  }
264
242
 
265
243
  function validateNamespaceBlocksForSqlTarget(input: {
266
- readonly namespaces: readonly PslNamespace[];
244
+ readonly namespaces: readonly NamespaceSymbol[];
267
245
  readonly targetId: string;
268
246
  readonly sourceId: string;
247
+ readonly sourceFile: SourceFile;
269
248
  readonly diagnostics: ContractSourceDiagnostic[];
270
249
  }): void {
271
250
  if (input.targetId === 'sqlite') {
272
251
  for (const namespace of input.namespaces) {
273
- if (namespace.name === UNSPECIFIED_PSL_NAMESPACE_NAME) {
274
- continue;
275
- }
276
252
  input.diagnostics.push({
277
253
  code: 'PSL_UNSUPPORTED_NAMESPACE_BLOCK',
278
254
  message: `SQLite does not support \`namespace ${namespace.name} { … }\` blocks (SQLite has no schema concept; declare models at the document top level instead).`,
279
255
  sourceId: input.sourceId,
280
- span: namespace.span,
256
+ span: nodePslSpan(namespace.node.syntax, input.sourceFile),
281
257
  });
282
258
  }
283
259
  return;
284
260
  }
285
261
 
286
262
  if (input.targetId === 'postgres') {
287
- const namedBlocks = input.namespaces.filter((ns) => ns.name !== UNSPECIFIED_PSL_NAMESPACE_NAME);
288
- const hasUnbound = namedBlocks.some((ns) => ns.name === 'unbound');
289
- const hasSibling = namedBlocks.some((ns) => ns.name !== 'unbound');
263
+ const hasUnbound = input.namespaces.some((ns) => ns.name === 'unbound');
264
+ const hasSibling = input.namespaces.some((ns) => ns.name !== 'unbound');
290
265
  if (hasUnbound && hasSibling) {
291
- const unboundBlock = namedBlocks.find((ns) => ns.name === 'unbound');
266
+ const unboundBlock = input.namespaces.find((ns) => ns.name === 'unbound');
292
267
  input.diagnostics.push({
293
268
  code: 'PSL_RESERVED_NAMESPACE_NAME',
294
269
  message:
295
270
  'Namespace "unbound" is reserved for the late-binding sentinel mapping and cannot appear alongside other named namespace blocks. ' +
296
271
  'Use `namespace unbound { … }` alone (no sibling named namespaces) for late-binding multi-tenant contracts.',
297
272
  sourceId: input.sourceId,
298
- ...ifDefined('span', unboundBlock?.span),
273
+ ...(unboundBlock !== undefined
274
+ ? { span: nodePslSpan(unboundBlock.node.syntax, input.sourceFile) }
275
+ : {}),
299
276
  });
300
277
  }
301
278
  }
302
279
  }
303
280
 
304
281
  interface ProcessEnumDeclarationsInput {
305
- readonly enums: readonly PslEnum[];
282
+ readonly enumBlocks: readonly PslExtensionBlock[];
306
283
  readonly sourceId: string;
307
- readonly enumEntityDescriptor: AuthoringEntityTypeDescriptor | undefined;
284
+ readonly authoringContributions: AuthoringContributions | undefined;
308
285
  readonly entityContext: AuthoringEntityContext;
309
286
  readonly diagnostics: ContractSourceDiagnostic[];
310
287
  }
311
288
 
312
289
  function processEnumDeclarations(input: ProcessEnumDeclarationsInput): {
313
- readonly storageTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry>;
290
+ readonly enumHandles: Record<string, EnumTypeHandle>;
314
291
  readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
315
292
  } {
316
- const storageTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry> = {};
293
+ const enumHandles: Record<string, EnumTypeHandle> = {};
317
294
  const enumTypeDescriptors = new Map<string, ColumnDescriptor>();
318
295
 
319
- if (input.enums.length === 0) {
320
- return { storageTypes, enumTypeDescriptors };
296
+ if (input.enumBlocks.length === 0) {
297
+ return { enumHandles, enumTypeDescriptors };
321
298
  }
322
299
 
323
- if (!input.enumEntityDescriptor) {
324
- // The PSL `enum X { … }` syntax only resolves when the active
325
- // pack composition contributes an `enum` entity-type factory (the
326
- // Postgres target pack does so today via
327
- // `authoring.entityTypes.enum`). Without the contribution we
328
- // surface a diagnostic per declaration rather than silently
329
- // swallowing the syntax.
330
- for (const enumDeclaration of input.enums) {
300
+ const enumDescriptor = getAuthoringEntity(input.authoringContributions, ['enum']);
301
+ if (!enumDescriptor) {
302
+ for (const decl of input.enumBlocks) {
331
303
  input.diagnostics.push({
332
- code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
333
- message: `Enum "${enumDeclaration.name}" requires the active target pack to contribute an enum entity-type helper`,
304
+ code: 'PSL_ENUM_MISSING_FACTORY',
305
+ message: `enum "${decl.name}" requires an "enum" entityType factory in the active authoring contributions`,
334
306
  sourceId: input.sourceId,
335
- span: enumDeclaration.span,
307
+ span: decl.span,
336
308
  });
337
309
  }
338
- return { storageTypes, enumTypeDescriptors };
310
+ return { enumHandles, enumTypeDescriptors };
339
311
  }
340
312
 
341
- for (const enumDeclaration of input.enums) {
342
- const nativeType = parseMapName({
343
- attribute: getAttribute(enumDeclaration.attributes, 'map'),
344
- defaultValue: enumDeclaration.name,
345
- sourceId: input.sourceId,
346
- diagnostics: input.diagnostics,
347
- entityLabel: `Enum "${enumDeclaration.name}"`,
348
- span: enumDeclaration.span,
349
- });
350
- const values = enumDeclaration.values.map((value) => value.name);
351
- const constructed = instantiateAuthoringEntityType(
313
+ for (const decl of input.enumBlocks) {
314
+ const handle = instantiateAuthoringEntityType(
352
315
  'enum',
353
- input.enumEntityDescriptor,
354
- [{ name: enumDeclaration.name, nativeType, values }],
316
+ enumDescriptor,
317
+ [decl],
355
318
  input.entityContext,
356
319
  );
357
- if (!isPostgresEnumStorageEntry(constructed)) {
358
- input.diagnostics.push({
359
- code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
360
- message: `Enum "${enumDeclaration.name}": enum entity-type factory must return a PostgresEnumStorageEntry-shaped value (kind: 'postgres-enum')`,
361
- sourceId: input.sourceId,
362
- span: enumDeclaration.span,
363
- });
364
- continue;
365
- }
366
- const descriptor: ColumnDescriptor = {
367
- codecId: constructed.codecId,
368
- nativeType: constructed.nativeType,
369
- typeRef: enumDeclaration.name,
370
- };
371
- enumTypeDescriptors.set(enumDeclaration.name, descriptor);
372
- storageTypes[enumDeclaration.name] = constructed;
373
- }
374
-
375
- return { storageTypes, enumTypeDescriptors };
376
- }
377
-
378
- interface ResolveNamedTypeDeclarationsInput {
379
- readonly declarations: readonly PslNamedTypeDeclaration[];
380
- readonly sourceId: string;
381
- readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
382
- readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
383
- readonly composedExtensions: ReadonlySet<string>;
384
- readonly familyId: string;
385
- readonly targetId: string;
386
- readonly authoringContributions: AuthoringContributions | undefined;
387
- readonly diagnostics: ContractSourceDiagnostic[];
388
- }
389
-
390
- function validateNamedTypeAttributes(input: {
391
- readonly declaration: PslNamedTypeDeclaration;
392
- readonly sourceId: string;
393
- readonly diagnostics: ContractSourceDiagnostic[];
394
- readonly composedExtensions: ReadonlySet<string>;
395
- readonly authoringContributions: AuthoringContributions | undefined;
396
- readonly allowDbNativeType: boolean;
397
- readonly familyId: string;
398
- readonly targetId: string;
399
- }): {
400
- readonly dbNativeTypeAttribute: PslAttribute | undefined;
401
- readonly hasUnsupportedNamedTypeAttribute: boolean;
402
- } {
403
- const dbNativeTypeAttributes = input.allowDbNativeType
404
- ? input.declaration.attributes.filter((attribute) => attribute.name.startsWith('db.'))
405
- : [];
406
- const [dbNativeTypeAttribute, ...extraDbNativeTypeAttributes] = dbNativeTypeAttributes;
407
- let hasUnsupportedNamedTypeAttribute = false;
408
-
409
- for (const extra of extraDbNativeTypeAttributes) {
410
- input.diagnostics.push({
411
- code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
412
- message: `Named type "${input.declaration.name}" can declare at most one @db.* attribute`,
413
- sourceId: input.sourceId,
414
- span: extra.span,
415
- });
416
- hasUnsupportedNamedTypeAttribute = true;
417
- }
418
320
 
419
- for (const attribute of input.declaration.attributes) {
420
- if (input.allowDbNativeType && attribute.name.startsWith('db.')) {
421
- continue;
422
- }
423
-
424
- const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
425
- familyId: input.familyId,
426
- targetId: input.targetId,
427
- authoringContributions: input.authoringContributions,
428
- });
429
- if (uncomposedNamespace) {
430
- reportUncomposedNamespace({
431
- subjectLabel: `Attribute "@${attribute.name}"`,
432
- namespace: uncomposedNamespace,
433
- sourceId: input.sourceId,
434
- span: attribute.span,
435
- diagnostics: input.diagnostics,
436
- });
437
- hasUnsupportedNamedTypeAttribute = true;
438
- continue;
439
- }
321
+ if (handle === undefined || handle === null) continue;
440
322
 
441
- input.diagnostics.push({
442
- code: 'PSL_UNSUPPORTED_NAMED_TYPE_ATTRIBUTE',
443
- message: `Named type "${input.declaration.name}" uses unsupported attribute "${attribute.name}"`,
444
- sourceId: input.sourceId,
445
- span: attribute.span,
323
+ const enumHandle = blindCast<EnumTypeHandle, 'enum factory returns EnumTypeHandle'>(handle);
324
+ enumHandles[decl.name] = enumHandle;
325
+ enumTypeDescriptors.set(decl.name, {
326
+ codecId: enumHandle.codecId,
327
+ nativeType: enumHandle.nativeType,
446
328
  });
447
- hasUnsupportedNamedTypeAttribute = true;
448
329
  }
449
330
 
450
- return { dbNativeTypeAttribute, hasUnsupportedNamedTypeAttribute };
331
+ return { enumHandles, enumTypeDescriptors };
451
332
  }
452
333
 
453
- function resolveNamedTypeDeclarations(input: ResolveNamedTypeDeclarationsInput): {
454
- readonly storageTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry>;
455
- readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
456
- } {
457
- const storageTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry> = {};
458
- const namedTypeDescriptors = new Map<string, ColumnDescriptor>();
459
-
460
- for (const declaration of input.declarations) {
461
- if (declaration.typeConstructor) {
462
- const { hasUnsupportedNamedTypeAttribute } = validateNamedTypeAttributes({
463
- declaration,
464
- sourceId: input.sourceId,
465
- diagnostics: input.diagnostics,
466
- composedExtensions: input.composedExtensions,
467
- authoringContributions: input.authoringContributions,
468
- allowDbNativeType: false,
469
- familyId: input.familyId,
470
- targetId: input.targetId,
471
- });
472
- if (hasUnsupportedNamedTypeAttribute) {
473
- continue;
474
- }
475
-
476
- const helperPath = declaration.typeConstructor.path.join('.');
477
- const typeConstructor = resolvePslTypeConstructorDescriptor({
478
- call: declaration.typeConstructor,
479
- authoringContributions: input.authoringContributions,
480
- composedExtensions: input.composedExtensions,
481
- familyId: input.familyId,
482
- targetId: input.targetId,
483
- diagnostics: input.diagnostics,
484
- sourceId: input.sourceId,
485
- unsupportedCode: 'PSL_UNSUPPORTED_NAMED_TYPE_CONSTRUCTOR',
486
- unsupportedMessage: `Named type "${declaration.name}" references unsupported constructor "${helperPath}"`,
487
- });
488
- if (!typeConstructor) {
489
- continue;
490
- }
491
-
492
- const storageType = instantiatePslTypeConstructor({
493
- call: declaration.typeConstructor,
494
- descriptor: typeConstructor,
495
- diagnostics: input.diagnostics,
496
- sourceId: input.sourceId,
497
- entityLabel: `Named type "${declaration.name}"`,
498
- });
499
- if (!storageType) {
500
- continue;
501
- }
502
-
503
- namedTypeDescriptors.set(
504
- declaration.name,
505
- toNamedTypeFieldDescriptor(declaration.name, storageType),
506
- );
507
- storageTypes[declaration.name] = {
508
- kind: 'codec-instance',
509
- codecId: storageType.codecId,
510
- nativeType: storageType.nativeType,
511
- typeParams: storageType.typeParams ?? {},
512
- };
513
- continue;
514
- }
515
-
516
- // Parser invariant: when typeConstructor is absent, baseType is defined.
517
- // The check below narrows `baseType` for TypeScript and guards against a
518
- // parser regression; it is unreachable under a correct parser.
519
- if (declaration.baseType === undefined) {
520
- input.diagnostics.push({
521
- code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
522
- message: `Named type "${declaration.name}" must declare a base type or constructor`,
523
- sourceId: input.sourceId,
524
- span: declaration.span,
525
- });
526
- continue;
527
- }
528
- const { baseType } = declaration;
529
- const baseDescriptor =
530
- input.enumTypeDescriptors.get(baseType) ?? input.scalarTypeDescriptors.get(baseType);
531
- if (!baseDescriptor) {
532
- input.diagnostics.push({
533
- code: 'PSL_UNSUPPORTED_NAMED_TYPE_BASE',
534
- message: `Named type "${declaration.name}" references unsupported base type "${baseType}"`,
535
- sourceId: input.sourceId,
536
- span: declaration.span,
537
- });
538
- continue;
539
- }
540
-
541
- const { dbNativeTypeAttribute, hasUnsupportedNamedTypeAttribute } = validateNamedTypeAttributes(
542
- {
543
- declaration,
544
- sourceId: input.sourceId,
545
- diagnostics: input.diagnostics,
546
- composedExtensions: input.composedExtensions,
547
- authoringContributions: input.authoringContributions,
548
- allowDbNativeType: true,
549
- familyId: input.familyId,
550
- targetId: input.targetId,
551
- },
552
- );
553
- if (hasUnsupportedNamedTypeAttribute) {
554
- continue;
555
- }
556
-
557
- if (dbNativeTypeAttribute) {
558
- const descriptor = resolveDbNativeTypeAttribute({
559
- attribute: dbNativeTypeAttribute,
560
- baseType,
561
- baseDescriptor,
562
- diagnostics: input.diagnostics,
563
- sourceId: input.sourceId,
564
- entityLabel: `Named type "${declaration.name}"`,
565
- });
566
- if (!descriptor) {
567
- continue;
568
- }
569
- namedTypeDescriptors.set(
570
- declaration.name,
571
- toNamedTypeFieldDescriptor(declaration.name, descriptor),
572
- );
573
- storageTypes[declaration.name] = {
574
- kind: 'codec-instance',
575
- codecId: descriptor.codecId,
576
- nativeType: descriptor.nativeType,
577
- typeParams: descriptor.typeParams ?? {},
578
- };
579
- continue;
334
+ /** Generic top-level blocks are supported only when a composed descriptor claims their keyword. */
335
+ function composedBlockKeywords(
336
+ authoringContributions: AuthoringContributions | undefined,
337
+ ): ReadonlySet<string> {
338
+ const keywords = new Set<string>();
339
+ const descriptors: AuthoringPslBlockDescriptorNamespace =
340
+ authoringContributions?.pslBlockDescriptors ?? {};
341
+ for (const [keyword, value] of Object.entries(descriptors)) {
342
+ if (isAuthoringPslBlockDescriptor(value)) {
343
+ keywords.add(keyword);
580
344
  }
581
-
582
- const descriptor = toNamedTypeFieldDescriptor(declaration.name, baseDescriptor);
583
- namedTypeDescriptors.set(declaration.name, descriptor);
584
- storageTypes[declaration.name] = {
585
- kind: 'codec-instance',
586
- codecId: baseDescriptor.codecId,
587
- nativeType: baseDescriptor.nativeType,
588
- typeParams: {},
589
- };
590
345
  }
591
-
592
- return { storageTypes, namedTypeDescriptors };
346
+ return keywords;
593
347
  }
594
348
 
595
349
  interface BuildModelNodeInput {
596
- readonly model: PslModel;
350
+ readonly model: ModelSymbol;
597
351
  readonly mapping: ModelNameMapping;
598
352
  readonly modelMappings: ReadonlyMap<string, ModelNameMapping>;
599
353
  /**
@@ -619,6 +373,7 @@ interface BuildModelNodeInput {
619
373
  readonly diagnostics: ContractSourceDiagnostic[];
620
374
  /** Resolved namespace id keyed by model name — used to stamp the target namespace on FKs. */
621
375
  readonly modelNamespaceIds: ReadonlyMap<string, string>;
376
+ readonly enumHandles?: ReadonlyMap<string, EnumTypeHandle>;
622
377
  }
623
378
 
624
379
  interface BuildModelNodeResult {
@@ -650,6 +405,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
650
405
  diagnostics,
651
406
  sourceId,
652
407
  scalarTypeDescriptors: input.scalarTypeDescriptors,
408
+ ...ifDefined('enumHandles', input.enumHandles),
653
409
  });
654
410
 
655
411
  const inlineIdFields = resolvedFields.filter((field) => field.isId);
@@ -674,7 +430,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
674
430
  let controlPolicy: ControlPolicy | undefined;
675
431
 
676
432
  const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
677
- for (const field of model.fields) {
433
+ for (const field of Object.values(model.fields)) {
678
434
  if (!field.list || !input.modelNames.has(field.typeName)) {
679
435
  continue;
680
436
  }
@@ -734,12 +490,12 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
734
490
  });
735
491
  }
736
492
 
737
- const relationAttributes = model.fields
493
+ const relationAttributes = Object.values(model.fields)
738
494
  .map((field) => ({
739
495
  field,
740
496
  relation: getAttribute(field.attributes, 'relation'),
741
497
  }))
742
- .filter((entry): entry is { field: PslField; relation: PslAttribute } =>
498
+ .filter((entry): entry is { field: FieldSymbol; relation: ResolvedAttribute } =>
743
499
  Boolean(entry.relation),
744
500
  );
745
501
  const uniqueConstraints: UniqueConstraintNode[] = resolvedFields
@@ -820,9 +576,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
820
576
  });
821
577
  continue;
822
578
  }
823
- const nullableFieldName = fieldNames.find(
824
- (name) => model.fields.find((f) => f.name === name)?.optional === true,
825
- );
579
+ const nullableFieldName = fieldNames.find((name) => model.fields[name]?.optional === true);
826
580
  if (nullableFieldName !== undefined) {
827
581
  diagnostics.push({
828
582
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
@@ -1307,6 +1061,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1307
1061
  declaringModelName: model.name,
1308
1062
  declaringFieldName: relationAttribute.field.name,
1309
1063
  declaringTableName: tableName,
1064
+ ...ifDefined('declaringNamespaceId', input.modelNamespaceIds.get(model.name)),
1310
1065
  targetModelName: targetMapping.model.name,
1311
1066
  targetTableName: targetMapping.tableName,
1312
1067
  ...ifDefined('targetNamespaceId', targetNamespaceId),
@@ -1320,14 +1075,18 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1320
1075
  modelNode: {
1321
1076
  modelName: model.name,
1322
1077
  tableName,
1323
- fields: resolvedFields.map((resolvedField) => ({
1324
- fieldName: resolvedField.field.name,
1325
- columnName: resolvedField.columnName,
1326
- descriptor: resolvedField.descriptor,
1327
- nullable: resolvedField.field.optional,
1328
- ...ifDefined('default', resolvedField.defaultValue),
1329
- ...ifDefined('executionDefaults', resolvedField.executionDefaults),
1330
- })),
1078
+ fields: resolvedFields.map((resolvedField) => {
1079
+ const enumHandle = input.enumHandles?.get(resolvedField.field.typeName);
1080
+ return {
1081
+ fieldName: resolvedField.field.name,
1082
+ columnName: resolvedField.columnName,
1083
+ descriptor: resolvedField.descriptor,
1084
+ nullable: resolvedField.nullable,
1085
+ ...ifDefined('default', resolvedField.defaultValue),
1086
+ ...ifDefined('executionDefaults', resolvedField.executionDefaults),
1087
+ ...ifDefined('enumTypeHandle', enumHandle),
1088
+ };
1089
+ }),
1331
1090
  ...ifDefined('id', primaryKey),
1332
1091
  ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
1333
1092
  ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
@@ -1342,7 +1101,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1342
1101
  }
1343
1102
 
1344
1103
  interface BuildValueObjectsInput {
1345
- readonly compositeTypes: readonly PslCompositeType[];
1104
+ readonly compositeTypes: readonly CompositeTypeSymbol[];
1346
1105
  readonly enumTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
1347
1106
  readonly namedTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
1348
1107
  readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
@@ -1372,7 +1131,7 @@ function buildValueObjects(input: BuildValueObjectsInput): Record<string, Contra
1372
1131
 
1373
1132
  for (const compositeType of compositeTypes) {
1374
1133
  const fields: Record<string, ContractField> = {};
1375
- for (const field of compositeType.fields) {
1134
+ for (const field of Object.values(compositeType.fields)) {
1376
1135
  if (compositeTypeNames.has(field.typeName)) {
1377
1136
  const result: ContractField = {
1378
1137
  type: { kind: 'valueObject', name: field.typeName },
@@ -1468,7 +1227,7 @@ type BaseDeclaration = {
1468
1227
  };
1469
1228
 
1470
1229
  function collectPolymorphismDeclarations(
1471
- models: readonly PslModel[],
1230
+ models: readonly ModelSymbol[],
1472
1231
  sourceId: string,
1473
1232
  diagnostics: ContractSourceDiagnostic[],
1474
1233
  ): {
@@ -1491,7 +1250,7 @@ function collectPolymorphismDeclarations(
1491
1250
  });
1492
1251
  continue;
1493
1252
  }
1494
- const discField = model.fields.find((f) => f.name === fieldName);
1253
+ const discField = model.fields[fieldName];
1495
1254
  if (discField && discField.typeName !== 'String') {
1496
1255
  diagnostics.push({
1497
1256
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
@@ -1868,7 +1627,7 @@ function stripStorageOnlyDomainFields(
1868
1627
  export function interpretPslDocumentToSqlContract(
1869
1628
  input: InterpretPslDocumentToSqlContractInput,
1870
1629
  ): Result<Contract, ContractSourceDiagnostics> {
1871
- const sourceId = input.document.ast.sourceId;
1630
+ const sourceId = input.sourceId;
1872
1631
  if (!input.target) {
1873
1632
  return notOk({
1874
1633
  summary: 'PSL to SQL contract interpretation failed',
@@ -1894,65 +1653,57 @@ export function interpretPslDocumentToSqlContract(
1894
1653
  });
1895
1654
  }
1896
1655
 
1897
- const diagnostics: ContractSourceDiagnostic[] = mapParserDiagnostics(input.document);
1656
+ const { topLevel } = input.symbolTable;
1657
+ const sourceFile = input.sourceFile;
1658
+ const namespaceSymbols = Object.values(topLevel.namespaces);
1659
+ const diagnostics: ContractSourceDiagnostic[] = [...(input.seedDiagnostics ?? [])];
1898
1660
  validateNamespaceBlocksForSqlTarget({
1899
- namespaces: input.document.ast.namespaces,
1661
+ namespaces: namespaceSymbols,
1900
1662
  targetId: input.target.targetId,
1901
1663
  sourceId,
1664
+ sourceFile,
1902
1665
  diagnostics,
1903
1666
  });
1904
- // Per-target namespace resolution: walk each AST bucket once,
1905
- // recording every model's resolved `namespaceId` for later threading
1906
- // into the `ModelNode` build. The resolution rules are target-local
1907
- // (see `resolveNamespaceIdForSqlTarget`); the flattened model list
1908
- // remains the input to the rest of the interpreter so non-namespace
1909
- // concerns stay structurally identical to before.
1910
- const models: PslModel[] = [];
1667
+ const models: ModelSymbol[] = [];
1911
1668
  const modelEntries: ModelNamespaceEntry[] = [];
1912
1669
  const modelNamespaceIds = new Map<string, string>();
1913
- for (const namespace of input.document.ast.namespaces) {
1670
+ const compositeTypes: CompositeTypeSymbol[] = [];
1671
+
1672
+ const collectScope = (
1673
+ bucketName: string,
1674
+ scopeModels: Iterable<ModelSymbol>,
1675
+ scopeCompositeTypes: Iterable<CompositeTypeSymbol>,
1676
+ ): void => {
1914
1677
  const resolvedNamespaceId = resolveNamespaceIdForSqlTarget({
1915
- bucketName: namespace.name,
1678
+ bucketName,
1916
1679
  targetId: input.target.targetId,
1917
1680
  });
1918
- for (const model of namespace.models) {
1681
+ for (const model of scopeModels) {
1919
1682
  models.push(model);
1920
1683
  modelEntries.push({ model, namespaceId: resolvedNamespaceId });
1921
1684
  if (resolvedNamespaceId !== undefined) {
1922
1685
  modelNamespaceIds.set(model.name, resolvedNamespaceId);
1923
1686
  }
1924
1687
  }
1925
- }
1926
- const defaultNamespaceId = input.target.defaultNamespaceId;
1927
- // Top-level enums (the __unspecified__ bucket) route to `storageTypes`;
1928
- // enums inside a named namespace block route to `namespaceTypes[nsId]`.
1929
- const topLevelEnums = input.document.ast.namespaces
1930
- .filter((ns) => ns.name === UNSPECIFIED_PSL_NAMESPACE_NAME)
1931
- .flatMap((ns) => ns.enums);
1932
- const namedNamespaceEnumsByNsId = new Map<string, readonly PslEnum[]>();
1933
- for (const ns of input.document.ast.namespaces) {
1934
- if (ns.name === UNSPECIFIED_PSL_NAMESPACE_NAME || ns.enums.length === 0) {
1935
- continue;
1688
+ for (const compositeType of scopeCompositeTypes) {
1689
+ compositeTypes.push(compositeType);
1936
1690
  }
1937
- const resolvedId = resolveNamespaceIdForSqlTarget({
1938
- bucketName: ns.name,
1939
- targetId: input.target.targetId,
1940
- });
1941
- if (resolvedId === undefined) {
1942
- continue;
1943
- }
1944
- // Read-then-merge so that any future change to the PSL parser (or to
1945
- // `resolveNamespaceIdForSqlTarget`) that produces two AST entries
1946
- // resolving to the same `resolvedId` would accumulate their enums
1947
- // rather than silently dropping the earlier set. Today the parser
1948
- // already merges duplicate `namespace <name> { … }` blocks into a
1949
- // single AST entry per name, so this loop sees one `ns` per
1950
- // resolvedId and the merge degrades to a plain set.
1951
- const existing = namedNamespaceEnumsByNsId.get(resolvedId) ?? [];
1952
- namedNamespaceEnumsByNsId.set(resolvedId, [...existing, ...ns.enums]);
1691
+ };
1692
+
1693
+ collectScope(
1694
+ UNSPECIFIED_PSL_NAMESPACE_NAME,
1695
+ Object.values(topLevel.models),
1696
+ Object.values(topLevel.compositeTypes),
1697
+ );
1698
+ for (const namespace of namespaceSymbols) {
1699
+ collectScope(
1700
+ namespace.name,
1701
+ Object.values(namespace.models),
1702
+ Object.values(namespace.compositeTypes),
1703
+ );
1953
1704
  }
1705
+ const defaultNamespaceId = input.target.defaultNamespaceId;
1954
1706
 
1955
- const compositeTypes = input.document.ast.namespaces.flatMap((ns) => ns.compositeTypes);
1956
1707
  const modelNames = new Set(models.map((model) => model.name));
1957
1708
  const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
1958
1709
  const composedExtensions = new Set(input.composedExtensionPacks ?? []);
@@ -1966,48 +1717,76 @@ export function interpretPslDocumentToSqlContract(
1966
1717
  generatorDescriptorById.set(descriptor.id, descriptor);
1967
1718
  }
1968
1719
 
1969
- const enumEntityDescriptor = getAuthoringEntity(input.authoringContributions, ['enum']);
1970
- const enumEntityContext = {
1971
- family: input.target.familyId,
1972
- target: input.target.targetId,
1720
+ const isEnumBlock = (block: BlockSymbol): boolean => block.keyword === 'enum';
1721
+ const legitimateBlockKeywords = composedBlockKeywords(input.authoringContributions);
1722
+ const reportUnsupportedTopLevelBlock = (block: BlockSymbol): void => {
1723
+ diagnostics.push({
1724
+ code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK',
1725
+ message: `Unsupported top-level block "${block.keyword}"`,
1726
+ sourceId,
1727
+ span: keywordPslSpan(block.node.syntax, block.keyword, sourceFile),
1728
+ });
1973
1729
  };
1974
1730
 
1731
+ const topLevelEnums = Object.values(topLevel.blocks)
1732
+ .filter((block) => {
1733
+ if (!legitimateBlockKeywords.has(block.keyword)) {
1734
+ reportUnsupportedTopLevelBlock(block);
1735
+ return false;
1736
+ }
1737
+ return isEnumBlock(block);
1738
+ })
1739
+ .map((block) => block.block);
1740
+ for (const namespace of namespaceSymbols) {
1741
+ for (const block of Object.values(namespace.blocks)) {
1742
+ if (isEnumBlock(block)) {
1743
+ diagnostics.push({
1744
+ code: 'PSL_ENUM_NAMESPACE_NOT_SUPPORTED',
1745
+ message: `enum "${block.name}" inside namespace "${namespace.name}" is not supported; declare enum at the top level`,
1746
+ sourceId,
1747
+ span: nodePslSpan(block.node.syntax, sourceFile),
1748
+ });
1749
+ continue;
1750
+ }
1751
+ if (!legitimateBlockKeywords.has(block.keyword)) {
1752
+ reportUnsupportedTopLevelBlock(block);
1753
+ }
1754
+ }
1755
+ }
1756
+
1975
1757
  const enumResult = processEnumDeclarations({
1976
- enums: topLevelEnums,
1758
+ enumBlocks: topLevelEnums,
1977
1759
  sourceId,
1978
- enumEntityDescriptor,
1979
- entityContext: enumEntityContext,
1760
+ authoringContributions: input.authoringContributions,
1761
+ entityContext: {
1762
+ family: input.target.familyId,
1763
+ target: input.target.targetId,
1764
+ ...ifDefined('codecLookup', input.codecLookup),
1765
+ sourceId,
1766
+ diagnostics: {
1767
+ push: (d) => {
1768
+ diagnostics.push(
1769
+ blindCast<ContractSourceDiagnostic, 'sink diagnostics are span-compatible'>(d),
1770
+ );
1771
+ },
1772
+ },
1773
+ },
1980
1774
  diagnostics,
1981
1775
  });
1982
1776
 
1983
- // Process enums declared in named namespace blocks and collect them into
1984
- // `namespaceTypes` keyed by the resolved namespace id.
1985
1777
  const allEnumTypeDescriptors = new Map(enumResult.enumTypeDescriptors);
1986
- const namespaceEnumStorageTypes: Record<string, Record<string, PostgresEnumStorageEntry>> = {};
1987
- for (const [nsId, nsEnums] of namedNamespaceEnumsByNsId) {
1988
- const nsEnumResult = processEnumDeclarations({
1989
- enums: nsEnums,
1990
- sourceId,
1991
- enumEntityDescriptor,
1992
- entityContext: enumEntityContext,
1993
- diagnostics,
1994
- });
1995
- for (const [name, descriptor] of nsEnumResult.enumTypeDescriptors) {
1996
- allEnumTypeDescriptors.set(name, descriptor);
1997
- }
1998
- const nsEntries: Record<string, PostgresEnumStorageEntry> = {};
1999
- for (const [name, entry] of Object.entries(nsEnumResult.storageTypes)) {
2000
- if (isPostgresEnumStorageEntry(entry)) {
2001
- nsEntries[name] = entry;
2002
- }
2003
- }
2004
- if (Object.keys(nsEntries).length > 0) {
2005
- namespaceEnumStorageTypes[nsId] = nsEntries;
2006
- }
2007
- }
1778
+
1779
+ const validEnumHandles: Record<string, EnumTypeHandle> = { ...enumResult.enumHandles };
1780
+
1781
+ const enumHandlesByName = new Map(Object.entries(validEnumHandles));
1782
+
1783
+ const namedTypeSymbols: readonly NamedTypeSymbol[] = [
1784
+ ...Object.values(topLevel.scalars),
1785
+ ...Object.values(topLevel.typeAliases),
1786
+ ];
2008
1787
 
2009
1788
  const namedTypeResult = resolveNamedTypeDeclarations({
2010
- declarations: input.document.ast.types?.declarations ?? [],
1789
+ declarations: namedTypeSymbols,
2011
1790
  sourceId,
2012
1791
  enumTypeDescriptors: allEnumTypeDescriptors,
2013
1792
  scalarTypeDescriptors: input.scalarTypeDescriptors,
@@ -2018,7 +1797,7 @@ export function interpretPslDocumentToSqlContract(
2018
1797
  diagnostics,
2019
1798
  });
2020
1799
 
2021
- const storageTypes = { ...enumResult.storageTypes, ...namedTypeResult.storageTypes };
1800
+ const storageTypes = { ...namedTypeResult.storageTypes };
2022
1801
 
2023
1802
  const modelMappingsByCoordinate = buildModelMappings(
2024
1803
  modelEntries,
@@ -2068,6 +1847,7 @@ export function interpretPslDocumentToSqlContract(
2068
1847
  sourceId,
2069
1848
  diagnostics,
2070
1849
  modelNamespaceIds,
1850
+ ...(enumHandlesByName.size > 0 ? { enumHandles: enumHandlesByName } : {}),
2071
1851
  });
2072
1852
  modelNodes.push(
2073
1853
  namespaceId !== undefined ? { ...result.modelNode, namespaceId } : result.modelNode,
@@ -2081,10 +1861,20 @@ export function interpretPslDocumentToSqlContract(
2081
1861
  }
2082
1862
  }
2083
1863
 
2084
- const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
1864
+ const { modelRelations, fkRelationsByPair, fkRelationsByDeclaringModel } = indexFkRelations({
1865
+ fkRelationMetadata,
1866
+ });
1867
+ const modelIdColumns = new Map<string, readonly string[]>();
1868
+ for (const modelNode of modelNodes) {
1869
+ if (modelNode.id) {
1870
+ modelIdColumns.set(modelNode.modelName, modelNode.id.columns);
1871
+ }
1872
+ }
2085
1873
  applyBackrelationCandidates({
2086
1874
  backrelationCandidates,
2087
1875
  fkRelationsByPair,
1876
+ fkRelationsByDeclaringModel,
1877
+ modelIdColumns,
2088
1878
  modelRelations,
2089
1879
  diagnostics,
2090
1880
  sourceId,
@@ -2144,7 +1934,7 @@ export function interpretPslDocumentToSqlContract(
2144
1934
  if (diagnostics.length > 0) {
2145
1935
  return notOk({
2146
1936
  summary: 'PSL to SQL contract interpretation failed',
2147
- diagnostics: dedupeDiagnostics(diagnostics),
1937
+ diagnostics,
2148
1938
  });
2149
1939
  }
2150
1940
 
@@ -2159,9 +1949,7 @@ export function interpretPslDocumentToSqlContract(
2159
1949
  ),
2160
1950
  ),
2161
1951
  ...(Object.keys(storageTypes).length > 0 ? { storageTypes } : {}),
2162
- ...(Object.keys(namespaceEnumStorageTypes).length > 0
2163
- ? { namespaceTypes: namespaceEnumStorageTypes }
2164
- : {}),
1952
+ ...(Object.keys(validEnumHandles).length > 0 ? { enums: validEnumHandles } : {}),
2165
1953
  ...ifDefined('createNamespace', input.createNamespace),
2166
1954
  models: stiColumnModelNodes.map((model) => ({
2167
1955
  ...model,
@@ -2175,16 +1963,16 @@ export function interpretPslDocumentToSqlContract(
2175
1963
  })),
2176
1964
  });
2177
1965
 
2178
- // Keyed by `(namespaceId, modelName)` coordinate so two models that share a
2179
- // bare name across namespaces stay distinct through the patch/polymorphism
2180
- // passes; only a genuine same-namespace duplicate is an error.
1966
+ // Include namespace in patch keys so same bare model names across namespaces
1967
+ // stay distinct; same-coordinate duplicates were already collapsed first-wins.
2181
1968
  const modelsForPatch: Record<string, ContractModel> = {};
2182
1969
  for (const [namespaceId, namespaceSlice] of Object.entries(contract.domain.namespaces)) {
2183
1970
  for (const [modelName, model] of Object.entries(namespaceSlice.models)) {
2184
1971
  const coordinate = modelCoordinateKey(namespaceId, modelName);
2185
- if (Object.hasOwn(modelsForPatch, coordinate)) {
2186
- throw new Error(`duplicate model "${namespaceId}.${modelName}" during PSL interpretation`);
2187
- }
1972
+ invariant(
1973
+ !Object.hasOwn(modelsForPatch, coordinate),
1974
+ `symbol table guarantees coordinate uniqueness; duplicate model "${namespaceId}.${modelName}" reached interpretation`,
1975
+ );
2188
1976
  modelsForPatch[coordinate] = model;
2189
1977
  }
2190
1978
  }
@@ -2233,6 +2021,7 @@ export function interpretPslDocumentToSqlContract(
2233
2021
  patchedModels[modelCoordinateKey(namespaceId, modelName)] ?? model,
2234
2022
  ]),
2235
2023
  ),
2024
+ ...(namespaceSlice.enum !== undefined ? { enum: namespaceSlice.enum } : {}),
2236
2025
  ...(namespaceSlice.valueObjects !== undefined
2237
2026
  ? { valueObjects: namespaceSlice.valueObjects }
2238
2027
  : {}),