@prisma-next/sql-contract-psl 0.12.0-dev.7 → 0.12.0-dev.70

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,
@@ -97,6 +102,16 @@ export interface InterpretPslDocumentToSqlContractInput {
97
102
  readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
98
103
  readonly controlMutationDefaults?: ControlMutationDefaults;
99
104
  readonly authoringContributions?: AuthoringContributions;
105
+ /**
106
+ * Extension contracts keyed by space ID. Required for cross-space FK
107
+ * resolution. A composed space must have an entry here; if the space ID
108
+ * appears in `composedExtensionPacks` but is absent from this map, the
109
+ * interpreter emits `PSL_UNKNOWN_CONTRACT_SPACE` and fails fast — there
110
+ * is no silent fallback. If a space's contract is present but the
111
+ * referenced model or namespace is not found in it, the interpreter
112
+ * emits `PSL_UNKNOWN_CROSS_SPACE_TARGET`.
113
+ */
114
+ readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
100
115
  /**
101
116
  * Target-supplied `Namespace` factory threaded into
102
117
  * `buildSqlContractFromDefinition` for the contract's
@@ -229,10 +244,6 @@ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
229
244
  * slot empty (which means the late-bound default at the `StorageTable`
230
245
  * layer; emitted JSON omits the field).
231
246
  */
232
- function defaultSqlNamespaceIdForTarget(targetId: string): string {
233
- return targetId === 'postgres' ? 'public' : UNBOUND_NAMESPACE_ID;
234
- }
235
-
236
247
  function resolveNamespaceIdForSqlTarget(input: {
237
248
  readonly bucketName: string;
238
249
  readonly targetId: string;
@@ -588,6 +599,8 @@ interface BuildModelNodeInput {
588
599
  readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
589
600
  readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
590
601
  readonly composedExtensions: Set<string>;
602
+ /** Extension contracts keyed by space ID for cross-space FK table-name resolution. */
603
+ readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
591
604
  readonly familyId: string;
592
605
  readonly targetId: string;
593
606
  readonly authoringContributions: AuthoringContributions | undefined;
@@ -605,6 +618,8 @@ interface BuildModelNodeResult {
605
618
  readonly fkRelationMetadata: FkRelationMetadata[];
606
619
  readonly backrelationCandidates: ModelBackrelationCandidate[];
607
620
  readonly resolvedFields: readonly ResolvedField[];
621
+ /** Cross-contract-space relation nodes that bypass the local back-relation matching. */
622
+ readonly crossSpaceRelations: RelationNode[];
608
623
  }
609
624
 
610
625
  function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
@@ -647,6 +662,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
647
662
  : undefined;
648
663
  const hasInlinePrimaryKey = primaryKey !== undefined;
649
664
  let blockPrimaryKeyDeclared = false;
665
+ let controlPolicyDeclared = false;
666
+ let controlPolicy: ControlPolicy | undefined;
650
667
 
651
668
  const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
652
669
  for (const field of model.fields) {
@@ -733,6 +750,27 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
733
750
  if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
734
751
  continue;
735
752
  }
753
+ if (modelAttribute.name === 'control') {
754
+ if (controlPolicyDeclared) {
755
+ diagnostics.push({
756
+ code: 'PSL_DUPLICATE_ATTRIBUTE',
757
+ message: `\`@@control\` declared more than once on model "${model.name}".`,
758
+ sourceId,
759
+ span: modelAttribute.span,
760
+ });
761
+ continue;
762
+ }
763
+ controlPolicyDeclared = true;
764
+ const parsed = parseControlPolicyAttribute({
765
+ attribute: modelAttribute,
766
+ sourceId,
767
+ diagnostics,
768
+ });
769
+ if (parsed !== undefined) {
770
+ controlPolicy = parsed;
771
+ }
772
+ continue;
773
+ }
736
774
  const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
737
775
  if (modelAttribute.name === 'id') {
738
776
  if (blockPrimaryKeyDeclared) {
@@ -937,13 +975,183 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
937
975
  }
938
976
 
939
977
  const resultFkRelationMetadata: FkRelationMetadata[] = [];
978
+ const resultCrossSpaceRelations: RelationNode[] = [];
940
979
  for (const relationAttribute of relationAttributes) {
980
+ const {
981
+ typeName: fieldTypeName,
982
+ typeNamespaceId: fieldTypeNamespaceId,
983
+ typeContractSpaceId: fieldTypeContractSpaceId,
984
+ } = relationAttribute.field;
985
+
941
986
  if (relationAttribute.field.list) {
987
+ // F-list: cross-space list relations are explicitly unsupported (Option B does not
988
+ // navigate, so a list target makes no sense to carry). Emit a diagnostic instead of
989
+ // silently dropping the field — the author needs to know the field was ignored.
990
+ if (fieldTypeContractSpaceId !== undefined) {
991
+ diagnostics.push({
992
+ code: 'PSL_UNSUPPORTED_CROSS_SPACE_LIST',
993
+ 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.`,
994
+ sourceId,
995
+ span: relationAttribute.field.span,
996
+ });
997
+ }
998
+ continue;
999
+ }
1000
+
1001
+ // Cross-contract-space relation: the target model lives in a different contract space
1002
+ // identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
1003
+ if (fieldTypeContractSpaceId !== undefined) {
1004
+ // Fail fast if the space has no entry in composedExtensionContracts (AC5 PSL half).
1005
+ const extContractForSpace = input.composedExtensionContracts.get(fieldTypeContractSpaceId);
1006
+ if (extContractForSpace === undefined) {
1007
+ diagnostics.push({
1008
+ code: 'PSL_UNKNOWN_CONTRACT_SPACE',
1009
+ 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.`,
1010
+ sourceId,
1011
+ span: relationAttribute.field.span,
1012
+ data: { space: fieldTypeContractSpaceId, suggestedPack: fieldTypeContractSpaceId },
1013
+ });
1014
+ continue;
1015
+ }
1016
+
1017
+ const parsedRelation = parseRelationAttribute({
1018
+ attribute: relationAttribute.relation,
1019
+ modelName: model.name,
1020
+ fieldName: relationAttribute.field.name,
1021
+ sourceId,
1022
+ diagnostics,
1023
+ });
1024
+ if (!parsedRelation) {
1025
+ continue;
1026
+ }
1027
+ if (!parsedRelation.fields || !parsedRelation.references) {
1028
+ diagnostics.push({
1029
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1030
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
1031
+ sourceId,
1032
+ span: relationAttribute.relation.span,
1033
+ });
1034
+ continue;
1035
+ }
1036
+
1037
+ const localColumns = mapFieldNamesToColumns({
1038
+ modelName: model.name,
1039
+ fieldNames: parsedRelation.fields,
1040
+ mapping,
1041
+ sourceId,
1042
+ diagnostics,
1043
+ span: relationAttribute.relation.span,
1044
+ entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
1045
+ });
1046
+ if (!localColumns) {
1047
+ continue;
1048
+ }
1049
+
1050
+ // For cross-space references the `references` list provides field names from the remote
1051
+ // model. Since the interpreter has no access to the extension contract, these field names
1052
+ // are treated as column names directly (matching the TS builder's cross-space path).
1053
+ const referencedColumns = parsedRelation.references;
1054
+
1055
+ if (localColumns.length !== referencedColumns.length) {
1056
+ diagnostics.push({
1057
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1058
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
1059
+ sourceId,
1060
+ span: relationAttribute.relation.span,
1061
+ });
1062
+ continue;
1063
+ }
1064
+
1065
+ const onDelete = parsedRelation.onDelete
1066
+ ? normalizeReferentialAction({
1067
+ modelName: model.name,
1068
+ fieldName: relationAttribute.field.name,
1069
+ actionName: 'onDelete',
1070
+ actionToken: parsedRelation.onDelete,
1071
+ sourceId,
1072
+ span: relationAttribute.field.span,
1073
+ diagnostics,
1074
+ })
1075
+ : undefined;
1076
+ const onUpdate = parsedRelation.onUpdate
1077
+ ? normalizeReferentialAction({
1078
+ modelName: model.name,
1079
+ fieldName: relationAttribute.field.name,
1080
+ actionName: 'onUpdate',
1081
+ actionToken: parsedRelation.onUpdate,
1082
+ sourceId,
1083
+ span: relationAttribute.field.span,
1084
+ diagnostics,
1085
+ })
1086
+ : undefined;
1087
+
1088
+ // Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the
1089
+ // no-namespace form is used (e.g. `supabase:User` → AC3).
1090
+ const crossTargetNamespaceId = fieldTypeNamespaceId ?? '__unbound__';
1091
+
1092
+ // Target table name: resolved from the extension contract. The get() check above
1093
+ // guarantees extContractForSpace is defined here; if the model or namespace is not
1094
+ // found in it, emit PSL_UNKNOWN_CROSS_SPACE_TARGET (user typo).
1095
+ const extContract = extContractForSpace;
1096
+ const resolvedTable =
1097
+ extContract.domain.namespaces[crossTargetNamespaceId]?.models[fieldTypeName]?.storage[
1098
+ 'table'
1099
+ ];
1100
+ if (typeof resolvedTable !== 'string') {
1101
+ const availableModels =
1102
+ Object.keys(extContract.domain.namespaces[crossTargetNamespaceId]?.models ?? {}).join(
1103
+ ', ',
1104
+ ) || '(none)';
1105
+ diagnostics.push({
1106
+ code: 'PSL_UNKNOWN_CROSS_SPACE_TARGET',
1107
+ 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}`,
1108
+ sourceId,
1109
+ span: relationAttribute.field.span,
1110
+ data: {
1111
+ space: fieldTypeContractSpaceId,
1112
+ namespace: crossTargetNamespaceId,
1113
+ model: fieldTypeName,
1114
+ },
1115
+ });
1116
+ continue;
1117
+ }
1118
+ const crossTargetTableName = resolvedTable;
1119
+
1120
+ foreignKeyNodes.push({
1121
+ columns: localColumns,
1122
+ references: {
1123
+ model: fieldTypeName,
1124
+ table: crossTargetTableName,
1125
+ columns: referencedColumns,
1126
+ namespaceId: crossTargetNamespaceId,
1127
+ spaceId: fieldTypeContractSpaceId,
1128
+ },
1129
+ ...ifDefined('name', parsedRelation.constraintName),
1130
+ ...ifDefined('onDelete', onDelete),
1131
+ ...ifDefined('onUpdate', onUpdate),
1132
+ });
1133
+
1134
+ // Build the cross-space RelationNode directly (no local back-relation candidate).
1135
+ // `buildSqlContractFromDefinition` recognises `spaceId` on a RelationNode and routes it
1136
+ // through the cross-space domain-relation path (produces a non-navigable CrossReference).
1137
+ resultCrossSpaceRelations.push({
1138
+ fieldName: relationAttribute.field.name,
1139
+ toModel: fieldTypeName,
1140
+ toTable: crossTargetTableName,
1141
+ cardinality: 'N:1',
1142
+ spaceId: fieldTypeContractSpaceId,
1143
+ namespaceId: crossTargetNamespaceId,
1144
+ on: {
1145
+ parentTable: tableName,
1146
+ parentColumns: localColumns,
1147
+ childTable: crossTargetTableName,
1148
+ childColumns: referencedColumns,
1149
+ },
1150
+ });
1151
+
942
1152
  continue;
943
1153
  }
944
1154
 
945
- const { typeName: fieldTypeName, typeNamespaceId: fieldTypeNamespaceId } =
946
- relationAttribute.field;
947
1155
  const qualifiedTypeName = fieldTypeNamespaceId
948
1156
  ? `${fieldTypeNamespaceId}.${fieldTypeName}`
949
1157
  : fieldTypeName;
@@ -1103,8 +1311,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1103
1311
  ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
1104
1312
  ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
1105
1313
  ...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
1314
+ ...ifDefined('control', controlPolicy),
1106
1315
  },
1107
1316
  fkRelationMetadata: resultFkRelationMetadata,
1317
+ crossSpaceRelations: resultCrossSpaceRelations,
1108
1318
  backrelationCandidates: resultBackrelationCandidates,
1109
1319
  resolvedFields,
1110
1320
  };
@@ -1310,12 +1520,27 @@ function resolvePolymorphism(
1310
1520
  modelNames: Set<string>,
1311
1521
  modelMappings: ReadonlyMap<string, ModelNameMapping>,
1312
1522
  modelNamespaceIds: ReadonlyMap<string, string>,
1313
- targetId: string,
1523
+ defaultNamespaceId: string,
1524
+ syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
1525
+ stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
1314
1526
  sourceId: string,
1315
1527
  diagnostics: ContractSourceDiagnostic[],
1316
1528
  ): Record<string, ContractModel> {
1317
1529
  let patched = models;
1318
1530
 
1531
+ // STI variant columns were materialised onto the base storage table so the
1532
+ // variants' `storage.fields` resolve. They are storage-only on the base — the
1533
+ // domain field belongs to the variant — so strip them from the base model's
1534
+ // domain + storage field maps (the table column, built upstream, stays).
1535
+ for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
1536
+ const baseModel = patched[baseName];
1537
+ if (!baseModel || fieldNames.length === 0) continue;
1538
+ patched = {
1539
+ ...patched,
1540
+ [baseName]: stripStorageOnlyDomainFields(baseModel, fieldNames),
1541
+ };
1542
+ }
1543
+
1319
1544
  for (const [modelName, decl] of discriminatorDeclarations) {
1320
1545
  if (baseDeclarations.has(modelName)) {
1321
1546
  diagnostics.push({
@@ -1410,22 +1635,211 @@ function resolvePolymorphism(
1410
1635
  variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
1411
1636
  const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
1412
1637
 
1638
+ const patchedVariant: ContractModel = {
1639
+ ...variantModel,
1640
+ base: crossRef(
1641
+ baseDecl.baseName,
1642
+ modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
1643
+ ),
1644
+ ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
1645
+ };
1646
+
1413
1647
  patched = {
1414
1648
  ...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
- },
1649
+ [variantName]: stripStorageOnlyDomainFields(
1650
+ patchedVariant,
1651
+ syntheticPkFieldsByVariant.get(variantName) ?? [],
1652
+ ),
1423
1653
  };
1424
1654
  }
1425
1655
 
1426
1656
  return patched;
1427
1657
  }
1428
1658
 
1659
+ /**
1660
+ * Multi-table-inheritance variants (`@@base` + their own `@@map`) live in a
1661
+ * separate table from their base. The ORM joins that table to the base on the
1662
+ * shared primary key (`base.id = variant.id`), so the variant storage table
1663
+ * must carry the base PK column even though the variant domain model declares
1664
+ * only its own fields. This enriches each MTI variant's `ModelNode` with that
1665
+ * link column, a primary key on it, and a FK back to the base table.
1666
+ *
1667
+ * The link column is reported back per variant in `syntheticPkFieldsByVariant`
1668
+ * so the domain-model patch can drop it again — keeping the variant's domain
1669
+ * surface thin (its create/read inputs don't gain a redundant `id`) while the
1670
+ * storage table stays joinable. Single-table-inheritance variants (no own
1671
+ * table) are left untouched.
1672
+ */
1673
+ function materializeMtiVariantStorageLinks(
1674
+ modelNodes: readonly ModelNode[],
1675
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1676
+ stiVariantNames: ReadonlySet<string>,
1677
+ ): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map<string, readonly string[]> } {
1678
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1679
+ const syntheticPkFieldsByVariant = new Map<string, readonly string[]>();
1680
+
1681
+ const enriched = modelNodes.map((node): ModelNode => {
1682
+ const baseDecl = baseDeclarations.get(node.modelName);
1683
+ if (!baseDecl) return node;
1684
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1685
+ if (!baseNode) return node;
1686
+ // Single-table inheritance (no own `@@map`) shares the base table; it gets
1687
+ // its columns materialised onto the base instead (see
1688
+ // {@link materializeStiVariantStorageColumns}), never a link column.
1689
+ if (stiVariantNames.has(node.modelName)) return node;
1690
+ const basePrimaryKey = baseNode.id;
1691
+ if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node;
1692
+
1693
+ const existingColumns = new Set(node.fields.map((field) => field.columnName));
1694
+ const linkFields: FieldNode[] = [];
1695
+ for (const pkColumn of basePrimaryKey.columns) {
1696
+ if (existingColumns.has(pkColumn)) continue;
1697
+ const baseField = baseNode.fields.find(
1698
+ (field): field is FieldNode => 'descriptor' in field && field.columnName === pkColumn,
1699
+ );
1700
+ if (!baseField) continue;
1701
+ linkFields.push({
1702
+ fieldName: baseField.fieldName,
1703
+ columnName: pkColumn,
1704
+ descriptor: baseField.descriptor,
1705
+ nullable: false,
1706
+ });
1707
+ }
1708
+ if (linkFields.length === 0) return node;
1709
+
1710
+ syntheticPkFieldsByVariant.set(
1711
+ node.modelName,
1712
+ linkFields.map((field) => field.fieldName),
1713
+ );
1714
+
1715
+ const foreignKey: ForeignKeyNode = {
1716
+ columns: basePrimaryKey.columns,
1717
+ references: {
1718
+ model: baseNode.modelName,
1719
+ table: baseNode.tableName,
1720
+ columns: basePrimaryKey.columns,
1721
+ ...ifDefined('namespaceId', baseNode.namespaceId),
1722
+ },
1723
+ constraint: true,
1724
+ // The link columns are the variant's own primary key, which already
1725
+ // carries a unique index — a separate FK backing index would be redundant.
1726
+ index: false,
1727
+ // Deleting a base row must delete its variant extension row — classic
1728
+ // multi-table-inheritance semantics.
1729
+ onDelete: 'cascade',
1730
+ };
1731
+
1732
+ return {
1733
+ ...node,
1734
+ fields: [...linkFields, ...node.fields],
1735
+ id: { columns: basePrimaryKey.columns },
1736
+ foreignKeys: [...(node.foreignKeys ?? []), foreignKey],
1737
+ };
1738
+ });
1739
+
1740
+ return { modelNodes: enriched, syntheticPkFieldsByVariant };
1741
+ }
1742
+
1743
+ /**
1744
+ * Single-table-inheritance variants (`@@base` with no own `@@map`) share the
1745
+ * base table: `resolvePolymorphism` points the variant's `storage.table` at the
1746
+ * base, and the ORM reads variant-declared fields straight off the base table.
1747
+ * For that to validate and round-trip, the base storage table must physically
1748
+ * carry every STI variant's declared columns. This enriches the base
1749
+ * `ModelNode` with those columns.
1750
+ *
1751
+ * The materialised columns are always nullable in storage: the base table hosts
1752
+ * every variant's rows, so a column a variant declares as required is still
1753
+ * NULL on sibling-variant rows. The variant's domain field keeps its declared
1754
+ * nullability — required-in-domain / nullable-in-storage is the intended STI
1755
+ * shape.
1756
+ *
1757
+ * Collisions (two variants declaring the same column, or a variant column name
1758
+ * clashing with a base column) are resolved skip-if-exists here, mirroring the
1759
+ * MTI link guard; surfacing them as diagnostics is tracked separately
1760
+ * (TML-2827).
1761
+ */
1762
+ function materializeStiVariantStorageColumns(
1763
+ modelNodes: readonly ModelNode[],
1764
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1765
+ stiVariantNames: ReadonlySet<string>,
1766
+ ): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map<string, readonly string[]> } {
1767
+ if (stiVariantNames.size === 0) {
1768
+ return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() };
1769
+ }
1770
+
1771
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1772
+ type StiColumn = ModelNode['fields'][number];
1773
+ const stiColumnsByBase = new Map<string, StiColumn[]>();
1774
+
1775
+ for (const variantName of stiVariantNames) {
1776
+ const variantNode = nodeByModel.get(variantName);
1777
+ const baseDecl = baseDeclarations.get(variantName);
1778
+ if (!variantNode || !baseDecl) continue;
1779
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1780
+ if (!baseNode) continue;
1781
+
1782
+ const baseColumns = new Set(baseNode.fields.map((field) => field.columnName));
1783
+ const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? [];
1784
+ const claimedColumns = new Set(claimed.map((field) => field.columnName));
1785
+
1786
+ for (const field of variantNode.fields) {
1787
+ if (baseColumns.has(field.columnName) || claimedColumns.has(field.columnName)) {
1788
+ continue;
1789
+ }
1790
+ claimedColumns.add(field.columnName);
1791
+ claimed.push({ ...field, nullable: true });
1792
+ }
1793
+ stiColumnsByBase.set(baseDecl.baseName, claimed);
1794
+ }
1795
+
1796
+ // The materialised columns exist on the base STORAGE table so the variants'
1797
+ // `storage.fields` resolve, but they are NOT base DOMAIN fields — `severity`
1798
+ // belongs to `Bug`, not to `Task`. Report the materialised field names per
1799
+ // base so the domain patch can strip them from the base model (the table
1800
+ // column stays); this is the STI analogue of `syntheticPkFieldsByVariant`.
1801
+ const stiBaseFieldsByBase = new Map<string, readonly string[]>();
1802
+ for (const [baseName, columns] of stiColumnsByBase) {
1803
+ stiBaseFieldsByBase.set(
1804
+ baseName,
1805
+ columns.map((field) => field.fieldName),
1806
+ );
1807
+ }
1808
+
1809
+ const enriched = modelNodes.map((node): ModelNode => {
1810
+ // STI variant: contributes a domain model but no storage table of its own.
1811
+ if (stiVariantNames.has(node.modelName)) {
1812
+ return { ...node, sharesBaseTable: true };
1813
+ }
1814
+ const stiColumns = stiColumnsByBase.get(node.modelName);
1815
+ if (!stiColumns || stiColumns.length === 0) return node;
1816
+ return { ...node, fields: [...node.fields, ...stiColumns] };
1817
+ });
1818
+
1819
+ return { modelNodes: enriched, stiBaseFieldsByBase };
1820
+ }
1821
+
1822
+ /**
1823
+ * Drop the storage-only link fields (added by
1824
+ * {@link materializeMtiVariantStorageLinks}) from a variant's domain model, so
1825
+ * the domain surface stays thin while the storage table keeps the link column.
1826
+ */
1827
+ function stripStorageOnlyDomainFields(
1828
+ model: ContractModel,
1829
+ fieldNames: readonly string[],
1830
+ ): ContractModel {
1831
+ if (fieldNames.length === 0) return model;
1832
+ const fields = { ...model.fields };
1833
+ for (const name of fieldNames) delete fields[name];
1834
+ const storage = blindCast<
1835
+ SqlModelStorage,
1836
+ 'SQL interpreter domain models always carry SqlModelStorage'
1837
+ >(model.storage);
1838
+ const storageFields = { ...storage.fields };
1839
+ for (const name of fieldNames) delete storageFields[name];
1840
+ return { ...model, fields, storage: { ...storage, fields: storageFields } };
1841
+ }
1842
+
1429
1843
  export function interpretPslDocumentToSqlContract(
1430
1844
  input: InterpretPslDocumentToSqlContractInput,
1431
1845
  ): Result<Contract, ContractSourceDiagnostics> {
@@ -1514,6 +1928,8 @@ export function interpretPslDocumentToSqlContract(
1514
1928
  const modelNames = new Set(models.map((model) => model.name));
1515
1929
  const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
1516
1930
  const composedExtensions = new Set(input.composedExtensionPacks ?? []);
1931
+ const composedExtensionContracts: ReadonlyMap<string, Contract> =
1932
+ input.composedExtensionContracts;
1517
1933
  const defaultFunctionRegistry: ControlMutationDefaultRegistry =
1518
1934
  input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map();
1519
1935
  const generatorDescriptors = input.controlMutationDefaults?.generatorDescriptors ?? [];
@@ -1581,6 +1997,9 @@ export function interpretPslDocumentToSqlContract(
1581
1997
  const fkRelationMetadata: FkRelationMetadata[] = [];
1582
1998
  const backrelationCandidates: ModelBackrelationCandidate[] = [];
1583
1999
  const modelResolvedFields = new Map<string, readonly ResolvedField[]>();
2000
+ // Cross-space relation nodes keyed by declaring model name — merged into
2001
+ // modelRelations after local back-relation matching so they bypass that step.
2002
+ const crossSpaceRelationsByModel = new Map<string, RelationNode[]>();
1584
2003
 
1585
2004
  for (const model of models) {
1586
2005
  const mapping = modelMappings.get(model.name);
@@ -1596,6 +2015,7 @@ export function interpretPslDocumentToSqlContract(
1596
2015
  enumTypeDescriptors: allEnumTypeDescriptors,
1597
2016
  namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
1598
2017
  composedExtensions,
2018
+ composedExtensionContracts,
1599
2019
  familyId: input.target.familyId,
1600
2020
  targetId: input.target.targetId,
1601
2021
  authoringContributions: input.authoringContributions,
@@ -1615,6 +2035,10 @@ export function interpretPslDocumentToSqlContract(
1615
2035
  fkRelationMetadata.push(...result.fkRelationMetadata);
1616
2036
  backrelationCandidates.push(...result.backrelationCandidates);
1617
2037
  modelResolvedFields.set(model.name, result.resolvedFields);
2038
+ if (result.crossSpaceRelations.length > 0) {
2039
+ const existing = crossSpaceRelationsByModel.get(model.name) ?? [];
2040
+ crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]);
2041
+ }
1618
2042
  }
1619
2043
 
1620
2044
  const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
@@ -1626,12 +2050,44 @@ export function interpretPslDocumentToSqlContract(
1626
2050
  sourceId,
1627
2051
  });
1628
2052
 
2053
+ // Merge cross-space relations into modelRelations after local back-relation matching.
2054
+ // Cross-space targets have no local back-relation candidates, so they bypass that step.
2055
+ for (const [modelName, relations] of crossSpaceRelationsByModel) {
2056
+ const existing = modelRelations.get(modelName);
2057
+ if (existing) {
2058
+ existing.push(...relations);
2059
+ } else {
2060
+ modelRelations.set(modelName, [...relations]);
2061
+ }
2062
+ }
2063
+
1629
2064
  const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
1630
2065
  models,
1631
2066
  sourceId,
1632
2067
  diagnostics,
1633
2068
  );
1634
2069
 
2070
+ // A variant with `@@base` but no own `@@map` is single-table inheritance:
2071
+ // it shares the base table. (`@@map` ⇒ multi-table inheritance.) This is the
2072
+ // authoritative STI/MTI signal — the variant's resolved table name is not,
2073
+ // because a no-`@@map` STI variant still gets a `lowerFirst(name)` default
2074
+ // table name that differs from the base before `resolvePolymorphism` rewrites
2075
+ // it onto the base table.
2076
+ const stiVariantNames = new Set<string>();
2077
+ for (const variantName of baseDeclarations.keys()) {
2078
+ const variantMapping = modelMappings.get(variantName);
2079
+ const hasExplicitMap =
2080
+ variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
2081
+ if (!hasExplicitMap) {
2082
+ stiVariantNames.add(variantName);
2083
+ }
2084
+ }
2085
+
2086
+ const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } =
2087
+ materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames);
2088
+ const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } =
2089
+ materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames);
2090
+
1635
2091
  const valueObjects = buildValueObjects({
1636
2092
  compositeTypes,
1637
2093
  enumTypeDescriptors: allEnumTypeDescriptors,
@@ -1667,7 +2123,7 @@ export function interpretPslDocumentToSqlContract(
1667
2123
  ? { namespaceTypes: namespaceEnumStorageTypes }
1668
2124
  : {}),
1669
2125
  ...ifDefined('createNamespace', input.createNamespace),
1670
- models: modelNodes.map((model) => ({
2126
+ models: stiColumnModelNodes.map((model) => ({
1671
2127
  ...model,
1672
2128
  ...(modelRelations.has(model.modelName)
1673
2129
  ? {
@@ -1687,7 +2143,7 @@ export function interpretPslDocumentToSqlContract(
1687
2143
  `duplicate model name "${modelName}" across domain namespaces during PSL interpretation`,
1688
2144
  );
1689
2145
  }
1690
- modelsForPatch[modelName] = model as ContractModel;
2146
+ modelsForPatch[modelName] = model;
1691
2147
  }
1692
2148
  }
1693
2149
  let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
@@ -1700,7 +2156,9 @@ export function interpretPslDocumentToSqlContract(
1700
2156
  modelNames,
1701
2157
  modelMappings,
1702
2158
  modelNamespaceIds,
1703
- input.target.targetId,
2159
+ input.target.defaultNamespaceId,
2160
+ syntheticPkFieldsByVariant,
2161
+ stiBaseFieldsByBase,
1704
2162
  sourceId,
1705
2163
  polyDiagnostics,
1706
2164
  );
@@ -1736,7 +2194,7 @@ export function interpretPslDocumentToSqlContract(
1736
2194
  ...(namespaceSlice.valueObjects !== undefined
1737
2195
  ? { valueObjects: namespaceSlice.valueObjects }
1738
2196
  : {}),
1739
- ...(namespaceId === defaultSqlNamespaceIdForTarget(input.target.targetId) &&
2197
+ ...(namespaceId === input.target.defaultNamespaceId &&
1740
2198
  Object.keys(valueObjects).length > 0
1741
2199
  ? { valueObjects }
1742
2200
  : {}),