@prisma-next/sql-contract-psl 0.12.0-dev.6 → 0.12.0-dev.60

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,
@@ -229,10 +234,6 @@ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
229
234
  * slot empty (which means the late-bound default at the `StorageTable`
230
235
  * layer; emitted JSON omits the field).
231
236
  */
232
- function defaultSqlNamespaceIdForTarget(targetId: string): string {
233
- return targetId === 'postgres' ? 'public' : UNBOUND_NAMESPACE_ID;
234
- }
235
-
236
237
  function resolveNamespaceIdForSqlTarget(input: {
237
238
  readonly bucketName: string;
238
239
  readonly targetId: string;
@@ -605,6 +606,8 @@ interface BuildModelNodeResult {
605
606
  readonly fkRelationMetadata: FkRelationMetadata[];
606
607
  readonly backrelationCandidates: ModelBackrelationCandidate[];
607
608
  readonly resolvedFields: readonly ResolvedField[];
609
+ /** Cross-contract-space relation nodes that bypass the local back-relation matching. */
610
+ readonly crossSpaceRelations: RelationNode[];
608
611
  }
609
612
 
610
613
  function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
@@ -647,6 +650,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
647
650
  : undefined;
648
651
  const hasInlinePrimaryKey = primaryKey !== undefined;
649
652
  let blockPrimaryKeyDeclared = false;
653
+ let controlPolicyDeclared = false;
654
+ let controlPolicy: ControlPolicy | undefined;
650
655
 
651
656
  const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
652
657
  for (const field of model.fields) {
@@ -733,6 +738,27 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
733
738
  if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
734
739
  continue;
735
740
  }
741
+ if (modelAttribute.name === 'control') {
742
+ if (controlPolicyDeclared) {
743
+ diagnostics.push({
744
+ code: 'PSL_DUPLICATE_ATTRIBUTE',
745
+ message: `\`@@control\` declared more than once on model "${model.name}".`,
746
+ sourceId,
747
+ span: modelAttribute.span,
748
+ });
749
+ continue;
750
+ }
751
+ controlPolicyDeclared = true;
752
+ const parsed = parseControlPolicyAttribute({
753
+ attribute: modelAttribute,
754
+ sourceId,
755
+ diagnostics,
756
+ });
757
+ if (parsed !== undefined) {
758
+ controlPolicy = parsed;
759
+ }
760
+ continue;
761
+ }
736
762
  const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
737
763
  if (modelAttribute.name === 'id') {
738
764
  if (blockPrimaryKeyDeclared) {
@@ -937,13 +963,162 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
937
963
  }
938
964
 
939
965
  const resultFkRelationMetadata: FkRelationMetadata[] = [];
966
+ const resultCrossSpaceRelations: RelationNode[] = [];
940
967
  for (const relationAttribute of relationAttributes) {
968
+ const {
969
+ typeName: fieldTypeName,
970
+ typeNamespaceId: fieldTypeNamespaceId,
971
+ typeContractSpaceId: fieldTypeContractSpaceId,
972
+ } = relationAttribute.field;
973
+
941
974
  if (relationAttribute.field.list) {
975
+ // F-list: cross-space list relations are explicitly unsupported (Option B does not
976
+ // navigate, so a list target makes no sense to carry). Emit a diagnostic instead of
977
+ // silently dropping the field — the author needs to know the field was ignored.
978
+ if (fieldTypeContractSpaceId !== undefined) {
979
+ diagnostics.push({
980
+ code: 'PSL_UNSUPPORTED_CROSS_SPACE_LIST',
981
+ 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.`,
982
+ sourceId,
983
+ span: relationAttribute.field.span,
984
+ });
985
+ }
986
+ continue;
987
+ }
988
+
989
+ // Cross-contract-space relation: the target model lives in a different contract space
990
+ // identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
991
+ if (fieldTypeContractSpaceId !== undefined) {
992
+ // Fail fast if the space is not in the composed extension packs (AC5 PSL half).
993
+ if (!input.composedExtensions.has(fieldTypeContractSpaceId)) {
994
+ diagnostics.push({
995
+ code: 'PSL_UNKNOWN_CONTRACT_SPACE',
996
+ 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.`,
997
+ sourceId,
998
+ span: relationAttribute.field.span,
999
+ data: { space: fieldTypeContractSpaceId, suggestedPack: fieldTypeContractSpaceId },
1000
+ });
1001
+ continue;
1002
+ }
1003
+
1004
+ const parsedRelation = parseRelationAttribute({
1005
+ attribute: relationAttribute.relation,
1006
+ modelName: model.name,
1007
+ fieldName: relationAttribute.field.name,
1008
+ sourceId,
1009
+ diagnostics,
1010
+ });
1011
+ if (!parsedRelation) {
1012
+ continue;
1013
+ }
1014
+ if (!parsedRelation.fields || !parsedRelation.references) {
1015
+ diagnostics.push({
1016
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1017
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
1018
+ sourceId,
1019
+ span: relationAttribute.relation.span,
1020
+ });
1021
+ continue;
1022
+ }
1023
+
1024
+ const localColumns = mapFieldNamesToColumns({
1025
+ modelName: model.name,
1026
+ fieldNames: parsedRelation.fields,
1027
+ mapping,
1028
+ sourceId,
1029
+ diagnostics,
1030
+ span: relationAttribute.relation.span,
1031
+ entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
1032
+ });
1033
+ if (!localColumns) {
1034
+ continue;
1035
+ }
1036
+
1037
+ // For cross-space references the `references` list provides field names from the remote
1038
+ // model. Since the interpreter has no access to the extension contract, these field names
1039
+ // are treated as column names directly (matching the TS builder's cross-space path).
1040
+ const referencedColumns = parsedRelation.references;
1041
+
1042
+ if (localColumns.length !== referencedColumns.length) {
1043
+ diagnostics.push({
1044
+ code: 'PSL_INVALID_RELATION_ATTRIBUTE',
1045
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
1046
+ sourceId,
1047
+ span: relationAttribute.relation.span,
1048
+ });
1049
+ continue;
1050
+ }
1051
+
1052
+ const onDelete = parsedRelation.onDelete
1053
+ ? normalizeReferentialAction({
1054
+ modelName: model.name,
1055
+ fieldName: relationAttribute.field.name,
1056
+ actionName: 'onDelete',
1057
+ actionToken: parsedRelation.onDelete,
1058
+ sourceId,
1059
+ span: relationAttribute.field.span,
1060
+ diagnostics,
1061
+ })
1062
+ : undefined;
1063
+ const onUpdate = parsedRelation.onUpdate
1064
+ ? normalizeReferentialAction({
1065
+ modelName: model.name,
1066
+ fieldName: relationAttribute.field.name,
1067
+ actionName: 'onUpdate',
1068
+ actionToken: parsedRelation.onUpdate,
1069
+ sourceId,
1070
+ span: relationAttribute.field.span,
1071
+ diagnostics,
1072
+ })
1073
+ : undefined;
1074
+
1075
+ // Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the
1076
+ // no-namespace form is used (e.g. `supabase:User` → AC3).
1077
+ const crossTargetNamespaceId = fieldTypeNamespaceId ?? '__unbound__';
1078
+
1079
+ // Target table name: the interpreter cannot resolve `User → users` because it has no
1080
+ // access to the extension contract (only a Set<string> of space names is available).
1081
+ // Use `fieldTypeName.toLowerCase()` as a symbolic fallback — the same convention the TS
1082
+ // builder uses (`targetModelName.toLowerCase()`) when `targetTableName` is absent.
1083
+ // Physical table resolution against the extension contract is deferred to the aggregate
1084
+ // stage (M3), which has access to the full extension contract.
1085
+ const crossTargetTableName = fieldTypeName.toLowerCase();
1086
+
1087
+ foreignKeyNodes.push({
1088
+ columns: localColumns,
1089
+ references: {
1090
+ model: fieldTypeName,
1091
+ table: crossTargetTableName,
1092
+ columns: referencedColumns,
1093
+ namespaceId: crossTargetNamespaceId,
1094
+ spaceId: fieldTypeContractSpaceId,
1095
+ },
1096
+ ...ifDefined('name', parsedRelation.constraintName),
1097
+ ...ifDefined('onDelete', onDelete),
1098
+ ...ifDefined('onUpdate', onUpdate),
1099
+ });
1100
+
1101
+ // Build the cross-space RelationNode directly (no local back-relation candidate).
1102
+ // `buildSqlContractFromDefinition` recognises `spaceId` on a RelationNode and routes it
1103
+ // through the cross-space domain-relation path (produces a non-navigable CrossReference).
1104
+ resultCrossSpaceRelations.push({
1105
+ fieldName: relationAttribute.field.name,
1106
+ toModel: fieldTypeName,
1107
+ toTable: crossTargetTableName,
1108
+ cardinality: 'N:1',
1109
+ spaceId: fieldTypeContractSpaceId,
1110
+ namespaceId: crossTargetNamespaceId,
1111
+ on: {
1112
+ parentTable: tableName,
1113
+ parentColumns: localColumns,
1114
+ childTable: crossTargetTableName,
1115
+ childColumns: referencedColumns,
1116
+ },
1117
+ });
1118
+
942
1119
  continue;
943
1120
  }
944
1121
 
945
- const { typeName: fieldTypeName, typeNamespaceId: fieldTypeNamespaceId } =
946
- relationAttribute.field;
947
1122
  const qualifiedTypeName = fieldTypeNamespaceId
948
1123
  ? `${fieldTypeNamespaceId}.${fieldTypeName}`
949
1124
  : fieldTypeName;
@@ -1103,8 +1278,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1103
1278
  ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
1104
1279
  ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
1105
1280
  ...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
1281
+ ...ifDefined('control', controlPolicy),
1106
1282
  },
1107
1283
  fkRelationMetadata: resultFkRelationMetadata,
1284
+ crossSpaceRelations: resultCrossSpaceRelations,
1108
1285
  backrelationCandidates: resultBackrelationCandidates,
1109
1286
  resolvedFields,
1110
1287
  };
@@ -1310,12 +1487,27 @@ function resolvePolymorphism(
1310
1487
  modelNames: Set<string>,
1311
1488
  modelMappings: ReadonlyMap<string, ModelNameMapping>,
1312
1489
  modelNamespaceIds: ReadonlyMap<string, string>,
1313
- targetId: string,
1490
+ defaultNamespaceId: string,
1491
+ syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
1492
+ stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
1314
1493
  sourceId: string,
1315
1494
  diagnostics: ContractSourceDiagnostic[],
1316
1495
  ): Record<string, ContractModel> {
1317
1496
  let patched = models;
1318
1497
 
1498
+ // STI variant columns were materialised onto the base storage table so the
1499
+ // variants' `storage.fields` resolve. They are storage-only on the base — the
1500
+ // domain field belongs to the variant — so strip them from the base model's
1501
+ // domain + storage field maps (the table column, built upstream, stays).
1502
+ for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
1503
+ const baseModel = patched[baseName];
1504
+ if (!baseModel || fieldNames.length === 0) continue;
1505
+ patched = {
1506
+ ...patched,
1507
+ [baseName]: stripStorageOnlyDomainFields(baseModel, fieldNames),
1508
+ };
1509
+ }
1510
+
1319
1511
  for (const [modelName, decl] of discriminatorDeclarations) {
1320
1512
  if (baseDeclarations.has(modelName)) {
1321
1513
  diagnostics.push({
@@ -1410,22 +1602,211 @@ function resolvePolymorphism(
1410
1602
  variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
1411
1603
  const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
1412
1604
 
1605
+ const patchedVariant: ContractModel = {
1606
+ ...variantModel,
1607
+ base: crossRef(
1608
+ baseDecl.baseName,
1609
+ modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
1610
+ ),
1611
+ ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
1612
+ };
1613
+
1413
1614
  patched = {
1414
1615
  ...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
- },
1616
+ [variantName]: stripStorageOnlyDomainFields(
1617
+ patchedVariant,
1618
+ syntheticPkFieldsByVariant.get(variantName) ?? [],
1619
+ ),
1423
1620
  };
1424
1621
  }
1425
1622
 
1426
1623
  return patched;
1427
1624
  }
1428
1625
 
1626
+ /**
1627
+ * Multi-table-inheritance variants (`@@base` + their own `@@map`) live in a
1628
+ * separate table from their base. The ORM joins that table to the base on the
1629
+ * shared primary key (`base.id = variant.id`), so the variant storage table
1630
+ * must carry the base PK column even though the variant domain model declares
1631
+ * only its own fields. This enriches each MTI variant's `ModelNode` with that
1632
+ * link column, a primary key on it, and a FK back to the base table.
1633
+ *
1634
+ * The link column is reported back per variant in `syntheticPkFieldsByVariant`
1635
+ * so the domain-model patch can drop it again — keeping the variant's domain
1636
+ * surface thin (its create/read inputs don't gain a redundant `id`) while the
1637
+ * storage table stays joinable. Single-table-inheritance variants (no own
1638
+ * table) are left untouched.
1639
+ */
1640
+ function materializeMtiVariantStorageLinks(
1641
+ modelNodes: readonly ModelNode[],
1642
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1643
+ stiVariantNames: ReadonlySet<string>,
1644
+ ): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map<string, readonly string[]> } {
1645
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1646
+ const syntheticPkFieldsByVariant = new Map<string, readonly string[]>();
1647
+
1648
+ const enriched = modelNodes.map((node): ModelNode => {
1649
+ const baseDecl = baseDeclarations.get(node.modelName);
1650
+ if (!baseDecl) return node;
1651
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1652
+ if (!baseNode) return node;
1653
+ // Single-table inheritance (no own `@@map`) shares the base table; it gets
1654
+ // its columns materialised onto the base instead (see
1655
+ // {@link materializeStiVariantStorageColumns}), never a link column.
1656
+ if (stiVariantNames.has(node.modelName)) return node;
1657
+ const basePrimaryKey = baseNode.id;
1658
+ if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node;
1659
+
1660
+ const existingColumns = new Set(node.fields.map((field) => field.columnName));
1661
+ const linkFields: FieldNode[] = [];
1662
+ for (const pkColumn of basePrimaryKey.columns) {
1663
+ if (existingColumns.has(pkColumn)) continue;
1664
+ const baseField = baseNode.fields.find(
1665
+ (field): field is FieldNode => 'descriptor' in field && field.columnName === pkColumn,
1666
+ );
1667
+ if (!baseField) continue;
1668
+ linkFields.push({
1669
+ fieldName: baseField.fieldName,
1670
+ columnName: pkColumn,
1671
+ descriptor: baseField.descriptor,
1672
+ nullable: false,
1673
+ });
1674
+ }
1675
+ if (linkFields.length === 0) return node;
1676
+
1677
+ syntheticPkFieldsByVariant.set(
1678
+ node.modelName,
1679
+ linkFields.map((field) => field.fieldName),
1680
+ );
1681
+
1682
+ const foreignKey: ForeignKeyNode = {
1683
+ columns: basePrimaryKey.columns,
1684
+ references: {
1685
+ model: baseNode.modelName,
1686
+ table: baseNode.tableName,
1687
+ columns: basePrimaryKey.columns,
1688
+ ...ifDefined('namespaceId', baseNode.namespaceId),
1689
+ },
1690
+ constraint: true,
1691
+ // The link columns are the variant's own primary key, which already
1692
+ // carries a unique index — a separate FK backing index would be redundant.
1693
+ index: false,
1694
+ // Deleting a base row must delete its variant extension row — classic
1695
+ // multi-table-inheritance semantics.
1696
+ onDelete: 'cascade',
1697
+ };
1698
+
1699
+ return {
1700
+ ...node,
1701
+ fields: [...linkFields, ...node.fields],
1702
+ id: { columns: basePrimaryKey.columns },
1703
+ foreignKeys: [...(node.foreignKeys ?? []), foreignKey],
1704
+ };
1705
+ });
1706
+
1707
+ return { modelNodes: enriched, syntheticPkFieldsByVariant };
1708
+ }
1709
+
1710
+ /**
1711
+ * Single-table-inheritance variants (`@@base` with no own `@@map`) share the
1712
+ * base table: `resolvePolymorphism` points the variant's `storage.table` at the
1713
+ * base, and the ORM reads variant-declared fields straight off the base table.
1714
+ * For that to validate and round-trip, the base storage table must physically
1715
+ * carry every STI variant's declared columns. This enriches the base
1716
+ * `ModelNode` with those columns.
1717
+ *
1718
+ * The materialised columns are always nullable in storage: the base table hosts
1719
+ * every variant's rows, so a column a variant declares as required is still
1720
+ * NULL on sibling-variant rows. The variant's domain field keeps its declared
1721
+ * nullability — required-in-domain / nullable-in-storage is the intended STI
1722
+ * shape.
1723
+ *
1724
+ * Collisions (two variants declaring the same column, or a variant column name
1725
+ * clashing with a base column) are resolved skip-if-exists here, mirroring the
1726
+ * MTI link guard; surfacing them as diagnostics is tracked separately
1727
+ * (TML-2827).
1728
+ */
1729
+ function materializeStiVariantStorageColumns(
1730
+ modelNodes: readonly ModelNode[],
1731
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1732
+ stiVariantNames: ReadonlySet<string>,
1733
+ ): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map<string, readonly string[]> } {
1734
+ if (stiVariantNames.size === 0) {
1735
+ return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() };
1736
+ }
1737
+
1738
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1739
+ type StiColumn = ModelNode['fields'][number];
1740
+ const stiColumnsByBase = new Map<string, StiColumn[]>();
1741
+
1742
+ for (const variantName of stiVariantNames) {
1743
+ const variantNode = nodeByModel.get(variantName);
1744
+ const baseDecl = baseDeclarations.get(variantName);
1745
+ if (!variantNode || !baseDecl) continue;
1746
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1747
+ if (!baseNode) continue;
1748
+
1749
+ const baseColumns = new Set(baseNode.fields.map((field) => field.columnName));
1750
+ const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? [];
1751
+ const claimedColumns = new Set(claimed.map((field) => field.columnName));
1752
+
1753
+ for (const field of variantNode.fields) {
1754
+ if (baseColumns.has(field.columnName) || claimedColumns.has(field.columnName)) {
1755
+ continue;
1756
+ }
1757
+ claimedColumns.add(field.columnName);
1758
+ claimed.push({ ...field, nullable: true });
1759
+ }
1760
+ stiColumnsByBase.set(baseDecl.baseName, claimed);
1761
+ }
1762
+
1763
+ // The materialised columns exist on the base STORAGE table so the variants'
1764
+ // `storage.fields` resolve, but they are NOT base DOMAIN fields — `severity`
1765
+ // belongs to `Bug`, not to `Task`. Report the materialised field names per
1766
+ // base so the domain patch can strip them from the base model (the table
1767
+ // column stays); this is the STI analogue of `syntheticPkFieldsByVariant`.
1768
+ const stiBaseFieldsByBase = new Map<string, readonly string[]>();
1769
+ for (const [baseName, columns] of stiColumnsByBase) {
1770
+ stiBaseFieldsByBase.set(
1771
+ baseName,
1772
+ columns.map((field) => field.fieldName),
1773
+ );
1774
+ }
1775
+
1776
+ const enriched = modelNodes.map((node): ModelNode => {
1777
+ // STI variant: contributes a domain model but no storage table of its own.
1778
+ if (stiVariantNames.has(node.modelName)) {
1779
+ return { ...node, sharesBaseTable: true };
1780
+ }
1781
+ const stiColumns = stiColumnsByBase.get(node.modelName);
1782
+ if (!stiColumns || stiColumns.length === 0) return node;
1783
+ return { ...node, fields: [...node.fields, ...stiColumns] };
1784
+ });
1785
+
1786
+ return { modelNodes: enriched, stiBaseFieldsByBase };
1787
+ }
1788
+
1789
+ /**
1790
+ * Drop the storage-only link fields (added by
1791
+ * {@link materializeMtiVariantStorageLinks}) from a variant's domain model, so
1792
+ * the domain surface stays thin while the storage table keeps the link column.
1793
+ */
1794
+ function stripStorageOnlyDomainFields(
1795
+ model: ContractModel,
1796
+ fieldNames: readonly string[],
1797
+ ): ContractModel {
1798
+ if (fieldNames.length === 0) return model;
1799
+ const fields = { ...model.fields };
1800
+ for (const name of fieldNames) delete fields[name];
1801
+ const storage = blindCast<
1802
+ SqlModelStorage,
1803
+ 'SQL interpreter domain models always carry SqlModelStorage'
1804
+ >(model.storage);
1805
+ const storageFields = { ...storage.fields };
1806
+ for (const name of fieldNames) delete storageFields[name];
1807
+ return { ...model, fields, storage: { ...storage, fields: storageFields } };
1808
+ }
1809
+
1429
1810
  export function interpretPslDocumentToSqlContract(
1430
1811
  input: InterpretPslDocumentToSqlContractInput,
1431
1812
  ): Result<Contract, ContractSourceDiagnostics> {
@@ -1581,6 +1962,9 @@ export function interpretPslDocumentToSqlContract(
1581
1962
  const fkRelationMetadata: FkRelationMetadata[] = [];
1582
1963
  const backrelationCandidates: ModelBackrelationCandidate[] = [];
1583
1964
  const modelResolvedFields = new Map<string, readonly ResolvedField[]>();
1965
+ // Cross-space relation nodes keyed by declaring model name — merged into
1966
+ // modelRelations after local back-relation matching so they bypass that step.
1967
+ const crossSpaceRelationsByModel = new Map<string, RelationNode[]>();
1584
1968
 
1585
1969
  for (const model of models) {
1586
1970
  const mapping = modelMappings.get(model.name);
@@ -1615,6 +1999,10 @@ export function interpretPslDocumentToSqlContract(
1615
1999
  fkRelationMetadata.push(...result.fkRelationMetadata);
1616
2000
  backrelationCandidates.push(...result.backrelationCandidates);
1617
2001
  modelResolvedFields.set(model.name, result.resolvedFields);
2002
+ if (result.crossSpaceRelations.length > 0) {
2003
+ const existing = crossSpaceRelationsByModel.get(model.name) ?? [];
2004
+ crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]);
2005
+ }
1618
2006
  }
1619
2007
 
1620
2008
  const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
@@ -1626,12 +2014,44 @@ export function interpretPslDocumentToSqlContract(
1626
2014
  sourceId,
1627
2015
  });
1628
2016
 
2017
+ // Merge cross-space relations into modelRelations after local back-relation matching.
2018
+ // Cross-space targets have no local back-relation candidates, so they bypass that step.
2019
+ for (const [modelName, relations] of crossSpaceRelationsByModel) {
2020
+ const existing = modelRelations.get(modelName);
2021
+ if (existing) {
2022
+ existing.push(...relations);
2023
+ } else {
2024
+ modelRelations.set(modelName, [...relations]);
2025
+ }
2026
+ }
2027
+
1629
2028
  const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
1630
2029
  models,
1631
2030
  sourceId,
1632
2031
  diagnostics,
1633
2032
  );
