@prisma-next/sql-contract-psl 0.12.0-dev.47 → 0.12.0-dev.48

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.
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract-psl",
3
- "version": "0.12.0-dev.47",
3
+ "version": "0.12.0-dev.48",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "PSL-to-SQL ContractIR interpreter for Prisma Next",
8
8
  "dependencies": {
9
- "@prisma-next/config": "0.12.0-dev.47",
10
- "@prisma-next/contract": "0.12.0-dev.47",
11
- "@prisma-next/framework-components": "0.12.0-dev.47",
12
- "@prisma-next/psl-parser": "0.12.0-dev.47",
13
- "@prisma-next/sql-contract": "0.12.0-dev.47",
14
- "@prisma-next/sql-contract-ts": "0.12.0-dev.47",
15
- "@prisma-next/utils": "0.12.0-dev.47",
9
+ "@prisma-next/config": "0.12.0-dev.48",
10
+ "@prisma-next/contract": "0.12.0-dev.48",
11
+ "@prisma-next/framework-components": "0.12.0-dev.48",
12
+ "@prisma-next/psl-parser": "0.12.0-dev.48",
13
+ "@prisma-next/sql-contract": "0.12.0-dev.48",
14
+ "@prisma-next/sql-contract-ts": "0.12.0-dev.48",
15
+ "@prisma-next/utils": "0.12.0-dev.48",
16
16
  "pathe": "^2.0.3"
17
17
  },
18
18
  "devDependencies": {
19
- "@prisma-next/contract-authoring": "0.12.0-dev.47",
20
- "@prisma-next/test-utils": "0.12.0-dev.47",
21
- "@prisma-next/tsconfig": "0.12.0-dev.47",
22
- "@prisma-next/tsdown": "0.12.0-dev.47",
19
+ "@prisma-next/contract-authoring": "0.12.0-dev.48",
20
+ "@prisma-next/test-utils": "0.12.0-dev.48",
21
+ "@prisma-next/tsconfig": "0.12.0-dev.48",
22
+ "@prisma-next/tsdown": "0.12.0-dev.48",
23
23
  "arktype": "^2.2.0",
24
24
  "tsdown": "0.22.0",
25
25
  "typescript": "5.9.3",
@@ -37,17 +37,20 @@ 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,
49
51
  type UniqueConstraintNode,
50
52
  } from '@prisma-next/sql-contract-ts/contract-builder';
53
+ import { blindCast } from '@prisma-next/utils/casts';
51
54
  import { ifDefined } from '@prisma-next/utils/defined';
