@prisma-next/sql-contract-psl 0.12.0-dev.5 → 0.12.0-dev.50

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/dist/provider.mjs CHANGED
@@ -1,8 +1,9 @@
1
- import { t as interpretPslDocumentToSqlContract } from "./interpreter-QE6eZZof.mjs";
1
+ import { t as interpretPslDocumentToSqlContract } from "./interpreter-Rn_Vzvt-.mjs";
2
2
  import { ifDefined } from "@prisma-next/utils/defined";
3
3
  import { notOk, ok } from "@prisma-next/utils/result";
4
4
  import { parsePslDocument } from "@prisma-next/psl-parser";
5
5
  import { readFile } from "node:fs/promises";
6
+ import { applySpecifierDefaultControlPolicy } from "@prisma-next/contract/apply-specifier-default-control-policy";
6
7
  import { basename, extname } from "pathe";
7
8
  //#region src/provider.ts
8
9
  /**
@@ -69,10 +70,11 @@ function prismaContract(schemaPath, options) {
69
70
  scalarTypeDescriptors,
70
71
  ...ifDefined("composedExtensionPacks", context.composedExtensionPacks.length > 0 ? [...context.composedExtensionPacks] : void 0),
71
72
  ...ifDefined("composedExtensionPackRefs", options.composedExtensionPackRefs?.length ? options.composedExtensionPackRefs : void 0),
72
- controlMutationDefaults: context.controlMutationDefaults
73
+ controlMutationDefaults: context.controlMutationDefaults,
74
+ ...ifDefined("createNamespace", options.createNamespace)
73
75
  });
74
76
  if (!interpreted.ok) return interpreted;
75
- return ok(interpreted.value);
77
+ return ok(applySpecifierDefaultControlPolicy(interpreted.value, options.defaultControlPolicy));
76
78
  }
77
79
  },
78
80
  output: options.output ?? defaultOutputFromSchemaPath(schemaPath)
@@ -1 +1 @@
1
- {"version":3,"file":"provider.mjs","names":[],"sources":["../src/provider.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport type { ContractConfig } from '@prisma-next/config/config-types';\nimport type { CodecLookup } from '@prisma-next/framework-components/codec';\nimport type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components';\nimport { parsePslDocument } from '@prisma-next/psl-parser';\nimport { ifDefined } from '@prisma-next/utils/defined';\nimport { notOk, ok } from '@prisma-next/utils/result';\nimport { basename, extname } from 'pathe';\nimport { interpretPslDocumentToSqlContract } from './interpreter';\nimport type { ColumnDescriptor } from './psl-column-resolution';\n\nexport interface PrismaContractOptions {\n readonly output?: string;\n readonly target: TargetPackRef<'sql', string>;\n readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];\n}\n\n/**\n * Derives the emit output path from the schema input path so artefacts land\n * colocated with the source (e.g. `src/contract/schema.prisma` →\n * `src/contract/contract.json`). The provider owns this because it is the\n * only layer that knows the input path; the upstream `normalizeContractConfig`\n * default is a last-resort fallback for providers that don't carry one.\n */\nfunction defaultOutputFromSchemaPath(schemaPath: string): string {\n const ext = extname(schemaPath);\n if (ext.length === 0) return `${schemaPath}.json`;\n const base = schemaPath.slice(0, -ext.length);\n // PSL schemas commonly use `schema.prisma`; the emitted JSON is called\n // `contract.json` to mirror the rest of the toolchain, not `schema.json`.\n // Match only the exact basename `schema` so files like `my-schema.prisma`\n // are not silently rewritten to `my-contract.json`.\n if (basename(base) === 'schema') {\n return `${base.slice(0, -'schema'.length)}contract.json`;\n }\n return `${base}.json`;\n}\n\nfunction buildColumnDescriptorMap(\n scalarTypeDescriptors: ReadonlyMap<string, string>,\n codecLookup: CodecLookup,\n): ReadonlyMap<string, ColumnDescriptor> {\n const result = new Map<string, ColumnDescriptor>();\n for (const [typeName, codecId] of scalarTypeDescriptors) {\n const nativeType = codecLookup.targetTypesFor(codecId)?.[0];\n if (nativeType === undefined) continue;\n result.set(typeName, { codecId, nativeType });\n }\n return result;\n}\n\nexport function prismaContract(schemaPath: string, options: PrismaContractOptions): ContractConfig {\n return {\n source: {\n inputs: [schemaPath],\n load: async (context) => {\n const [absoluteSchemaPath] = context.resolvedInputs;\n if (absoluteSchemaPath === undefined) {\n throw new Error(\n 'prismaContract: context.resolvedInputs is empty. The CLI config loader should populate it positional-matched with source.inputs.',\n );\n }\n let schema: string;\n try {\n schema = await readFile(absoluteSchemaPath, 'utf-8');\n } catch (error) {\n const message = String(error);\n return notOk({\n summary: `Failed to read Prisma schema at \"${schemaPath}\"`,\n diagnostics: [\n {\n code: 'PSL_SCHEMA_READ_FAILED',\n message,\n sourceId: schemaPath,\n },\n ],\n meta: { schemaPath, absoluteSchemaPath, cause: message },\n });\n }\n\n const document = parsePslDocument({\n schema,\n sourceId: schemaPath,\n });\n\n const scalarTypeDescriptors = buildColumnDescriptorMap(\n context.scalarTypeDescriptors,\n context.codecLookup,\n );\n\n const interpreted = interpretPslDocumentToSqlContract({\n document,\n target: options.target,\n authoringContributions: context.authoringContributions,\n scalarTypeDescriptors,\n ...ifDefined(\n 'composedExtensionPacks',\n context.composedExtensionPacks.length > 0\n ? [...context.composedExtensionPacks]\n : undefined,\n ),\n ...ifDefined(\n 'composedExtensionPackRefs',\n options.composedExtensionPackRefs?.length\n ? options.composedExtensionPackRefs\n : undefined,\n ),\n controlMutationDefaults: context.controlMutationDefaults,\n });\n if (!interpreted.ok) {\n return interpreted;\n }\n\n return ok(interpreted.value);\n },\n },\n output: options.output ?? defaultOutputFromSchemaPath(schemaPath),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;AAwBA,SAAS,4BAA4B,YAA4B;CAC/D,MAAM,MAAM,QAAQ,UAAU;CAC9B,IAAI,IAAI,WAAW,GAAG,OAAO,GAAG,WAAW;CAC3C,MAAM,OAAO,WAAW,MAAM,GAAG,CAAC,IAAI,MAAM;CAK5C,IAAI,SAAS,IAAI,MAAM,UACrB,OAAO,GAAG,KAAK,MAAM,GAAG,EAAgB,EAAE;CAE5C,OAAO,GAAG,KAAK;AACjB;AAEA,SAAS,yBACP,uBACA,aACuC;CACvC,MAAM,yBAAS,IAAI,IAA8B;CACjD,KAAK,MAAM,CAAC,UAAU,YAAY,uBAAuB;EACvD,MAAM,aAAa,YAAY,eAAe,OAAO,IAAI;EACzD,IAAI,eAAe,KAAA,GAAW;EAC9B,OAAO,IAAI,UAAU;GAAE;GAAS;EAAW,CAAC;CAC9C;CACA,OAAO;AACT;AAEA,SAAgB,eAAe,YAAoB,SAAgD;CACjG,OAAO;EACL,QAAQ;GACN,QAAQ,CAAC,UAAU;GACnB,MAAM,OAAO,YAAY;IACvB,MAAM,CAAC,sBAAsB,QAAQ;IACrC,IAAI,uBAAuB,KAAA,GACzB,MAAM,IAAI,MACR,kIACF;IAEF,IAAI;IACJ,IAAI;KACF,SAAS,MAAM,SAAS,oBAAoB,OAAO;IACrD,SAAS,OAAO;KACd,MAAM,UAAU,OAAO,KAAK;KAC5B,OAAO,MAAM;MACX,SAAS,oCAAoC,WAAW;MACxD,aAAa,CACX;OACE,MAAM;OACN;OACA,UAAU;MACZ,CACF;MACA,MAAM;OAAE;OAAY;OAAoB,OAAO;MAAQ;KACzD,CAAC;IACH;IAEA,MAAM,WAAW,iBAAiB;KAChC;KACA,UAAU;IACZ,CAAC;IAED,MAAM,wBAAwB,yBAC5B,QAAQ,uBACR,QAAQ,WACV;IAEA,MAAM,cAAc,kCAAkC;KACpD;KACA,QAAQ,QAAQ;KAChB,wBAAwB,QAAQ;KAChC;KACA,GAAG,UACD,0BACA,QAAQ,uBAAuB,SAAS,IACpC,CAAC,GAAG,QAAQ,sBAAsB,IAClC,KAAA,CACN;KACA,GAAG,UACD,6BACA,QAAQ,2BAA2B,SAC/B,QAAQ,4BACR,KAAA,CACN;KACA,yBAAyB,QAAQ;IACnC,CAAC;IACD,IAAI,CAAC,YAAY,IACf,OAAO;IAGT,OAAO,GAAG,YAAY,KAAK;GAC7B;EACF;EACA,QAAQ,QAAQ,UAAU,4BAA4B,UAAU;CAClE;AACF"}
1
+ {"version":3,"file":"provider.mjs","names":[],"sources":["../src/provider.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport type { ContractConfig } from '@prisma-next/config/config-types';\nimport { applySpecifierDefaultControlPolicy } from '@prisma-next/contract/apply-specifier-default-control-policy';\nimport type { ControlPolicy } from '@prisma-next/contract/types';\nimport type { CodecLookup } from '@prisma-next/framework-components/codec';\nimport type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components';\nimport type { Namespace } from '@prisma-next/framework-components/ir';\nimport { parsePslDocument } from '@prisma-next/psl-parser';\nimport type { SqlNamespaceTablesInput } from '@prisma-next/sql-contract/types';\nimport { ifDefined } from '@prisma-next/utils/defined';\nimport { notOk, ok } from '@prisma-next/utils/result';\nimport { basename, extname } from 'pathe';\nimport { interpretPslDocumentToSqlContract } from './interpreter';\nimport type { ColumnDescriptor } from './psl-column-resolution';\n\nexport interface PrismaContractOptions {\n readonly output?: string;\n readonly target: TargetPackRef<'sql', string>;\n readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];\n readonly createNamespace?: (input: SqlNamespaceTablesInput) => Namespace;\n readonly defaultControlPolicy?: ControlPolicy;\n}\n\n/**\n * Derives the emit output path from the schema input path so artefacts land\n * colocated with the source (e.g. `src/contract/schema.prisma` →\n * `src/contract/contract.json`). The provider owns this because it is the\n * only layer that knows the input path; the upstream `normalizeContractConfig`\n * default is a last-resort fallback for providers that don't carry one.\n */\nfunction defaultOutputFromSchemaPath(schemaPath: string): string {\n const ext = extname(schemaPath);\n if (ext.length === 0) return `${schemaPath}.json`;\n const base = schemaPath.slice(0, -ext.length);\n // PSL schemas commonly use `schema.prisma`; the emitted JSON is called\n // `contract.json` to mirror the rest of the toolchain, not `schema.json`.\n // Match only the exact basename `schema` so files like `my-schema.prisma`\n // are not silently rewritten to `my-contract.json`.\n if (basename(base) === 'schema') {\n return `${base.slice(0, -'schema'.length)}contract.json`;\n }\n return `${base}.json`;\n}\n\nfunction buildColumnDescriptorMap(\n scalarTypeDescriptors: ReadonlyMap<string, string>,\n codecLookup: CodecLookup,\n): ReadonlyMap<string, ColumnDescriptor> {\n const result = new Map<string, ColumnDescriptor>();\n for (const [typeName, codecId] of scalarTypeDescriptors) {\n const nativeType = codecLookup.targetTypesFor(codecId)?.[0];\n if (nativeType === undefined) continue;\n result.set(typeName, { codecId, nativeType });\n }\n return result;\n}\n\nexport function prismaContract(schemaPath: string, options: PrismaContractOptions): ContractConfig {\n return {\n source: {\n inputs: [schemaPath],\n load: async (context) => {\n const [absoluteSchemaPath] = context.resolvedInputs;\n if (absoluteSchemaPath === undefined) {\n throw new Error(\n 'prismaContract: context.resolvedInputs is empty. The CLI config loader should populate it positional-matched with source.inputs.',\n );\n }\n let schema: string;\n try {\n schema = await readFile(absoluteSchemaPath, 'utf-8');\n } catch (error) {\n const message = String(error);\n return notOk({\n summary: `Failed to read Prisma schema at \"${schemaPath}\"`,\n diagnostics: [\n {\n code: 'PSL_SCHEMA_READ_FAILED',\n message,\n sourceId: schemaPath,\n },\n ],\n meta: { schemaPath, absoluteSchemaPath, cause: message },\n });\n }\n\n const document = parsePslDocument({\n schema,\n sourceId: schemaPath,\n });\n\n const scalarTypeDescriptors = buildColumnDescriptorMap(\n context.scalarTypeDescriptors,\n context.codecLookup,\n );\n\n const interpreted = interpretPslDocumentToSqlContract({\n document,\n target: options.target,\n authoringContributions: context.authoringContributions,\n scalarTypeDescriptors,\n ...ifDefined(\n 'composedExtensionPacks',\n context.composedExtensionPacks.length > 0\n ? [...context.composedExtensionPacks]\n : undefined,\n ),\n ...ifDefined(\n 'composedExtensionPackRefs',\n options.composedExtensionPackRefs?.length\n ? options.composedExtensionPackRefs\n : undefined,\n ),\n controlMutationDefaults: context.controlMutationDefaults,\n ...ifDefined('createNamespace', options.createNamespace),\n });\n if (!interpreted.ok) {\n return interpreted;\n }\n\n return ok(\n applySpecifierDefaultControlPolicy(interpreted.value, options.defaultControlPolicy),\n );\n },\n },\n output: options.output ?? defaultOutputFromSchemaPath(schemaPath),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AA8BA,SAAS,4BAA4B,YAA4B;CAC/D,MAAM,MAAM,QAAQ,UAAU;CAC9B,IAAI,IAAI,WAAW,GAAG,OAAO,GAAG,WAAW;CAC3C,MAAM,OAAO,WAAW,MAAM,GAAG,CAAC,IAAI,MAAM;CAK5C,IAAI,SAAS,IAAI,MAAM,UACrB,OAAO,GAAG,KAAK,MAAM,GAAG,EAAgB,EAAE;CAE5C,OAAO,GAAG,KAAK;AACjB;AAEA,SAAS,yBACP,uBACA,aACuC;CACvC,MAAM,yBAAS,IAAI,IAA8B;CACjD,KAAK,MAAM,CAAC,UAAU,YAAY,uBAAuB;EACvD,MAAM,aAAa,YAAY,eAAe,OAAO,IAAI;EACzD,IAAI,eAAe,KAAA,GAAW;EAC9B,OAAO,IAAI,UAAU;GAAE;GAAS;EAAW,CAAC;CAC9C;CACA,OAAO;AACT;AAEA,SAAgB,eAAe,YAAoB,SAAgD;CACjG,OAAO;EACL,QAAQ;GACN,QAAQ,CAAC,UAAU;GACnB,MAAM,OAAO,YAAY;IACvB,MAAM,CAAC,sBAAsB,QAAQ;IACrC,IAAI,uBAAuB,KAAA,GACzB,MAAM,IAAI,MACR,kIACF;IAEF,IAAI;IACJ,IAAI;KACF,SAAS,MAAM,SAAS,oBAAoB,OAAO;IACrD,SAAS,OAAO;KACd,MAAM,UAAU,OAAO,KAAK;KAC5B,OAAO,MAAM;MACX,SAAS,oCAAoC,WAAW;MACxD,aAAa,CACX;OACE,MAAM;OACN;OACA,UAAU;MACZ,CACF;MACA,MAAM;OAAE;OAAY;OAAoB,OAAO;MAAQ;KACzD,CAAC;IACH;IAEA,MAAM,WAAW,iBAAiB;KAChC;KACA,UAAU;IACZ,CAAC;IAED,MAAM,wBAAwB,yBAC5B,QAAQ,uBACR,QAAQ,WACV;IAEA,MAAM,cAAc,kCAAkC;KACpD;KACA,QAAQ,QAAQ;KAChB,wBAAwB,QAAQ;KAChC;KACA,GAAG,UACD,0BACA,QAAQ,uBAAuB,SAAS,IACpC,CAAC,GAAG,QAAQ,sBAAsB,IAClC,KAAA,CACN;KACA,GAAG,UACD,6BACA,QAAQ,2BAA2B,SAC/B,QAAQ,4BACR,KAAA,CACN;KACA,yBAAyB,QAAQ;KACjC,GAAG,UAAU,mBAAmB,QAAQ,eAAe;IACzD,CAAC;IACD,IAAI,CAAC,YAAY,IACf,OAAO;IAGT,OAAO,GACL,mCAAmC,YAAY,OAAO,QAAQ,oBAAoB,CACpF;GACF;EACF;EACA,QAAQ,QAAQ,UAAU,4BAA4B,UAAU;CAClE;AACF"}
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract-psl",
3
- "version": "0.12.0-dev.5",
3
+ "version": "0.12.0-dev.50",
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.5",
10
- "@prisma-next/contract": "0.12.0-dev.5",
11
- "@prisma-next/framework-components": "0.12.0-dev.5",
12
- "@prisma-next/psl-parser": "0.12.0-dev.5",
13
- "@prisma-next/sql-contract": "0.12.0-dev.5",
14
- "@prisma-next/sql-contract-ts": "0.12.0-dev.5",
15
- "@prisma-next/utils": "0.12.0-dev.5",
9
+ "@prisma-next/config": "0.12.0-dev.50",
10
+ "@prisma-next/contract": "0.12.0-dev.50",
11
+ "@prisma-next/framework-components": "0.12.0-dev.50",
12
+ "@prisma-next/psl-parser": "0.12.0-dev.50",
13
+ "@prisma-next/sql-contract": "0.12.0-dev.50",
14
+ "@prisma-next/sql-contract-ts": "0.12.0-dev.50",
15
+ "@prisma-next/utils": "0.12.0-dev.50",
16
16
  "pathe": "^2.0.3"
17
17
  },
18
18
  "devDependencies": {
19
- "@prisma-next/contract-authoring": "0.12.0-dev.5",
20
- "@prisma-next/test-utils": "0.12.0-dev.5",
21
- "@prisma-next/tsconfig": "0.12.0-dev.5",
22
- "@prisma-next/tsdown": "0.12.0-dev.5",
19
+ "@prisma-next/contract-authoring": "0.12.0-dev.50",
20
+ "@prisma-next/test-utils": "0.12.0-dev.50",
21
+ "@prisma-next/tsconfig": "0.12.0-dev.50",
22
+ "@prisma-next/tsdown": "0.12.0-dev.50",
23
23
  "arktype": "^2.2.0",
24
24
  "tsdown": "0.22.0",
25
25
  "typescript": "5.9.3",
@@ -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,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 {
@@ -58,6 +61,7 @@ import {
58
61
  mapFieldNamesToColumns,
59
62
  parseAttributeFieldList,
60
63
  parseConstraintMapArgument,
64
+ parseControlPolicyAttribute,
61
65
  parseMapName,
62
66
  parseObjectLiteralStringMap,
63
67
  parseQuotedStringLiteral,
@@ -229,10 +233,6 @@ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
229
233
  * slot empty (which means the late-bound default at the `StorageTable`
230
234
  * layer; emitted JSON omits the field).
231
235
  */
232
- function defaultSqlNamespaceIdForTarget(targetId: string): string {
233
- return targetId === 'postgres' ? 'public' : UNBOUND_NAMESPACE_ID;
234
- }
235
-
236
236
  function resolveNamespaceIdForSqlTarget(input: {
237
237
  readonly bucketName: string;
238
238
  readonly targetId: string;
@@ -647,6 +647,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
647
647
  : undefined;
648
648
  const hasInlinePrimaryKey = primaryKey !== undefined;
649
649
  let blockPrimaryKeyDeclared = false;
650
+ let controlPolicyDeclared = false;
651
+ let controlPolicy: ControlPolicy | undefined;
650
652
 
651
653
  const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
652
654
  for (const field of model.fields) {
@@ -733,6 +735,27 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
733
735
  if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
734
736
  continue;
735
737
  }
738
+ if (modelAttribute.name === 'control') {
739
+ if (controlPolicyDeclared) {
740
+ diagnostics.push({
741
+ code: 'PSL_DUPLICATE_ATTRIBUTE',
742
+ message: `\`@@control\` declared more than once on model "${model.name}".`,
743
+ sourceId,
744
+ span: modelAttribute.span,
745
+ });
746
+ continue;
747
+ }
748
+ controlPolicyDeclared = true;
749
+ const parsed = parseControlPolicyAttribute({
750
+ attribute: modelAttribute,
751
+ sourceId,
752
+ diagnostics,
753
+ });
754
+ if (parsed !== undefined) {
755
+ controlPolicy = parsed;
756
+ }
757
+ continue;
758
+ }
736
759
  const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
737
760
  if (modelAttribute.name === 'id') {
738
761
  if (blockPrimaryKeyDeclared) {
@@ -1103,6 +1126,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
1103
1126
  ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
1104
1127
  ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
1105
1128
  ...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
1129
+ ...ifDefined('control', controlPolicy),
1106
1130
  },
1107
1131
  fkRelationMetadata: resultFkRelationMetadata,
1108
1132
  backrelationCandidates: resultBackrelationCandidates,
@@ -1310,12 +1334,27 @@ function resolvePolymorphism(
1310
1334
  modelNames: Set<string>,
1311
1335
  modelMappings: ReadonlyMap<string, ModelNameMapping>,
1312
1336
  modelNamespaceIds: ReadonlyMap<string, string>,
1313
- targetId: string,
1337
+ defaultNamespaceId: string,
1338
+ syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
1339
+ stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
1314
1340
  sourceId: string,
1315
1341
  diagnostics: ContractSourceDiagnostic[],
1316
1342
  ): Record<string, ContractModel> {
1317
1343
  let patched = models;
1318
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
+
1319
1358
  for (const [modelName, decl] of discriminatorDeclarations) {
1320
1359
  if (baseDeclarations.has(modelName)) {
1321
1360
  diagnostics.push({
@@ -1410,22 +1449,211 @@ function resolvePolymorphism(
1410
1449
  variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
1411
1450
  const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
1412
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
+
1413
1461
  patched = {
1414
1462
  ...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
- },
1463
+ [variantName]: stripStorageOnlyDomainFields(
1464
+ patchedVariant,
1465
+ syntheticPkFieldsByVariant.get(variantName) ?? [],
1466
+ ),
1423
1467
  };
1424
1468
  }
1425
1469
 
1426
1470
  return patched;
1427
1471
  }
1428
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
+
1429
1657
  export function interpretPslDocumentToSqlContract(
1430
1658
  input: InterpretPslDocumentToSqlContractInput,
1431
1659
  ): Result<Contract, ContractSourceDiagnostics> {
@@ -1632,6 +1860,27 @@ export function interpretPslDocumentToSqlContract(
1632
1860
  diagnostics,
1633
1861
  );
1634
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
+
1635
1884
  const valueObjects = buildValueObjects({
1636
1885
  compositeTypes,
1637
1886
  enumTypeDescriptors: allEnumTypeDescriptors,
@@ -1667,7 +1916,7 @@ export function interpretPslDocumentToSqlContract(
1667
1916
  ? { namespaceTypes: namespaceEnumStorageTypes }
1668
1917
  : {}),
1669
1918
  ...ifDefined('createNamespace', input.createNamespace),
1670
- models: modelNodes.map((model) => ({
1919
+ models: stiColumnModelNodes.map((model) => ({
1671
1920
  ...model,
1672
1921
  ...(modelRelations.has(model.modelName)
1673
1922
  ? {
@@ -1687,7 +1936,7 @@ export function interpretPslDocumentToSqlContract(
1687
1936
  `duplicate model name "${modelName}" across domain namespaces during PSL interpretation`,
1688
1937
  );
1689
1938
  }
1690
- modelsForPatch[modelName] = model as ContractModel;
1939
+ modelsForPatch[modelName] = model;
1691
1940
  }
1692
1941
  }
1693
1942
  let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
@@ -1700,7 +1949,9 @@ export function interpretPslDocumentToSqlContract(
1700
1949
  modelNames,
1701
1950
  modelMappings,
1702
1951
  modelNamespaceIds,
1703
- input.target.targetId,
1952
+ input.target.defaultNamespaceId,
1953
+ syntheticPkFieldsByVariant,
1954
+ stiBaseFieldsByBase,
1704
1955
  sourceId,
1705
1956
  polyDiagnostics,
1706
1957
  );
@@ -1736,7 +1987,7 @@ export function interpretPslDocumentToSqlContract(
1736
1987
  ...(namespaceSlice.valueObjects !== undefined
1737
1988
  ? { valueObjects: namespaceSlice.valueObjects }
1738
1989
  : {}),
1739
- ...(namespaceId === defaultSqlNamespaceIdForTarget(input.target.targetId) &&
1990
+ ...(namespaceId === input.target.defaultNamespaceId &&
1740
1991
  Object.keys(valueObjects).length > 0
1741
1992
  ? { valueObjects }
1742
1993
  : {}),
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),
@@ -1,4 +1,5 @@
1
1
  import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
2
+ import type { ControlPolicy } from '@prisma-next/contract/types';
2
3
  import type { PslAttribute, PslSpan } from '@prisma-next/psl-parser';
3
4
  import { getPositionalArgument, parseQuotedStringLiteral } from '@prisma-next/psl-parser';
4
5
 
@@ -411,6 +412,71 @@ export function findDuplicateFieldName(fieldNames: readonly string[]): string |
411
412
  return undefined;
412
413
  }
413
414
 
415
+ const CONTROL_POLICY_LITERALS = [
416
+ 'managed',
417
+ 'tolerated',
418
+ 'external',
419
+ 'observed',
420
+ ] as const satisfies readonly ControlPolicy[];
421
+
422
+ const CONTROL_POLICY_LITERAL_SET = new Set<string>(CONTROL_POLICY_LITERALS);
423
+
424
+ function isControlPolicyLiteral(value: string): value is ControlPolicy {
425
+ return CONTROL_POLICY_LITERAL_SET.has(value);
426
+ }
427
+
428
+ export function parseControlPolicyAttribute(input: {
429
+ readonly attribute: PslAttribute;
430
+ readonly sourceId: string;
431
+ readonly diagnostics: ContractSourceDiagnostic[];
432
+ }): ControlPolicy | undefined {
433
+ const namedArgs = input.attribute.args.filter((arg) => arg.kind === 'named');
434
+ if (namedArgs.length > 0) {
435
+ input.diagnostics.push({
436
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
437
+ message:
438
+ '`@@control` does not accept named arguments; pass the policy positionally as `@@control(external)`.',
439
+ sourceId: input.sourceId,
440
+ span: input.attribute.span,
441
+ });
442
+ return undefined;
443
+ }
444
+
445
+ const positionalArgs = getPositionalArguments(input.attribute);
446
+ if (positionalArgs.length === 0) {
447
+ input.diagnostics.push({
448
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
449
+ message:
450
+ '`@@control` requires exactly one positional argument: `managed`, `tolerated`, `external`, or `observed`.',
451
+ sourceId: input.sourceId,
452
+ span: input.attribute.span,
453
+ });
454
+ return undefined;
455
+ }
456
+ if (positionalArgs.length > 1) {
457
+ input.diagnostics.push({
458
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
459
+ message: `\`@@control\` accepts exactly one positional argument; got ${positionalArgs.length}.`,
460
+ sourceId: input.sourceId,
461
+ span: input.attribute.span,
462
+ });
463
+ return undefined;
464
+ }
465
+
466
+ const token = unquoteStringLiteral(positionalArgs[0] ?? '').trim();
467
+ if (!isControlPolicyLiteral(token)) {
468
+ input.diagnostics.push({
469
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
470
+ message: `\`@@control\` argument \`${token}\` is not a known policy. Allowed: \`managed\`, \`tolerated\`, \`external\`, \`observed\`.`,
471
+ sourceId: input.sourceId,
472
+ span: input.attribute.span,
473
+ });
474
+ return undefined;
475
+ }
476
+
477
+ return token;
478
+ }
479
+
414
480
  export function mapFieldNamesToColumns(input: {
415
481
  readonly modelName: string;
416
482
  readonly fieldNames: readonly string[];
@@ -25,6 +25,7 @@ import type {
25
25
  PslSpan,
26
26
  PslTypeConstructorCall,
27
27
  } from '@prisma-next/psl-parser';
28
+ import { blindCast } from '@prisma-next/utils/casts';
28
29
  import {
29
30
  lowerDefaultFunctionWithRegistry,
30
31
  parseDefaultFunctionCall,
@@ -67,7 +68,9 @@ export function getAuthoringTypeConstructor(
67
68
  if (typeof current !== 'object' || current === null || Array.isArray(current)) {
68
69
  return undefined;
69
70
  }
70
- current = (current as Record<string, unknown>)[segment];
71
+ current = blindCast<Record<string, unknown>, 'narrowed by preceding typeof/null/array guards'>(
72
+ current,
73
+ )[segment];
71
74
  }
72
75
 
73
76
  return isAuthoringTypeConstructorDescriptor(current) ? current : undefined;
@@ -94,7 +97,9 @@ export function getAuthoringEntity(
94
97
  if (typeof current !== 'object' || current === null || Array.isArray(current)) {
95
98
  return undefined;
96
99
  }
97
- current = (current as Record<string, unknown>)[segment];
100
+ current = blindCast<Record<string, unknown>, 'narrowed by preceding typeof/null/array guards'>(
101
+ current,
102
+ )[segment];
98
103
  }
99
104
 
100
105
  return isAuthoringEntityTypeDescriptor(current) ? current : undefined;
@@ -115,7 +120,9 @@ export function getAuthoringFieldPreset(
115
120
  if (typeof current !== 'object' || current === null || Array.isArray(current)) {
116
121
  return undefined;
117
122
  }
118
- current = (current as Record<string, unknown>)[segment];
123
+ current = blindCast<Record<string, unknown>, 'narrowed by preceding typeof/null/array guards'>(
124
+ current,
125
+ )[segment];
119
126
  }
120
127
 
121
128
  return isAuthoringFieldPresetDescriptor(current) ? current : undefined;