import { BINARY_TYPE_DENYLIST, ScaffoldEntityInputSchema, type EntityRelation, type ValidationResult } from './types.js' import { CORE_WHITELIST_V1 } from './generate.js' import { PERSON_IDENTITY_ATTRIBUTES, matchCoreEntity, matchReservedCoreName, } from '../../../../../lib/core-catalog.js' import { referencedFields } from '../../../../../lib/code-pattern-grammar.js' import { knownIndexColumns as sharedKnownIndexColumns } from '../../../../../lib/entity-columns.js' /** * Words that are NEVER a business table prefix, even though they pass the * `domainPrefix` format regex. `ext` is the extensions-schema *migration* prefix * (efcore/migration-name → `ext_v1_001_…`); `extensions`/`core` are *schema* * names. Passing any of them yields a wrong table like `[extensions].[ext_X]` — * the recurring bug. The real prefix is the entity's `**Préfixe table**` from * `entité.md` (`ref_`, `aff_`, `cli_`…). Guarded here so it can never be emitted. */ const RESERVED_TABLE_PREFIXES = new Set(['ext', 'extensions', 'core']) /** FK column name an owning relation (many-to-one / one-to-one) maps to. */ function fkNameOf(rel: EntityRelation): string { return rel.foreignKey ?? `${rel.targetEntity}Id` } /** Effective referential action: explicit `onDelete` wins, else legacy boolean, else restrict. */ function onDeleteOf(rel: EntityRelation): 'restrict' | 'cascade' | 'set-null' | 'no-action' { return rel.onDelete ?? (rel.cascadeDelete ? 'cascade' : 'restrict') } export function validate(raw: unknown): ValidationResult { const result = ScaffoldEntityInputSchema.safeParse(raw) if (!result.success) return { valid: false, errors: result.error.issues.map(i => `[${i.path.join('.')}] ${i.message}`), warnings: [] } const spec = result.data const warnings: string[] = [] const errors: string[] = [] // ─── Table-prefix guard (fail-closed) — `domainPrefix` is the business domain, never a reserved word ─── // Deterministic backstop for the recurring `[extensions].[ext_X]` bug: the // table is `${domainPrefix}_${Plural}`, and `ext`/`extensions`/`core` pass the // format regex but are the migration prefix / schema names, not a table prefix. if (RESERVED_TABLE_PREFIXES.has(spec.domainPrefix.toLowerCase())) { errors.push( `domainPrefix "${spec.domainPrefix}" is reserved and must not be a table prefix: ` + `"ext" is the extensions-schema MIGRATION prefix (efcore/migration-name), ` + `"extensions"/"core" are schema names — none is a business-domain prefix. ` + `Use the entity's **Préfixe table** from entité.md (e.g. "ref_" → "ref", "aff_" → "aff") ` + `with the trailing underscore stripped. The schema stays "extensions" (schemaTarget); ` + `the per-entity domainPrefix is the functional domain, so the table is e.g. "ref_${spec.pluralName ?? spec.name}".` ) } const reserved = ['Id', 'CreatedAt', 'UpdatedAt', 'DeletedAt', 'TenantId'] for (const f of spec.fields) { if (reserved.includes(f.name)) warnings.push(`"${f.name}" inherited from BaseEntity — skipped`) } // ─── Binary-content guard (fail-closed) — file content never goes in the DB ─── // Without this, `binary` slips through csType's raw fallback and emits // `public binary X { get; private set; }` — uncompilable C#. The platform // already ships the storage primitive; the entity carries METADATA only. for (const f of spec.fields) { if (BINARY_TYPE_DENYLIST.has(f.type.toLowerCase())) { errors.push( `field "${f.name}": type "${f.type}" is not scaffoldable — file content never goes in the DB. ` + `Model a METADATA entity (FileName string/256, StoredFileName string/500 unique, ContentType string/100, ` + `FileSizeBytes long + parent FK) and store the bytes via the platform IFileStorageService ` + `(Scoped via AddSmartStack, injectable from any extension handler). ` + `Full pattern: development/backend/data-layer/references/file-storage.md (gates C-6 / DM-019 / PRD-053).` ) } } // ─── Core-duplication guard (fail-closed) — extension entities must never recreate Core ─── // Whole-token name/alias match against lib/core-catalog.ts. Scoped to the // `extensions` schema: scaffolding `schemaTarget: 'core'` is platform-internal. if (spec.schemaTarget === 'extensions') { for (const candidate of [spec.name, ...(spec.pluralName ? [spec.pluralName] : [])]) { const core = matchCoreEntity(candidate) if (core) { const registryNote = core.name === 'TenantOrganisation' ? ' — the shared organisation directory' : '' errors.push( `entity "${candidate}": duplicates SmartStack Core ${core.name} (${core.qualifiedTable}${registryNote}). ` + `Do NOT recreate it. Reference it instead: relations: [{ "type": "many-to-one", "targetEntity": "${core.name}", "targetScope": "core" }] ` + `(real cross-schema FK + navigation via SmartStackExtensionDbContext), or model an extension entity ` + `under a different name carrying that FK + only net-new fields.` ) break } const blocked = matchReservedCoreName(candidate) if (blocked) { errors.push( `entity "${candidate}": collides with the reserved SmartStack Core entity ${blocked.name}. ` + `It is not FK-able and must not be a client table — use ${blocked.useInstead}. ` + `See docs/extensions/cross-context-references.md.` ) break } } // Heuristic (warning only — person mode is a BA decision): identity fields // redeclare core.auth_Users when the entity has no User link at all. const identitySet = new Set(PERSON_IDENTITY_ATTRIBUTES.map(a => a.toLowerCase())) const identityFields = spec.fields.filter(f => identitySet.has(f.name.toLowerCase())) if (identityFields.length >= 2) { const hasUserRelation = spec.relations.some(r => (r.type === 'many-to-one' || r.type === 'one-to-one') && r.targetEntity === 'User' && r.targetScope === 'core') const hasUserIdField = spec.fields.some(f => f.name.toLowerCase() === 'userid' && f.type.toLowerCase() === 'guid') if (!hasUserRelation && !hasUserIdField) { warnings.push( `entity "${spec.name}": declares ${identityFields.length} identity fields (${identityFields.map(f => f.name).join(', ')}) ` + `with no User link — Core auth_Users already carries them. If this is a person entity, link it ` + `(FK UserId, targetScope "core") and drop the redeclared fields (person: mandatory) or keep them ` + `local with a nullable FK (person: optional).` ) } } } // ─── Relations: validate the OWNING side (the one that carries the FK column) ─── const owning = spec.relations.filter(r => r.type === 'many-to-one' || r.type === 'one-to-one') const fieldNames = new Map(spec.fields.map(f => [f.name.toLowerCase(), f])) const seenFk = new Set() for (const rel of owning) { const fk = fkNameOf(rel) const action = onDeleteOf(rel) const scope = rel.targetScope ?? 'same-module' if (scope === 'core' && !CORE_WHITELIST_V1.has(rel.targetEntity)) { errors.push( `relation → ${rel.targetEntity}: targetScope "core" is restricted to the V1 whitelist ` + `(User, Role, Tenant, TenantOrganisation, Department, JobTitle, Office, Language, Group). ` + `"${rel.targetEntity}" is NOT whitelisted. Inject ICoreDataService (lookup helpers) ` + `or open an issue per docs/extensions/whitelist-evolution.md to propose adding it.` ) } if (reserved.includes(fk)) errors.push(`relation → ${rel.targetEntity}: foreign key "${fk}" collides with a BaseEntity property`) if (action === 'set-null' && !rel.nullable) errors.push(`relation → ${rel.targetEntity}: onDelete "set-null" requires nullable: true`) if (seenFk.has(fk.toLowerCase())) errors.push(`duplicate foreign key "${fk}" across relations — give one an explicit distinct foreignKey`) seenFk.add(fk.toLowerCase()) const collide = fieldNames.get(fk.toLowerCase()) if (collide && collide.type.toLowerCase() !== 'guid') { errors.push(`relation → ${rel.targetEntity}: foreign key "${fk}" collides with a non-Guid field of the same name`) } else if (collide) { warnings.push(`relation → ${rel.targetEntity}: reuses the existing "${fk}" Guid field as the FK column (no duplicate emitted)`) } } // Cross-module / core scope only makes sense on the OWNING side (this entity holds // the FK column). A collection relation cannot point outside the module. for (const rel of spec.relations) { if ((rel.type === 'one-to-many' || rel.type === 'many-to-many') && rel.targetScope && rel.targetScope !== 'same-module') { errors.push(`relation → ${rel.targetEntity}: targetScope "${rel.targetScope}" is only valid on a many-to-one / one-to-one (owning) relation`) } if ((rel.type === 'one-to-many' || rel.type === 'many-to-many') && rel.unique) { errors.push(`relation → ${rel.targetEntity}: "unique" only applies to an OWNING relation (many-to-one / one-to-one — the side carrying the FK column)`) } } // ─── Declared indexes (entité.md **Index** carried verbatim) ─── // Every referenced column must exist — a typo'd index field would emit a // HasIndex over a phantom property and fail the .NET build far from here. // Known columns = declared fields ∪ synthesized FK columns ∪ scope columns. // ONE definition with audit-ba DM-023 (lib/entity-columns): the BA gate // now refuses the same shape six phases earlier, and the two must not drift. const knownIndexColumns = sharedKnownIndexColumns({ fields: spec.fields.map(f => f.name), owningFks: owning.map(r => fkNameOf(r)), extra: [ ...(spec.dataScope && spec.dataScope.mode !== 'assigned' ? [spec.dataScope.ownerProperty] : []), ...(spec.dataScope && spec.dataScope.mode !== 'own' ? [spec.dataScope.assignedProperty] : []), ], coded: Boolean(spec.codedEntity), }) for (const idx of spec.indexes ?? []) { for (const col of idx.fields) { if (!knownIndexColumns.has(col.toLowerCase())) { errors.push( `index (${idx.fields.join(', ')})${idx.unique ? ' unique' : ''}: column "${col}" matches no declared field, ` + `synthesized FK or scope column — fix the **Index** declaration or the spec`, ) } } } // ─── Data scope (own/assigned) — ownership column sanity ─── // The columns the DataScopePolicy filters on must be plain Guid columns. if (spec.dataScope) { const ds = spec.dataScope const scopeProps = [ ...(ds.mode !== 'assigned' ? [{ prop: ds.ownerProperty, label: 'ownerProperty' }] : []), ...(ds.mode !== 'own' ? [{ prop: ds.assignedProperty, label: 'assignedProperty' }] : []), ] for (const { prop, label } of scopeProps) { if (reserved.includes(prop)) { errors.push(`dataScope.${label} "${prop}" collides with a BaseEntity/tenant property — pick a dedicated ownership column`) } const declared = fieldNames.get(prop.toLowerCase()) if (declared && declared.type.toLowerCase() !== 'guid') { errors.push(`dataScope.${label} "${prop}" collides with a non-Guid field of the same name — the DataScopePolicy Visibility expression compares it to the current user id`) } else if (declared) { warnings.push(`dataScope.${label}: reuses the existing "${prop}" Guid field as the ownership column (no duplicate emitted)`) } } if (ds.mode === 'own-assigned' && ds.ownerProperty === ds.assignedProperty) { errors.push(`dataScope: ownerProperty and assignedProperty are both "${ds.ownerProperty}" — own-assigned needs two distinct columns`) } if (spec.schemaTarget === 'core') { warnings.push('dataScope on schemaTarget "core" — platform entities carry their policies in SmartStack.app; this seam targets client extensions') } warnings.push( `dataScope "${ds.mode}": this emits the COLUMN half only. Run cli/scaffold-data-scope for the same entity ` + `to generate the {Entity}ScopePolicy + ApplyDataScopeFilter + DI wiring — without it the ${spec.name} lists return every row.` ) } // ─── Coded entity — the Code column is engine-owned ─── if (spec.codedEntity) { if (fieldNames.has('code')) { errors.push( `codedEntity: a "Code" field is also declared in fields[] — the coded-entities seam OWNS the Code column ` + `(engine-allocated at insert); remove the manual field.` ) } // ─── format → GetCodeInputs coherence (the derived-token runtime trap) ─── if (!spec.codedEntity.format) { warnings.push( `codedEntity "${spec.codedEntity.codeKey}": no format passed — GetCodeInputs() is emitted EMPTY, so any ` + `derived-token format ({ABBR:Champ:n}, {SLUG:Champ}, …) fails at allocation time. Pass the same mask as ` + `the descriptor's defaultFormat (source: the entité.md **Code pattern** line) so the referenced fields ` + `are surfaced to the engine.` ) } else { for (const ref of referencedFields(spec.codedEntity.format)) { const field = spec.fields.find(f => f.name.toLowerCase() === ref.toLowerCase()) if (field && !field.formula) continue if (field?.formula) { errors.push( `codedEntity format references {…:${ref}} but "${field.name}" is a COMPUTED field (formula) — it has ` + `no Domain property, so GetCodeInputs() cannot surface it. Derive the code from a stored scalar field.` ) continue } errors.push( `codedEntity format references {…:${ref}} but no scalar field of that name exists on the entity — ` + `the engine receives no input and the allocation FAILS at insert. Derive from one of the entity's own ` + `stored fields (a relation/navigation is not loadable at SaveChanges time — snapshot the needed value ` + `as a scalar field if the code must carry parent data).` ) } } if (spec.codedEntity.unique === false) { warnings.push( `codedEntity "${spec.codedEntity.codeKey}": unique: false opts out of the socle pattern (unique index on Code) — ` + `the DB loses its safety net under the allocation engine. Only valid for a deliberately non-unique pattern ` + `(e.g. a reset period without a date token in the format); document why in entité.md.` ) } warnings.push( `codedEntity "${spec.codedEntity.codeKey}": this emits the ENTITY half only (Code column + ICodedEntity). ` + `Run cli/scaffold-coded-entity for the same entity to register the ICodeKeyDescriptor (AddSmartStackCodeKey) — ` + `without it the key falls back to nothing and allocation FAILS at insert. The DbContext ctor must forward IServiceProvider.` ) } // ─── Versioned entity — the RowVersion column is EF/DB-owned ─── if (spec.versioned) { if (fieldNames.has('rowversion')) { errors.push( `versioned: a "RowVersion" field is also declared in fields[] — the versioned seam OWNS the RowVersion column ` + `(SQL Server rowversion concurrency token, EF/DB-managed — never set by hand); remove the manual field.` ) } warnings.push( `versioned: adds a rowversion column to ${spec.name} — schema change requires an EF extension migration. ` + `Create/apply it via /efcore (governed; NEVER auto-run). Pass the same versioned: true to scaffold-business ` + `for this entity so the Update path echoes RowVersion and surfaces stale offline writes as 409.` ) } return { valid: errors.length === 0, errors, warnings } }