1634
2033
 
2034
+ // A variant with `@@base` but no own `@@map` is single-table inheritance:
2035
+ // it shares the base table. (`@@map` ⇒ multi-table inheritance.) This is the
2036
+ // authoritative STI/MTI signal — the variant's resolved table name is not,
2037
+ // because a no-`@@map` STI variant still gets a `lowerFirst(name)` default
2038
+ // table name that differs from the base before `resolvePolymorphism` rewrites
2039
+ // it onto the base table.
2040
+ const stiVariantNames = new Set<string>();
2041
+ for (const variantName of baseDeclarations.keys()) {
2042
+ const variantMapping = modelMappings.get(variantName);
2043
+ const hasExplicitMap =
2044
+ variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
2045
+ if (!hasExplicitMap) {
2046
+ stiVariantNames.add(variantName);
2047
+ }
2048
+ }
2049
+
2050
+ const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } =
2051
+ materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames);
2052
+ const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } =
2053
+ materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames);
2054
+
1635
2055
  const valueObjects = buildValueObjects({
1636
2056
  compositeTypes,
1637
2057
  enumTypeDescriptors: allEnumTypeDescriptors,
@@ -1667,7 +2087,7 @@ export function interpretPslDocumentToSqlContract(
1667
2087
  ? { namespaceTypes: namespaceEnumStorageTypes }
1668
2088
  : {}),
1669
2089
  ...ifDefined('createNamespace', input.createNamespace),
1670
- models: modelNodes.map((model) => ({
2090
+ models: stiColumnModelNodes.map((model) => ({
1671
2091
  ...model,
1672
2092
  ...(modelRelations.has(model.modelName)
1673
2093
  ? {
@@ -1687,7 +2107,7 @@ export function interpretPslDocumentToSqlContract(
1687
2107
  `duplicate model name "${modelName}" across domain namespaces during PSL interpretation`,
1688
2108
  );
1689
2109
  }
1690
- modelsForPatch[modelName] = model as ContractModel;
2110
+ modelsForPatch[modelName] = model;
1691
2111
  }
1692
2112
  }
1693
2113
  let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
@@ -1700,7 +2120,9 @@ export function interpretPslDocumentToSqlContract(
1700
2120
  modelNames,
1701
2121
  modelMappings,
1702
2122
  modelNamespaceIds,
1703
- input.target.targetId,
2123
+ input.target.defaultNamespaceId,
2124
+ syntheticPkFieldsByVariant,
2125
+ stiBaseFieldsByBase,
1704
2126
  sourceId,
1705
2127
  polyDiagnostics,
1706
2128
  );
@@ -1736,7 +2158,7 @@ export function interpretPslDocumentToSqlContract(
1736
2158
  ...(namespaceSlice.valueObjects !== undefined
1737
2159
  ? { valueObjects: namespaceSlice.valueObjects }
1738
2160
  : {}),
1739
- ...(namespaceId === defaultSqlNamespaceIdForTarget(input.target.targetId) &&
2161
+ ...(namespaceId === input.target.defaultNamespaceId &&
1740
2162
  Object.keys(valueObjects).length > 0
1741
2163
  ? { valueObjects }
1742
2164
  : {}),
package/src/provider.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import type { ContractConfig } from '@prisma-next/config/config-types';
3
+ import { applySpecifierDefaultControlPolicy } from '@prisma-next/contract/apply-specifier-default-control-policy';
4
+ import type { ControlPolicy } from '@prisma-next/contract/types';
3
5
  import type { CodecLookup } from '@prisma-next/framework-components/codec';
4
6
  import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components';
7
+ import type { Namespace } from '@prisma-next/framework-components/ir';
5
8
  import { parsePslDocument } from '@prisma-next/psl-parser';
9
+ import type { SqlNamespaceTablesInput } from '@prisma-next/sql-contract/types';
6
10
  import { ifDefined } from '@prisma-next/utils/defined';
7
11
  import { notOk, ok } from '@prisma-next/utils/result';
8
12
  import { basename, extname } from 'pathe';
@@ -13,6 +17,8 @@ export interface PrismaContractOptions {
13
17
  readonly output?: string;
14
18
  readonly target: TargetPackRef<'sql', string>;
15
19
  readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
20
+ readonly createNamespace?: (input: SqlNamespaceTablesInput) => Namespace;
21
+ readonly defaultControlPolicy?: ControlPolicy;
16
22
  }
17
23
 
18
24
  /**
@@ -106,12 +112,15 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption
106
112
  : undefined,
107
113
  ),
108
114
  controlMutationDefaults: context.controlMutationDefaults,
115
+ ...ifDefined('createNamespace', options.createNamespace),
109
116
  });
110
117
  if (!interpreted.ok) {
111
118
  return interpreted;
112
119
  }
113
120
 
114
- return ok(interpreted.value);
121
+ return ok(
122
+ applySpecifierDefaultControlPolicy(interpreted.value, options.defaultControlPolicy),
123
+ );
115
124
  },
116
125
  },
117
126
  output: options.output ?? defaultOutputFromSchemaPath(schemaPath),