52
55
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
53
56
  import {
@@ -1332,11 +1335,26 @@ function resolvePolymorphism(
1332
1335
  modelMappings: ReadonlyMap<string, ModelNameMapping>,
1333
1336
  modelNamespaceIds: ReadonlyMap<string, string>,
1334
1337
  defaultNamespaceId: string,
1338
+ syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
1339
+ stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
1335
1340
  sourceId: string,
1336
1341
  diagnostics: ContractSourceDiagnostic[],
1337
1342
  ): Record<string, ContractModel> {
1338
1343
  let patched = models;
1339
1344
 
1345
+ // STI variant columns were materialised onto the base storage table so the
1346
+ // variants' `storage.fields` resolve. They are storage-only on the base — the
1347
+ // domain field belongs to the variant — so strip them from the base model's
1348
+ // domain + storage field maps (the table column, built upstream, stays).
1349
+ for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
1350
+ const baseModel = patched[baseName];
1351
+ if (!baseModel || fieldNames.length === 0) continue;
1352
+ patched = {
1353
+ ...patched,
1354
+ [baseName]: stripStorageOnlyDomainFields(baseModel, fieldNames),
1355
+ };
1356
+ }
1357
+
1340
1358
  for (const [modelName, decl] of discriminatorDeclarations) {
1341
1359
  if (baseDeclarations.has(modelName)) {
1342
1360
  diagnostics.push({
@@ -1431,22 +1449,211 @@ function resolvePolymorphism(
1431
1449
  variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
1432
1450
  const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
1433
1451
 
1452
+ const patchedVariant: ContractModel = {
1453
+ ...variantModel,
1454
+ base: crossRef(
1455
+ baseDecl.baseName,
1456
+ modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
1457
+ ),
1458
+ ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
1459
+ };
1460
+
1434
1461
  patched = {
1435
1462
  ...patched,
1436
- [variantName]: {
1437
- ...variantModel,
1438
- base: crossRef(
1439
- baseDecl.baseName,
1440
- modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
1441
- ),
1442
- ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
1443
- },
1463
+ [variantName]: stripStorageOnlyDomainFields(
1464
+ patchedVariant,
1465
+ syntheticPkFieldsByVariant.get(variantName) ?? [],
1466
+ ),
1444
1467
  };
1445
1468
  }
1446
1469
 
1447
1470
  return patched;
1448
1471
  }
1449
1472
 
1473
+ /**
1474
+ * Multi-table-inheritance variants (`@@base` + their own `@@map`) live in a
1475
+ * separate table from their base. The ORM joins that table to the base on the
1476
+ * shared primary key (`base.id = variant.id`), so the variant storage table
1477
+ * must carry the base PK column even though the variant domain model declares
1478
+ * only its own fields. This enriches each MTI variant's `ModelNode` with that
1479
+ * link column, a primary key on it, and a FK back to the base table.
1480
+ *
1481
+ * The link column is reported back per variant in `syntheticPkFieldsByVariant`
1482
+ * so the domain-model patch can drop it again — keeping the variant's domain
1483
+ * surface thin (its create/read inputs don't gain a redundant `id`) while the
1484
+ * storage table stays joinable. Single-table-inheritance variants (no own
1485
+ * table) are left untouched.
1486
+ */
1487
+ function materializeMtiVariantStorageLinks(
1488
+ modelNodes: readonly ModelNode[],
1489
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1490
+ stiVariantNames: ReadonlySet<string>,
1491
+ ): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map<string, readonly string[]> } {
1492
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1493
+ const syntheticPkFieldsByVariant = new Map<string, readonly string[]>();
1494
+
1495
+ const enriched = modelNodes.map((node): ModelNode => {
1496
+ const baseDecl = baseDeclarations.get(node.modelName);
1497
+ if (!baseDecl) return node;
1498
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1499
+ if (!baseNode) return node;
1500
+ // Single-table inheritance (no own `@@map`) shares the base table; it gets
1501
+ // its columns materialised onto the base instead (see
1502
+ // {@link materializeStiVariantStorageColumns}), never a link column.
1503
+ if (stiVariantNames.has(node.modelName)) return node;
1504
+ const basePrimaryKey = baseNode.id;
1505
+ if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node;
1506
+
1507
+ const existingColumns = new Set(node.fields.map((field) => field.columnName));
1508
+ const linkFields: FieldNode[] = [];
1509
+ for (const pkColumn of basePrimaryKey.columns) {
1510
+ if (existingColumns.has(pkColumn)) continue;
1511
+ const baseField = baseNode.fields.find(
1512
+ (field): field is FieldNode => 'descriptor' in field && field.columnName === pkColumn,
1513
+ );
1514
+ if (!baseField) continue;
1515
+ linkFields.push({
1516
+ fieldName: baseField.fieldName,
1517
+ columnName: pkColumn,
1518
+ descriptor: baseField.descriptor,
1519
+ nullable: false,
1520
+ });
1521
+ }
1522
+ if (linkFields.length === 0) return node;
1523
+
1524
+ syntheticPkFieldsByVariant.set(
1525
+ node.modelName,
1526
+ linkFields.map((field) => field.fieldName),
1527
+ );
1528
+
1529
+ const foreignKey: ForeignKeyNode = {
1530
+ columns: basePrimaryKey.columns,
1531
+ references: {
1532
+ model: baseNode.modelName,
1533
+ table: baseNode.tableName,
1534
+ columns: basePrimaryKey.columns,
1535
+ ...ifDefined('namespaceId', baseNode.namespaceId),
1536
+ },
1537
+ constraint: true,
1538
+ // The link columns are the variant's own primary key, which already
1539
+ // carries a unique index — a separate FK backing index would be redundant.
1540
+ index: false,
1541
+ // Deleting a base row must delete its variant extension row — classic
1542
+ // multi-table-inheritance semantics.
1543
+ onDelete: 'cascade',
1544
+ };
1545
+
1546
+ return {
1547
+ ...node,
1548
+ fields: [...linkFields, ...node.fields],
1549
+ id: { columns: basePrimaryKey.columns },
1550
+ foreignKeys: [...(node.foreignKeys ?? []), foreignKey],
1551
+ };
1552
+ });
1553
+
1554
+ return { modelNodes: enriched, syntheticPkFieldsByVariant };
1555
+ }
1556
+
1557
+ /**
1558
+ * Single-table-inheritance variants (`@@base` with no own `@@map`) share the
1559
+ * base table: `resolvePolymorphism` points the variant's `storage.table` at the
1560
+ * base, and the ORM reads variant-declared fields straight off the base table.
1561
+ * For that to validate and round-trip, the base storage table must physically
1562
+ * carry every STI variant's declared columns. This enriches the base
1563
+ * `ModelNode` with those columns.
1564
+ *
1565
+ * The materialised columns are always nullable in storage: the base table hosts
1566
+ * every variant's rows, so a column a variant declares as required is still
1567
+ * NULL on sibling-variant rows. The variant's domain field keeps its declared
1568
+ * nullability — required-in-domain / nullable-in-storage is the intended STI
1569
+ * shape.
1570
+ *
1571
+ * Collisions (two variants declaring the same column, or a variant column name
1572
+ * clashing with a base column) are resolved skip-if-exists here, mirroring the
1573
+ * MTI link guard; surfacing them as diagnostics is tracked separately
1574
+ * (TML-2827).
1575
+ */
1576
+ function materializeStiVariantStorageColumns(
1577
+ modelNodes: readonly ModelNode[],
1578
+ baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
1579
+ stiVariantNames: ReadonlySet<string>,
1580
+ ): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map<string, readonly string[]> } {
1581
+ if (stiVariantNames.size === 0) {
1582
+ return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() };
1583
+ }
1584
+
1585
+ const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
1586
+ type StiColumn = ModelNode['fields'][number];
1587
+ const stiColumnsByBase = new Map<string, StiColumn[]>();
1588
+
1589
+ for (const variantName of stiVariantNames) {
1590
+ const variantNode = nodeByModel.get(variantName);
1591
+ const baseDecl = baseDeclarations.get(variantName);
1592
+ if (!variantNode || !baseDecl) continue;
1593
+ const baseNode = nodeByModel.get(baseDecl.baseName);
1594
+ if (!baseNode) continue;
1595
+
1596
+ const baseColumns = new Set(baseNode.fields.map((field) => field.columnName));
1597
+ const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? [];
1598
+ const claimedColumns = new Set(claimed.map((field) => field.columnName));
1599
+
1600
+ for (const field of variantNode.fields) {
1601
+ if (baseColumns.has(field.columnName) || claimedColumns.has(field.columnName)) {
1602
+ continue;
1603
+ }
1604
+ claimedColumns.add(field.columnName);
1605
+ claimed.push({ ...field, nullable: true });
1606
+ }
1607
+ stiColumnsByBase.set(baseDecl.baseName, claimed);
1608
+ }
1609
+
1610
+ // The materialised columns exist on the base STORAGE table so the variants'
1611
+ // `storage.fields` resolve, but they are NOT base DOMAIN fields — `severity`
1612
+ // belongs to `Bug`, not to `Task`. Report the materialised field names per
1613
+ // base so the domain patch can strip them from the base model (the table
1614
+ // column stays); this is the STI analogue of `syntheticPkFieldsByVariant`.
1615
+ const stiBaseFieldsByBase = new Map<string, readonly string[]>();
1616
+ for (const [baseName, columns] of stiColumnsByBase) {
1617
+ stiBaseFieldsByBase.set(
1618
+ baseName,
1619
+ columns.map((field) => field.fieldName),
1620
+ );
1621
+ }
1622
+
1623
+ const enriched = modelNodes.map((node): ModelNode => {
1624
+ // STI variant: contributes a domain model but no storage table of its own.
1625
+ if (stiVariantNames.has(node.modelName)) {
1626
+ return { ...node, sharesBaseTable: true };
1627
+ }
1628
+ const stiColumns = stiColumnsByBase.get(node.modelName);
1629
+ if (!stiColumns || stiColumns.length === 0) return node;
1630
+ return { ...node, fields: [...node.fields, ...stiColumns] };
1631
+ });
1632
+
1633
+ return { modelNodes: enriched, stiBaseFieldsByBase };
1634
+ }
1635
+
1636
+ /**
1637
+ * Drop the storage-only link fields (added by
1638
+ * {@link materializeMtiVariantStorageLinks}) from a variant's domain model, so
1639
+ * the domain surface stays thin while the storage table keeps the link column.
1640
+ */
1641
+ function stripStorageOnlyDomainFields(
1642
+ model: ContractModel,
1643
+ fieldNames: readonly string[],
1644
+ ): ContractModel {
1645
+ if (fieldNames.length === 0) return model;
1646
+ const fields = { ...model.fields };
1647
+ for (const name of fieldNames) delete fields[name];
1648
+ const storage = blindCast<
1649
+ SqlModelStorage,
1650
+ 'SQL interpreter domain models always carry SqlModelStorage'
1651
+ >(model.storage);
1652
+ const storageFields = { ...storage.fields };
1653
+ for (const name of fieldNames) delete storageFields[name];
1654
+ return { ...model, fields, storage: { ...storage, fields: storageFields } };
1655
+ }
1656
+
1450
1657
  export function interpretPslDocumentToSqlContract(
1451
1658
  input: InterpretPslDocumentToSqlContractInput,
1452
1659
  ): Result<Contract, ContractSourceDiagnostics> {
@@ -1653,6 +1860,27 @@ export function interpretPslDocumentToSqlContract(
1653
1860
  diagnostics,
1654
1861
  );
1655
1862
 
1863
+ // A variant with `@@base` but no own `@@map` is single-table inheritance:
1864
+ // it shares the base table. (`@@map` ⇒ multi-table inheritance.) This is the
1865
+ // authoritative STI/MTI signal — the variant's resolved table name is not,
1866
+ // because a no-`@@map` STI variant still gets a `lowerFirst(name)` default
1867
+ // table name that differs from the base before `resolvePolymorphism` rewrites
1868
+ // it onto the base table.
1869
+ const stiVariantNames = new Set<string>();
1870
+ for (const variantName of baseDeclarations.keys()) {
1871
+ const variantMapping = modelMappings.get(variantName);
1872
+ const hasExplicitMap =
1873
+ variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
1874
+ if (!hasExplicitMap) {
1875
+ stiVariantNames.add(variantName);
1876
+ }
1877
+ }
1878
+
1879
+ const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } =
1880
+ materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames);
1881
+ const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } =
1882
+ materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames);
1883
+
1656
1884
  const valueObjects = buildValueObjects({
1657
1885
  compositeTypes,
1658
1886
  enumTypeDescriptors: allEnumTypeDescriptors,
@@ -1688,7 +1916,7 @@ export function interpretPslDocumentToSqlContract(
1688
1916
  ? { namespaceTypes: namespaceEnumStorageTypes }
1689
1917
  : {}),
1690
1918
  ...ifDefined('createNamespace', input.createNamespace),
1691
- models: modelNodes.map((model) => ({
1919
+ models: stiColumnModelNodes.map((model) => ({
1692
1920
  ...model,
1693
1921
  ...(modelRelations.has(model.modelName)
1694
1922
  ? {
@@ -1722,6 +1950,8 @@ export function interpretPslDocumentToSqlContract(
1722
1950
  modelMappings,
1723
1951
  modelNamespaceIds,
1724
1952
  input.target.defaultNamespaceId,
1953
+ syntheticPkFieldsByVariant,
1954
+ stiBaseFieldsByBase,
1725
1955
  sourceId,
1726
1956
  polyDiagnostics,
1727
1957
  );