@prisma-next/sql-contract-psl 0.12.0 → 0.13.0-dev.1

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.
@@ -8,6 +8,7 @@ import type {
8
8
  ContractField,
9
9
  ContractModel,
10
10
  ContractValueObject,
11
+ ControlPolicy,
11
12
  } from '@prisma-next/contract/types';
12
13
  import { crossRef } from '@prisma-next/contract/types';
13
14
  import type {
@@ -23,7 +24,6 @@ import type {
23
24
  MutationDefaultGeneratorDescriptor,
24
25
  } from '@prisma-next/framework-components/control';
25
26
  import type { Namespace } from '@prisma-next/framework-components/ir';
26
- import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
27
27
  import type {
28
28
  ParsePslDocumentResult,
29
29
  PslAttribute,
@@ -37,17 +37,21 @@ import type {
37
37
  import {
38
38
  isPostgresEnumStorageEntry,
39
39
  type PostgresEnumStorageEntry,
40
+ type SqlModelStorage,
40
41
  type SqlNamespaceTablesInput,
41
42
  type StorageTypeInstance,
42
43
  } from '@prisma-next/sql-contract/types';
43
44
  import {
44
45
  buildSqlContractFromDefinition,
46
+ type FieldNode,
45
47
  type ForeignKeyNode,
46
48
  type IndexNode,
47
49
  type ModelNode,
48
50
  type PrimaryKeyNode,
51
+ type RelationNode,
49
52
  type UniqueConstraintNode,
50
53
  } from '@prisma-next/sql-contract-ts/contract-builder';
54
+ import { blindCast } from '@prisma-next/utils/casts';
51
55
  import { ifDefined } from '@prisma-next/utils/defined';
52
56
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
53
57
  import {
@@ -58,6 +62,7 @@ import {
58
62
  mapFieldNamesToColumns,
59
63
  parseAttributeFieldList,
60
64
  parseConstraintMapArgument,
65
+ parseControlPolicyAttribute,
61
66
  parseMapName,
62
67
  parseObjectLiteralStringMap,
63
68
  parseQuotedStringLiteral,
@@ -77,6 +82,8 @@ import {
77
82
  buildModelMappings,
78
83
  collectResolvedFields,
79
84
  type ModelNameMapping,
85
+ type ModelNamespaceEntry,
86
+ modelCoordinateKey,
80
87
  type ResolvedField,
81
88
  } from './psl-field-resolution';
82
89
  import {
@@ -97,6 +104,16 @@ export interface InterpretPslDocumentToSqlContractInput {
97
104
  readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
98
105
  readonly controlMutationDefaults?: ControlMutationDefaults;
99
106
  readonly authoringContributions?: AuthoringContributions;
107
+ /**
108
+ * Extension contracts keyed by space ID. Required for cross-space FK
109
+ * resolution. A composed space must have an entry here; if the space ID
110
+ * appears in `composedExtensionPacks` but is absent from this map, the
111
+ * interpreter emits `PSL_UNKNOWN_CONTRACT_SPACE` and fails fast — there
112
+ * is no silent fallback. If a space's contract is present but the
113
+ * referenced model or namespace is not found in it, the interpreter
114
+ * emits `PSL_UNKNOWN_CROSS_SPACE_TARGET`.
115
+ */
116
+ readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
100
117
  /**
101
118
  * Target-supplied `Namespace` factory threaded into
102
119
  * `buildSqlContractFromDefinition` for the contract's
@@ -229,10 +246,6 @@ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
229
246
  * slot empty (which means the late-bound default at the `StorageTable`
230
247
  * layer; emitted JSON omits the field).
231
248
  */
232
- function defaultSqlNamespaceIdForTarget(targetId: string): string {
233
- return targetId === 'postgres' ? 'public' : UNBOUND_NAMESPACE_ID;
234
- }
235
-
236
249
  function resolveNamespaceIdForSqlTarget(input: {
237
250
  readonly bucketName: string;
238
251
  readonly targetId: string;
@@ -583,11 +596,19 @@ interface BuildModelNodeInput {
583
596
  readonly model: PslModel;
584
597
  readonly mapping: ModelNameMapping;
585
598
  readonly modelMappings: ReadonlyMap<string, ModelNameMapping>;
599
+ /**
600
+ * Model mappings keyed by `(namespaceId, modelName)` coordinate. Used to
601
+ * resolve a namespace-qualified relation target (`auth.User`) to the exact
602
+ * model even when the bare name is shared across namespaces.
603
+ */
604
+ readonly modelMappingsByCoordinate: ReadonlyMap<string, ModelNameMapping>;
586
605
  readonly modelNames: Set<string>;
587
606
  readonly compositeTypeNames: ReadonlySet<string>;
588
607
  readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
589
608
  readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
590
609
  readonly composedExtensions: Set<string>;
610
+ /** Extension contracts keyed by space ID for cross-space FK table-name resolution. */
611
+ readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
591
612
  readonly familyId: string;
592
613
  readonly targetId: string;
593
614
  readonly authoringContributions: AuthoringContributions | undefined;
@@ -605,6 +626,8 @@ interface BuildModelNodeResult {
605
626
  readonly fkRelationMetadata: FkRelationMetadata[];
606
627
  readonly backrelationCandidates: ModelBackrelationCandidate[];
607
628
  readonly resolvedFields: readonly ResolvedField[];
629
+ /** Cross-contract-space relation nodes that bypass the local back-relation matching. */
630
+ readonly crossSpaceRelations: RelationNode[];
608
631
  }
609
632
 
610
633
  function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
@@ -647,6 +670,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
647
670
  : undefined;
648
671
  const hasInlinePrimaryKey = primaryKey !== undefined;
649
672
  let blockPrimaryKeyDeclared = false;
673
+ let controlPolicyDeclared = false;
674
+ let controlPolicy: ControlPolicy | undefined;
650
675
 
651
676
  const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
652
677
  for (const field of model.fields) {
@@ -733,6 +758,27 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
733
758
  if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
734
759
  continue;
735
760
  }
761
+ if (modelAttribute.name === 'control') {
762
+ if (controlPolicyDeclared) {
763
+ diagnostics.push({
764
+ code: 'PSL_DUPLICATE_ATTRIBUTE',
765
+ message: `\`@@control\` declared more than once on model "${model.name}".`,
766
+ sourceId,
767
+ span: modelAttribute.span,
768
+ });
769
+ continue;
770
+ }
771
+ controlPolicyDeclared = true;
772
+ const parsed = parseControlPolicyAttribute({
773
+ attribute: modelAttribute,
774
+ sourceId,
775
+ diagnostics,
776
+ });
777
+ if (parsed !== undefined) {
778
+ controlPolicy = parsed;
779
+ }
780
+ continue;
781
+ }
736
782
  const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
737
783
  if (modelAttribute.name === 'id') {
738
784
  if (blockPrimaryKeyDeclared) {
@@ -937,13 +983,183 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
937
983
  }
938
984
 
939
985
  const resultFkRelationMetadata: FkRelationMetadata[] = [];
986
+ const resultCrossSpaceRelations: RelationNode[] = [];
940
987
  for (const relationAttribute of relationAttributes) {
988
+ const {
989
+ typeName: fieldTypeName,
990
+ typeNamespaceId: fieldTypeNamespaceId,
991
+ typeContractSpaceId: fieldTypeContractSpaceId,
992
+ } = relationAttribute.field;
993
+
941
994
  if (relationAttribute.field.list) {
995
+ // F-list: cross-space list relations are explicitly unsupported (Option B does not
996
+ // navigate, so a list target makes no sense to carry). Emit a diagnostic instead of
997
+ // silently dropping the field — the author needs to know the field was ignored.
998
+ if (fieldTypeContractSpaceId !== undefined) {
999
+ diagnostics.push({
1000
+ code: 'PSL_UNSUPPORTED_CROSS_SPACE_LIST',
1001
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" is a cross-space list relation (type "${fieldTypeContractSpaceId}:${fieldTypeNamespaceId !== undefined ? `${fieldTypeNamespaceId}.` : ''}${fieldTypeName}[]"). Cross-space relations must be singular in v0.1 — list cross-space relations are not supported.`,
1002
+ sourceId,
1003
+ span: relationAttribute.field.span,
1004
+ });
1005
+ }
1006
+ continue;
1007
+ }
1008
+
1009
+ // Cross-contract-space relation: the target model lives in a different contract space
1010
+ // identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
1011
+ if (fieldTypeContractSpaceId !== undefined) {
1012
+ // Fail fast if the space has no entry in composedExtensionContracts (AC5 PSL half).
1013
+ const extContractForSpace = input.composedExtensionContracts.get(fieldTypeContractSpaceId);
1014
+ if (extContractForSpace === undefined) {
1015
+ diagnostics.push({
1016
+ code: 'PSL_UNKNOWN_CONTRACT_SPACE',
1017
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references contract space "${fieldTypeContractSpaceId}" which is not declared in extensionPacks. Add "${fieldTypeContractSpaceId}" to extensionPacks in prisma-next.config.ts.`,
1018
+ sourceId,
1019
+ span: relationAttribute.field.span,
1020
+ data: { space: fieldTypeContractSpaceId, suggestedPack: fieldTypeContractSpaceId },
1021
+ });
1022
+ continue;
1023
+ }
1024
+
1025
+ const parsedRelation = parseRelationAttribute({
1026
+ attribute: relationAttribute.relation,
1027
+ modelName: model.name,
1028
+ fieldName: relationAttribute.field.name,
1029
+ sourceId,
1030
+ diagnostics,
1031
+ });
1032
+ if (!parsedRelation) {
1033
+ continue;
1034
+ }
1035
+ if (!parsedRelation.fields || !parsedRelation.references) {
1036
+ diagnostics.push({
1037
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1038
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
1039
+ sourceId,
1040
+ span: relationAttribute.relation.span,
1041
+ });
1042
+ continue;
1043
+ }
1044
+
1045
+ const localColumns = mapFieldNamesToColumns({
1046
+ modelName: model.name,
1047
+ fieldNames: parsedRelation.fields,
1048
+ mapping,
1049
+ sourceId,
1050
+ diagnostics,
1051
+ span: relationAttribute.relation.span,
1052
+ entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
1053
+ });
1054
+ if (!localColumns) {
1055
+ continue;
1056
+ }
1057
+
1058
+ // For cross-space references the `references` list provides field names from the remote
1059
+ // model. Since the interpreter has no access to the extension contract, these field names
1060
+ // are treated as column names directly (matching the TS builder's cross-space path).
1061
+ const referencedColumns = parsedRelation.references;
1062
+
1063
+ if (localColumns.length !== referencedColumns.length) {
1064
+ diagnostics.push({
1065
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1066
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
1067
+ sourceId,
1068
+ span: relationAttribute.relation.span,
1069
+ });
1070
+ continue;
1071
+ }
1072
+
1073
+ const onDelete = parsedRelation.onDelete
1074
+ ? normalizeReferentialAction({
1075
+ modelName: model.name,
1076
+ fieldName: relationAttribute.field.name,
1077
+ actionName: 'onDelete',
1078
+ actionToken: parsedRelation.onDelete,
1079
+ sourceId,
1080
+ span: relationAttribute.field.span,
1081
+ diagnostics,
1082
+ })
1083
+ : undefined;
1084
+ const onUpdate = parsedRelation.onUpdate
1085
+ ? normalizeReferentialAction({
1086
+ modelName: model.name,
1087
+ fieldName: relationAttribute.field.name,
1088
+ actionName: 'onUpdate',
1089
+ actionToken: parsedRelation.onUpdate,
1090
+ sourceId,
1091
+ span: relationAttribute.field.span,
1092
+ diagnostics,
1093
+ })
1094
+ : undefined;
1095
+
1096
+ // Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the
1097
+ // no-namespace form is used (e.g. `supabase:User` → AC3).
1098
+ const crossTargetNamespaceId = fieldTypeNamespaceId ?? '__unbound__';
1099
+
1100
+ // Target table name: resolved from the extension contract. The get() check above
1101
+ // guarantees extContractForSpace is defined here; if the model or namespace is not
1102
+ // found in it, emit PSL_UNKNOWN_CROSS_SPACE_TARGET (user typo).
1103
+ const extContract = extContractForSpace;
1104
+ const resolvedTable =
1105
+ extContract.domain.namespaces[crossTargetNamespaceId]?.models[fieldTypeName]?.storage[
1106
+ 'table'
1107
+ ];
1108
+ if (typeof resolvedTable !== 'string') {
1109
+ const availableModels =
1110
+ Object.keys(extContract.domain.namespaces[crossTargetNamespaceId]?.models ?? {}).join(
1111
+ ', ',
1112
+ ) || '(none)';
1113
+ diagnostics.push({
1114
+ code: 'PSL_UNKNOWN_CROSS_SPACE_TARGET',
1115
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references model "${fieldTypeName}" in namespace "${crossTargetNamespaceId}" of space "${fieldTypeContractSpaceId}", but that model was not found in the extension contract. Available models: ${availableModels}`,
1116
+ sourceId,
1117
+ span: relationAttribute.field.span,
1118
+ data: {
1119
+ space: fieldTypeContractSpaceId,
1120
+ namespace: crossTargetNamespaceId,
1121
+ model: fieldTypeName,
1122
+ },
1123
+ });
1124
+ continue;
1125
+ }
1126
+ const crossTargetTableName = resolvedTable;
1127
+
1128
+ foreignKeyNodes.push({
1129
+ columns: localColumns,
1130
+ references: {
1131
+ model: fieldTypeName,
1132
+ table: crossTargetTableName,
1133
+ columns: referencedColumns,
1134
+ namespaceId: crossTargetNamespaceId,
1135
+ spaceId: fieldTypeContractSpaceId,
1136
+ },
1137
+ ...ifDefined('name', parsedRelation.constraintName),
1138
+ ...ifDefined('onDelete', onDelete),
1139
+ ...ifDefined('onUpdate', onUpdate),
1140
+ });
1141
+
1142
+ // Build the cross-space RelationNode directly (no local back-relation candidate).
1143
+ // `buildSqlContractFromDefinition` recognises `spaceId` on a RelationNode and routes it
1144
+ // through the cross-space domain-relation path (produces a non-navigable CrossReference).
1145
+ resultCrossSpaceRelations.push({
1146
+ fieldName: relationAttribute.field.name,
1147
+ toModel: fieldTypeName,
1148
+ toTable: crossTargetTableName,
1149
+ cardinality: 'N:1',
1150
+ spaceId: fieldTypeContractSpaceId,
1151
+ namespaceId: crossTargetNamespaceId,
1152
+ on: {
1153
+ parentTable: tableName,
1154
+ parentColumns: localColumns,
1155
+ childTable: crossTargetTableName,
1156
+ childColumns: referencedColumns,
1157
+ },
1158
+ });
1159
+
942
1160
  continue;
943
1161
  }
944
1162
 
945
- const { typeName: fieldTypeName, typeNamespaceId: fieldTypeNamespaceId } =
946
- relationAttribute.field;
947
1163
  const qualifiedTypeName = fieldTypeNamespaceId
948
1164
  ? `${fieldTypeNamespaceId}.${fieldTypeName}`
949
1165
  : fieldTypeName;
@@ -958,19 +1174,23 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
958
1174
  continue;
959
1175
  }
960
1176
 
961
- if (fieldTypeNamespaceId !== undefined) {
962
- const resolvedTargetNamespaceId = input.modelNamespaceIds.get(fieldTypeName);
963
- const normalizedQualifier =
964
- fieldTypeNamespaceId === 'unbound' ? '__unbound__' : fieldTypeNamespaceId;
965
- if (resolvedTargetNamespaceId !== normalizedQualifier) {
966
- diagnostics.push({
967
- code: 'PSL_INVALID_RELATION_TARGET',
968
- message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
969
- sourceId,
970
- span: relationAttribute.field.span,
971
- });
972
- continue;
973
- }
1177
+ const normalizedQualifier =
1178
+ fieldTypeNamespaceId === undefined
1179
+ ? undefined
1180
+ : fieldTypeNamespaceId === 'unbound'
1181
+ ? '__unbound__'
1182
+ : fieldTypeNamespaceId;
1183
+ if (
1184
+ normalizedQualifier !== undefined &&
1185
+ !input.modelMappingsByCoordinate.has(modelCoordinateKey(normalizedQualifier, fieldTypeName))
1186
+ ) {
1187
+ diagnostics.push({
1188
+ code: 'PSL_INVALID_RELATION_TARGET',
1189
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
1190
+ sourceId,
1191
+ span: relationAttribute.field.span,
1192
+ });
1193
+ continue;
974
1194
  }
975
1195
 
976
1196
  const parsedRelation = parseRelationAttribute({
@@ -993,7 +1213,12 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
993
1213
  continue;
994
1214
  }
995
1215
 
996
- const targetMapping = input.modelMappings.get(fieldTypeName);
1216
+ const targetMapping =
1217
+ normalizedQualifier !== undefined
1218
+ ? input.modelMappingsByCoordinate.get(
1219
+ modelCoordinateKey(normalizedQualifier, fieldTypeName),
1220
+ )
1221
+ : input.modelMappings.get(fieldTypeName);
997
1222
  if (!targetMapping) {
998
1223
  diagnostics.push({
999
1224
  code: 'PSL_INVALID_RELATION_TARGET',
@@ -1061,7 +1286,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1061
1286
  })
1062
1287
  : undefined;
1063
1288
 
1064
- const targetNamespaceId = input.modelNamespaceIds.get(targetMapping.model.name);
1289
+ const targetNamespaceId =
1290
+ normalizedQualifier !== undefined
1291
+ ? normalizedQualifier
1292
+ : input.modelNamespaceIds.get(targetMapping.model.name);
1065
1293
  foreignKeyNodes.push({
1066
1294
  columns: localColumns,
1067
1295
  references: {
@@ -1081,6 +1309,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1081
1309
  declaringTableName: tableName,
1082
1310
  targetModelName: targetMapping.model.name,
1083
1311
  targetTableName: targetMapping.tableName,
1312
+ ...ifDefined('targetNamespaceId', targetNamespaceId),
1084
1313
  ...ifDefined('relationName', parsedRelation.relationName),
1085
1314
  localColumns,
1086
1315
  referencedColumns,
@@ -1103,8 +1332,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1103
1332
  ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
1104
1333
  ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
1105
1334
  ...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
1335
+ ...ifDefined('control', controlPolicy),
1106
1336
  },
1107
1337
  fkRelationMetadata: resultFkRelationMetadata,
1338
+ crossSpaceRelations: resultCrossSpaceRelations,
1108
1339
  backrelationCandidates: resultBackrelationCandidates,
1109
1340
  resolvedFields,
1110
1341
  };
@@ -1310,12 +1541,31 @@ function resolvePolymorphism(
1310
1541
  modelNames: Set<string>,
1311
1542
  modelMappings: ReadonlyMap<string, ModelNameMapping>,
1312
1543
  modelNamespaceIds: ReadonlyMap<string, string>,
1313
- targetId: string,
1544
+ defaultNamespaceId: string,
1545
+ syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
1546
+ stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
1314
1547
  sourceId: string,
1315
1548
  diagnostics: ContractSourceDiagnostic[],
1316
1549
  ): Record<string, ContractModel> {
1317
1550
  let patched = models;
1318
1551
 
1552
+ const coordinateFor = (modelName: string): string =>
1553
+ modelCoordinateKey(modelNamespaceIds.get(modelName) ?? defaultNamespaceId, modelName);
1554
+
1555
+ // STI variant columns were materialised onto the base storage table so the
1556
+ // variants' `storage.fields` resolve. They are storage-only on the base — the
1557
+ // domain field belongs to the variant — so strip them from the base model's
1558
+ // domain + storage field maps (the table column, built upstream, stays).
1559
+ for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
1560
+ const baseKey = coordinateFor(baseName);
1561
+ const baseModel = patched[baseKey];
1562
+ if (!baseModel || fieldNames.length === 0) continue;
1563
+ patched = {
1564
+ ...patched,
1565
+ [baseKey]: stripStorageOnlyDomainFields(baseModel, fieldNames),
1566
+ };
1567
+ }
1568
+
1319
1569
  for (const [modelName, decl] of discriminatorDeclarations) {
1320
1570
  if (baseDeclarations.has(modelName)) {
1321
1571
  diagnostics.push({
@@ -1327,7 +1577,7 @@ function resolvePolymorphism(
1327
1577
  continue;
1328
1578
  }
1329
1579
 
1330
- const model = patched[modelName];
1580
+ const model = patched[coordinateFor(modelName)];
1331
1581
  if (!model) continue;
1332
1582
 
1333
1583
  if (!Object.hasOwn(model.fields, decl.fieldName)) {
@@ -1372,7 +1622,7 @@ function resolvePolymorphism(
1372
1622
 
1373
1623
  patched = {
1374
1624
  ...patched,
1375
- [modelName]: { ...model, discriminator: { field: decl.fieldName }, variants },
1625
+ [coordinateFor(modelName)]: { ...model, discriminator: { field: decl.fieldName }, variants },
1376
1626
  };
1377
1627
  }
1378
1628
 
@@ -1401,7 +1651,7 @@ function resolvePolymorphism(
1401
1651
  continue;
1402
1652
  }
1403
1653
 
1404
- const variantModel = patched[variantName];
1654
+ const variantModel = patched[coordinateFor(variantName)];
1405
1655
  if (!variantModel) continue;
1406
1656
 
1407
1657
  const baseMapping = modelMappings.get(baseDecl.baseName);
@@ -1410,22 +1660,211 @@ function resolvePolymorphism(
1410
1660
  variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
1411
1661
  const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
1412
1662
 
1663
+ const patchedVariant: ContractModel = {
1664
+ ...variantModel,
1665
+ base: crossRef(
1666
+ baseDecl.baseName,
1667
+ modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
1668
+ ),
1669
+ ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
1670
+ };
1671
+
1413
1672
  patched = {
1414
1673
  ...patched,
1415
- [variantName]: {
1416
- ...variantModel,
1417
- base: crossRef(
1418
- baseDecl.baseName,
1419
- modelNamespaceIds.get(baseDecl.baseName) ?? defaultSqlNamespaceIdForTarget(targetId),
1420
- ),
1421
- ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
1422
- },
1674
+ [coordinateFor(variantName)]: stripStorageOnlyDomainFields(
1675
+ patchedVariant,
1676
+ syntheticPkFieldsByVariant.get(variantName) ?? [],
1677
+ ),
1423
1678
  };
1424
1679
  }
1425
1680
 
1426
1681
  return patched;
1427
1682
  }
1428
1683
 
1684
+ /**
1685
+ * Multi-table-inheritance variants (`@@base` + their own `@@map`) live in a
1686
+ * separate table from their base. The ORM joins that table to the base on the
1687
+ * shared primary key (`base.id = variant.id`), so the variant storage table
1688
+ * must carry the base PK column even though the variant domain model declares
1689
+ * only its own fields. This enriches each MTI variant's `ModelNode` with that
1690
+ * link column, a primary key on it, and a FK back to the base table.
1691
+ *
1692
+ * The link column is reported back per variant in `syntheticPkFieldsByVariant`
1693
+ * so the domain-model patch can drop it again — keeping the variant's domain
1694
+ * surface thin (its create/read inputs don't gain a redundant `id`) while the
1695
+ * storage table stays joinable. Single-table-inheritance variants (no own
1696
+ * table) are left untouched.
1697
+ */
1698
+ function materializeMtiVariantStorageLinks(
1699
+ modelNodes: readonly ModelNode[],
1700
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1701
+ stiVariantNames: ReadonlySet<string>,
1702
+ ): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map<string, readonly string[]> } {
1703
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1704
+ const syntheticPkFieldsByVariant = new Map<string, readonly string[]>();
1705
+
1706
+ const enriched = modelNodes.map((node): ModelNode => {
1707
+ const baseDecl = baseDeclarations.get(node.modelName);
1708
+ if (!baseDecl) return node;
1709
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1710
+ if (!baseNode) return node;
1711
+ // Single-table inheritance (no own `@@map`) shares the base table; it gets
1712
+ // its columns materialised onto the base instead (see
1713
+ // {@link materializeStiVariantStorageColumns}), never a link column.
1714
+ if (stiVariantNames.has(node.modelName)) return node;
1715
+ const basePrimaryKey = baseNode.id;
1716
+ if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node;
1717
+
1718
+ const existingColumns = new Set(node.fields.map((field) => field.columnName));
1719
+ const linkFields: FieldNode[] = [];
1720
+ for (const pkColumn of basePrimaryKey.columns) {
1721
+ if (existingColumns.has(pkColumn)) continue;
1722
+ const baseField = baseNode.fields.find(
1723
+ (field): field is FieldNode => 'descriptor' in field && field.columnName === pkColumn,
1724
+ );
1725
+ if (!baseField) continue;
1726
+ linkFields.push({
1727
+ fieldName: baseField.fieldName,
1728
+ columnName: pkColumn,
1729
+ descriptor: baseField.descriptor,
1730
+ nullable: false,
1731
+ });
1732
+ }
1733
+ if (linkFields.length === 0) return node;
1734
+
1735
+ syntheticPkFieldsByVariant.set(
1736
+ node.modelName,
1737
+ linkFields.map((field) => field.fieldName),
1738
+ );
1739
+
1740
+ const foreignKey: ForeignKeyNode = {
1741
+ columns: basePrimaryKey.columns,
1742
+ references: {
1743
+ model: baseNode.modelName,
1744
+ table: baseNode.tableName,
1745
+ columns: basePrimaryKey.columns,
1746
+ ...ifDefined('namespaceId', baseNode.namespaceId),
1747
+ },
1748
+ constraint: true,
1749
+ // The link columns are the variant's own primary key, which already
1750
+ // carries a unique index — a separate FK backing index would be redundant.
1751
+ index: false,
1752
+ // Deleting a base row must delete its variant extension row — classic
1753
+ // multi-table-inheritance semantics.
1754
+ onDelete: 'cascade',
1755
+ };
1756
+
1757
+ return {
1758
+ ...node,
1759
+ fields: [...linkFields, ...node.fields],
1760
+ id: { columns: basePrimaryKey.columns },
1761
+ foreignKeys: [...(node.foreignKeys ?? []), foreignKey],
1762
+ };
1763
+ });
1764
+
1765
+ return { modelNodes: enriched, syntheticPkFieldsByVariant };
1766
+ }
1767
+
1768
+ /**
1769
+ * Single-table-inheritance variants (`@@base` with no own `@@map`) share the
1770
+ * base table: `resolvePolymorphism` points the variant's `storage.table` at the
1771
+ * base, and the ORM reads variant-declared fields straight off the base table.
1772
+ * For that to validate and round-trip, the base storage table must physically
1773
+ * carry every STI variant's declared columns. This enriches the base
1774
+ * `ModelNode` with those columns.
1775
+ *
1776
+ * The materialised columns are always nullable in storage: the base table hosts
1777
+ * every variant's rows, so a column a variant declares as required is still
1778
+ * NULL on sibling-variant rows. The variant's domain field keeps its declared
1779
+ * nullability — required-in-domain / nullable-in-storage is the intended STI
1780
+ * shape.
1781
+ *
1782
+ * Collisions (two variants declaring the same column, or a variant column name
1783
+ * clashing with a base column) are resolved skip-if-exists here, mirroring the
1784
+ * MTI link guard; surfacing them as diagnostics is tracked separately
1785
+ * (TML-2827).
1786
+ */
1787
+ function materializeStiVariantStorageColumns(
1788
+ modelNodes: readonly ModelNode[],
1789
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1790
+ stiVariantNames: ReadonlySet<string>,
1791
+ ): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map<string, readonly string[]> } {
1792
+ if (stiVariantNames.size === 0) {
1793
+ return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() };
1794
+ }
1795
+
1796
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1797
+ type StiColumn = ModelNode['fields'][number];
1798
+ const stiColumnsByBase = new Map<string, StiColumn[]>();
1799
+
1800
+ for (const variantName of stiVariantNames) {
1801
+ const variantNode = nodeByModel.get(variantName);
1802
+ const baseDecl = baseDeclarations.get(variantName);
1803
+ if (!variantNode || !baseDecl) continue;
1804
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1805
+ if (!baseNode) continue;
1806
+
1807
+ const baseColumns = new Set(baseNode.fields.map((field) => field.columnName));
1808
+ const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? [];
1809
+ const claimedColumns = new Set(claimed.map((field) => field.columnName));
1810
+
1811
+ for (const field of variantNode.fields) {
1812
+ if (baseColumns.has(field.columnName) || claimedColumns.has(field.columnName)) {
1813
+ continue;
1814
+ }
1815
+ claimedColumns.add(field.columnName);
1816
+ claimed.push({ ...field, nullable: true });
1817
+ }
1818
+ stiColumnsByBase.set(baseDecl.baseName, claimed);
1819
+ }
1820
+
1821
+ // The materialised columns exist on the base STORAGE table so the variants'
1822
+ // `storage.fields` resolve, but they are NOT base DOMAIN fields — `severity`
1823
+ // belongs to `Bug`, not to `Task`. Report the materialised field names per
1824
+ // base so the domain patch can strip them from the base model (the table
1825
+ // column stays); this is the STI analogue of `syntheticPkFieldsByVariant`.
1826
+ const stiBaseFieldsByBase = new Map<string, readonly string[]>();
1827
+ for (const [baseName, columns] of stiColumnsByBase) {
1828
+ stiBaseFieldsByBase.set(
1829
+ baseName,
1830
+ columns.map((field) => field.fieldName),
1831
+ );
1832
+ }
1833
+
1834
+ const enriched = modelNodes.map((node): ModelNode => {
1835
+ // STI variant: contributes a domain model but no storage table of its own.
1836
+ if (stiVariantNames.has(node.modelName)) {
1837
+ return { ...node, sharesBaseTable: true };
1838
+ }
1839
+ const stiColumns = stiColumnsByBase.get(node.modelName);
1840
+ if (!stiColumns || stiColumns.length === 0) return node;
1841
+ return { ...node, fields: [...node.fields, ...stiColumns] };
1842
+ });
1843
+
1844
+ return { modelNodes: enriched, stiBaseFieldsByBase };
1845
+ }
1846
+
1847
+ /**
1848
+ * Drop the storage-only link fields (added by
1849
+ * {@link materializeMtiVariantStorageLinks}) from a variant's domain model, so
1850
+ * the domain surface stays thin while the storage table keeps the link column.
1851
+ */
1852
+ function stripStorageOnlyDomainFields(
1853
+ model: ContractModel,
1854
+ fieldNames: readonly string[],
1855
+ ): ContractModel {
1856
+ if (fieldNames.length === 0) return model;
1857
+ const fields = { ...model.fields };
1858
+ for (const name of fieldNames) delete fields[name];
1859
+ const storage = blindCast<
1860
+ SqlModelStorage,
1861
+ 'SQL interpreter domain models always carry SqlModelStorage'
1862
+ >(model.storage);
1863
+ const storageFields = { ...storage.fields };
1864
+ for (const name of fieldNames) delete storageFields[name];
1865
+ return { ...model, fields, storage: { ...storage, fields: storageFields } };
1866
+ }
1867
+
1429
1868
  export function interpretPslDocumentToSqlContract(
1430
1869
  input: InterpretPslDocumentToSqlContractInput,
1431
1870
  ): Result<Contract, ContractSourceDiagnostics> {
@@ -1469,6 +1908,7 @@ export function interpretPslDocumentToSqlContract(
1469
1908
  // remains the input to the rest of the interpreter so non-namespace
1470
1909
  // concerns stay structurally identical to before.
1471
1910
  const models: PslModel[] = [];
1911
+ const modelEntries: ModelNamespaceEntry[] = [];
1472
1912
  const modelNamespaceIds = new Map<string, string>();
1473
1913
  for (const namespace of input.document.ast.namespaces) {
1474
1914
  const resolvedNamespaceId = resolveNamespaceIdForSqlTarget({
@@ -1477,11 +1917,13 @@ export function interpretPslDocumentToSqlContract(
1477
1917
  });
1478
1918
  for (const model of namespace.models) {
1479
1919
  models.push(model);
1920
+ modelEntries.push({ model, namespaceId: resolvedNamespaceId });
1480
1921
  if (resolvedNamespaceId !== undefined) {
1481
1922
  modelNamespaceIds.set(model.name, resolvedNamespaceId);
1482
1923
  }
1483
1924
  }
1484
1925
  }
1926
+ const defaultNamespaceId = input.target.defaultNamespaceId;
1485
1927
  // Top-level enums (the __unspecified__ bucket) route to `storageTypes`;
1486
1928
  // enums inside a named namespace block route to `namespaceTypes[nsId]`.
1487
1929
  const topLevelEnums = input.document.ast.namespaces
@@ -1514,6 +1956,8 @@ export function interpretPslDocumentToSqlContract(
1514
1956
  const modelNames = new Set(models.map((model) => model.name));
1515
1957
  const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
1516
1958
  const composedExtensions = new Set(input.composedExtensionPacks ?? []);
1959
+ const composedExtensionContracts: ReadonlyMap<string, Contract> =
1960
+ input.composedExtensionContracts;
1517
1961
  const defaultFunctionRegistry: ControlMutationDefaultRegistry =
1518
1962
  input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map();
1519
1963
  const generatorDescriptors = input.controlMutationDefaults?.generatorDescriptors ?? [];
@@ -1576,14 +2020,31 @@ export function interpretPslDocumentToSqlContract(
1576
2020
 
1577
2021
  const storageTypes = { ...enumResult.storageTypes, ...namedTypeResult.storageTypes };
1578
2022
 
1579
- const modelMappings = buildModelMappings(models, diagnostics, sourceId);
2023
+ const modelMappingsByCoordinate = buildModelMappings(
2024
+ modelEntries,
2025
+ defaultNamespaceId,
2026
+ diagnostics,
2027
+ sourceId,
2028
+ );
2029
+ // Bare-name view for unqualified relation targets and polymorphism, where
2030
+ // resolution is by bare model name. When a bare name is shared across
2031
+ // namespaces this collapses to the last entry; qualified relation targets
2032
+ // and per-model lowering use the coordinate-keyed map above instead.
2033
+ const modelMappings = new Map<string, ModelNameMapping>();
2034
+ for (const mapping of modelMappingsByCoordinate.values()) {
2035
+ modelMappings.set(mapping.model.name, mapping);
2036
+ }
1580
2037
  const modelNodes: ModelNode[] = [];
1581
2038
  const fkRelationMetadata: FkRelationMetadata[] = [];
1582
2039
  const backrelationCandidates: ModelBackrelationCandidate[] = [];
1583
2040
  const modelResolvedFields = new Map<string, readonly ResolvedField[]>();
2041
+ // Cross-space relation nodes keyed by declaring model name — merged into
2042
+ // modelRelations after local back-relation matching so they bypass that step.
2043
+ const crossSpaceRelationsByModel = new Map<string, RelationNode[]>();
1584
2044
 
1585
- for (const model of models) {
1586
- const mapping = modelMappings.get(model.name);
2045
+ for (const { model, namespaceId } of modelEntries) {
2046
+ const coordinate = modelCoordinateKey(namespaceId ?? defaultNamespaceId, model.name);
2047
+ const mapping = modelMappingsByCoordinate.get(coordinate);
1587
2048
  if (!mapping) {
1588
2049
  continue;
1589
2050
  }
@@ -1591,11 +2052,13 @@ export function interpretPslDocumentToSqlContract(
1591
2052
  model,
1592
2053
  mapping,
1593
2054
  modelMappings,
2055
+ modelMappingsByCoordinate,
1594
2056
  modelNames,
1595
2057
  compositeTypeNames,
1596
2058
  enumTypeDescriptors: allEnumTypeDescriptors,
1597
2059
  namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
1598
2060
  composedExtensions,
2061
+ composedExtensionContracts,
1599
2062
  familyId: input.target.familyId,
1600
2063
  targetId: input.target.targetId,
1601
2064
  authoringContributions: input.authoringContributions,
@@ -1606,15 +2069,16 @@ export function interpretPslDocumentToSqlContract(
1606
2069
  diagnostics,
1607
2070
  modelNamespaceIds,
1608
2071
  });
1609
- const resolvedNamespaceId = modelNamespaceIds.get(model.name);
1610
2072
  modelNodes.push(
1611
- resolvedNamespaceId !== undefined
1612
- ? { ...result.modelNode, namespaceId: resolvedNamespaceId }
1613
- : result.modelNode,
2073
+ namespaceId !== undefined ? { ...result.modelNode, namespaceId } : result.modelNode,
1614
2074
  );
1615
2075
  fkRelationMetadata.push(...result.fkRelationMetadata);
1616
2076
  backrelationCandidates.push(...result.backrelationCandidates);
1617
- modelResolvedFields.set(model.name, result.resolvedFields);
2077
+ modelResolvedFields.set(coordinate, result.resolvedFields);
2078
+ if (result.crossSpaceRelations.length > 0) {
2079
+ const existing = crossSpaceRelationsByModel.get(model.name) ?? [];
2080
+ crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]);
2081
+ }
1618
2082
  }
1619
2083
 
1620
2084
  const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
@@ -1626,12 +2090,44 @@ export function interpretPslDocumentToSqlContract(
1626
2090
  sourceId,
1627
2091
  });
1628
2092
 
2093
+ // Merge cross-space relations into modelRelations after local back-relation matching.
2094
+ // Cross-space targets have no local back-relation candidates, so they bypass that step.
2095
+ for (const [modelName, relations] of crossSpaceRelationsByModel) {
2096
+ const existing = modelRelations.get(modelName);
2097
+ if (existing) {
2098
+ existing.push(...relations);
2099
+ } else {
2100
+ modelRelations.set(modelName, [...relations]);
2101
+ }
2102
+ }
2103
+
1629
2104
  const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
1630
2105
  models,
1631
2106
  sourceId,
1632
2107
  diagnostics,
1633
2108
  );
1634
2109
 
2110
+ // A variant with `@@base` but no own `@@map` is single-table inheritance:
2111
+ // it shares the base table. (`@@map` ⇒ multi-table inheritance.) This is the
2112
+ // authoritative STI/MTI signal — the variant's resolved table name is not,
2113
+ // because a no-`@@map` STI variant still gets a `lowerFirst(name)` default
2114
+ // table name that differs from the base before `resolvePolymorphism` rewrites
2115
+ // it onto the base table.
2116
+ const stiVariantNames = new Set<string>();
2117
+ for (const variantName of baseDeclarations.keys()) {
2118
+ const variantMapping = modelMappings.get(variantName);
2119
+ const hasExplicitMap =
2120
+ variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
2121
+ if (!hasExplicitMap) {
2122
+ stiVariantNames.add(variantName);
2123
+ }
2124
+ }
2125
+
2126
+ const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } =
2127
+ materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames);
2128
+ const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } =
2129
+ materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames);
2130
+
1635
2131
  const valueObjects = buildValueObjects({
1636
2132
  compositeTypes,
1637
2133
  enumTypeDescriptors: allEnumTypeDescriptors,
@@ -1667,7 +2163,7 @@ export function interpretPslDocumentToSqlContract(
1667
2163
  ? { namespaceTypes: namespaceEnumStorageTypes }
1668
2164
  : {}),
1669
2165
  ...ifDefined('createNamespace', input.createNamespace),
1670
- models: modelNodes.map((model) => ({
2166
+ models: stiColumnModelNodes.map((model) => ({
1671
2167
  ...model,
1672
2168
  ...(modelRelations.has(model.modelName)
1673
2169
  ? {
@@ -1679,15 +2175,17 @@ export function interpretPslDocumentToSqlContract(
1679
2175
  })),
1680
2176
  });
1681
2177
 
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.
1682
2181
  const modelsForPatch: Record<string, ContractModel> = {};
1683
- for (const namespaceSlice of Object.values(contract.domain.namespaces)) {
2182
+ for (const [namespaceId, namespaceSlice] of Object.entries(contract.domain.namespaces)) {
1684
2183
  for (const [modelName, model] of Object.entries(namespaceSlice.models)) {
1685
- if (Object.hasOwn(modelsForPatch, modelName)) {
1686
- throw new Error(
1687
- `duplicate model name "${modelName}" across domain namespaces during PSL interpretation`,
1688
- );
2184
+ const coordinate = modelCoordinateKey(namespaceId, modelName);
2185
+ if (Object.hasOwn(modelsForPatch, coordinate)) {
2186
+ throw new Error(`duplicate model "${namespaceId}.${modelName}" during PSL interpretation`);
1689
2187
  }
1690
- modelsForPatch[modelName] = model as ContractModel;
2188
+ modelsForPatch[coordinate] = model;
1691
2189
  }
1692
2190
  }
1693
2191
  let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
@@ -1700,7 +2198,9 @@ export function interpretPslDocumentToSqlContract(
1700
2198
  modelNames,
1701
2199
  modelMappings,
1702
2200
  modelNamespaceIds,
1703
- input.target.targetId,
2201
+ input.target.defaultNamespaceId,
2202
+ syntheticPkFieldsByVariant,
2203
+ stiBaseFieldsByBase,
1704
2204
  sourceId,
1705
2205
  polyDiagnostics,
1706
2206
  );
@@ -1730,13 +2230,13 @@ export function interpretPslDocumentToSqlContract(
1730
2230
  models: Object.fromEntries(
1731
2231
  Object.entries(namespaceSlice.models).map(([modelName, model]) => [
1732
2232
  modelName,
1733
- patchedModels[modelName] ?? model,
2233
+ patchedModels[modelCoordinateKey(namespaceId, modelName)] ?? model,
1734
2234
  ]),
1735
2235
  ),
1736
2236
  ...(namespaceSlice.valueObjects !== undefined
1737
2237
  ? { valueObjects: namespaceSlice.valueObjects }
1738
2238
  : {}),
1739
- ...(namespaceId === defaultSqlNamespaceIdForTarget(input.target.targetId) &&
2239
+ ...(namespaceId === input.target.defaultNamespaceId &&
1740
2240
  Object.keys(valueObjects).length > 0
1741
2241
  ? { valueObjects }
1742
2242
  : {}),