/** * cli:scaffold-entity — generate.ts * * Generates a Domain entity + its EF Core Configuration, against the VERIFIED * package + project-shim contract (namespaces checked against SmartStack.app — * the historical `SmartStack.Core.Domain` emission compiled only because each * /ba-develop run auto-healed it): * - {ns}.Domain.Common.ExtensionBaseEntity — PROJECT-LOCAL shim shipped by * `ss init` (ExtensionBaseEntity.cs.template): soft-delete (DeletedAt) + * domain events on top of the package's SmartStack.Domain.Common.BaseEntity * (which provides Id, CreatedAt, UpdatedAt, ExtensionData only). * - SmartStack.Domain.Common — ITenantEntity / IOptionalTenantEntity, * IOwnedEntity / IAssignedEntity (dataScope markers). * - SmartStack.Domain.Support.Events.IDomainEvent — requires OccurredAt, so * every event record carries `(…, DateTime OccurredAt)`. * - {ns}.Infrastructure.Persistence.SchemaConstants — PROJECT-LOCAL shim * (SchemaConstants.cs.template), resolved via the enclosing namespace of * the generated configuration (no using needed). * - SmartStack.Infrastructure.Persistence.Extensions.SmartStackExtensionDbContext * (V3.55+) — base class that exposes the V1 Core whitelist (User, Role, * Tenant, TenantOrganisation, Department, JobTitle, Office, Language, Group) via * ExcludeFromMigrations, so client entities can declare real navigation * properties + SQL foreign keys to those Core principals. * * Conventions enforced: * - Private parameterless ctor + static Create() factory (no public setters) * - Update() validates required string fields * - SoftDelete()/Restore() methods (DeletedAt-based) * - Table = "{domainPrefix}_{PluralName}" in schemaTarget ('core' for SmartStack * built-ins, 'extensions' for client modules) * - Indexes on CreatedAt (common query filter) and any field flagged `indexed: true` * - Core refs are gated by the V1 whitelist (see CORE_WHITELIST_V1 below); * anything outside is rejected by validate.ts with a link to the whitelist * evolution process. */ import { pluralize } from '../../../../../lib/string-utils.js' import { referencedFields } from '../../../../../lib/code-pattern-grammar.js' import { CORE_WHITELIST_V1, CORE_WHITELIST_V1_NAMESPACES } from '../../../../../lib/core-catalog.js' import { domainEntitiesDir, configurationsDir, legacyDomainEntitiesDir, legacyConfigurationsDir, } from '../../../../../lib/app-classification.js' import { BINARY_TYPE_DENYLIST, type ScaffoldEntityInput, type GeneratedFile, type EntityField, type EntityRelation } from './types.js' /** * V1 whitelist of SmartStack Core entities a client extension may reference via * real navigation property + FK, and the namespace each ships under. Both now * live in the canonical `lib/core-catalog.ts` (single source of truth shared * with the BA skills' inline tables and the codegen guards) — re-exported here * so existing consumers (`validate.ts`, tests) keep their import path. */ export { CORE_WHITELIST_V1, CORE_WHITELIST_V1_NAMESPACES } export function generate(spec: ScaffoldEntityInput): GeneratedFile[] { const ns = spec.namespace ?? spec.appCode const reserved = new Set(['Id', 'CreatedAt', 'UpdatedAt', 'DeletedAt', 'TenantId']) // Computed fields (with `formula`) are NOT emitted as Domain properties — they // live only in the read DTOs where scaffold-business injects the formula into // the LINQ projection. EF Configuration also ignores them (no column mapping). const userFields = spec.fields.filter(f => !reserved.has(f.name) && !f.formula) // ─── Relations → FK columns + navigation properties ─── // Owning side (many-to-one / one-to-one) carries the FK column on THIS entity; // the collection side (one-to-many / many-to-many) only gets an inverse nav. // EVERY reference becomes a REAL FK constraint; how the navigation is realized // depends on the target's scope: // - same-module → FK + nav property (lambda) // - core, target in CORE_WHITELIST_V1 → FK + nav property (lambda), // resolved against the base class (SmartStackExtensionDbContext); // NO local stub is generated — the base class owns the ExcludeFromMigrations // mapping centrally. // - cross-module → FK to the real target type, // NO navigation (decoupled across modules; both tables in `extensions`). // - core, target NOT in CORE_WHITELIST_V1 → rejected by validate.ts. const owningRels = spec.relations.filter(r => r.type === 'many-to-one' || r.type === 'one-to-one') const collectionRels = spec.relations.filter(r => r.type === 'one-to-many' || r.type === 'many-to-many') const navOwning = owningRels.filter(hasLambdaNav) // same-module + core-whitelist const typeOnlyOwning = owningRels.filter(r => !hasLambdaNav(r)) // cross-module const present = new Set(userFields.map(f => f.name.toLowerCase())) // Synthesize a Guid FK column per owning relation so the Create/Update/column // logic treats it like any other field. Skip when the spec already declares the // Guid field by hand — reuse it, only add the config. // FK columns are NOT flagged indexed (EF Core auto-creates the FK index) but // they DO carry the relation's declared UNIQUENESS: the §27 hole was exactly // this synthesis dropping `unique` — 7/23 declared unique indexes (all // FK-bearing) silently vanished, leaving BR-level uniqueness on a racy // app-layer AnyAsync blind to soft-deleted rows. const fkFields: EntityField[] = [] // Case-INSENSITIVE, mirroring the `present` lookup below: a hand-declared // `vehicleId` satisfies the presence check, so a case-sensitive match here // silently dropped the relation's declared uniqueness (§27 again). const uniqueFkNames = new Set(owningRels.filter(r => r.unique).map(r => fkName(r).toLowerCase())) for (const rel of owningRels) { const fk = fkName(rel) if (present.has(fk.toLowerCase())) continue fkFields.push({ name: fk, type: 'guid', required: !rel.nullable, isKey: false, indexed: false, unique: rel.unique === true }) present.add(fk.toLowerCase()) } // Hand-declared FK Guid reused by the relation: the relation's `unique` still // applies. COPY the field — writing `f.unique` through would mutate the // caller's spec object (the same parsed spec Phase 1 reuses downstream). for (let i = 0; i < userFields.length; i++) { const f = userFields[i]! if (!f.unique && uniqueFkNames.has(f.name.toLowerCase())) userFields[i] = { ...f, unique: true } } // ─── Data scope (own/assigned) → ownership columns ─── // The BA RBAC matrix planned a scoped `read` — synthesize the column(s) the // DataScopePolicy's Visibility expression filters on. Indexed: the named // "DataScope" query filter hits them on EVERY list query. Reuse a hand-declared // field of the same name (validate.ts enforces it is a Guid). const scope = spec.dataScope const scopeOwns = scope?.mode === 'own' || scope?.mode === 'own-assigned' const scopeAssigns = scope?.mode === 'assigned' || scope?.mode === 'own-assigned' const scopeFields: EntityField[] = [] if (scope && scopeOwns && !present.has(scope.ownerProperty.toLowerCase())) { scopeFields.push({ name: scope.ownerProperty, type: 'guid', required: true, isKey: false, indexed: true }) present.add(scope.ownerProperty.toLowerCase()) } if (scope && scopeAssigns && !present.has(scope.assignedProperty.toLowerCase())) { scopeFields.push({ name: scope.assignedProperty, type: 'guid', required: false, isKey: false, indexed: true }) present.add(scope.assignedProperty.toLowerCase()) } const columns = [...userFields, ...fkFields, ...scopeFields] const reqFields = columns.filter(f => f.required) // Optional CREATION fields (non-key, non-phased): defaulted factory params + // initializer lines — the Create-honnête contract (an optional value typed on // the create form used to be silently lost against the required-only Create // surface). Lifecycle-phased fields stay Update-only by construction. const optionalCreateFields = columns.filter(f => !f.required && !f.isKey && !f.phase) // The owner column is set ONCE at Create() and never flows through Update() — // reassigning ownership would silently move the row across users' scopes. The // assigned column IS updatable (reassignment is the business gesture). const updatableFields = columns.filter(f => !f.isKey && !(scope && scopeOwns && f.name === scope.ownerProperty)) // Navigation properties: same-module + core-whitelist owning relations get a // reference nav. Cross-module references intentionally carry no nav. const navProps = [ ...navOwning.map(rel => ` public ${rel.targetEntity}${rel.nullable ? '?' : ''} ${refNavName(rel)} { get; private set; }${rel.nullable ? '' : ' = null!;'}`), ...collectionRels.map(rel => ` public ICollection<${rel.targetEntity}> ${collNavName(rel)} { get; private set; } = new List<${rel.targetEntity}>();`), ] const navBlock = navProps.length ? '\n\n' + navProps.join('\n') : '' // Collect the Core whitelist namespaces we need to import (entity + config). // The Tenant FK (when tenantMode != 'none') always pulls in SmartStack Tenant. const coreUsings = new Set() if (spec.tenantMode !== 'none') addCoreUsing(coreUsings, 'Tenant') for (const rel of owningRels) { if (relScope(rel) === 'core' && CORE_WHITELIST_V1.has(rel.targetEntity)) { addCoreUsing(coreUsings, rel.targetEntity) } } const coreUsingBlock = [...coreUsings].sort().map(n => `using ${n};`).join('\n') // EF Core relationship configuration — the REAL FK constraint + declared cascade. const relConfig: string[] = [] // Tenant — real cross-schema FK to core.tenant_Tenants via the base-class // Tenant DbSet (ExcludeFromMigrations). Previously this required a local // TenantReference stub; the base class now provides the mapping centrally. if (spec.tenantMode !== 'none') { relConfig.push(` builder.HasOne().WithMany().HasForeignKey(e => e.TenantId).OnDelete(DeleteBehavior.Restrict);`) } for (const rel of navOwning) { relConfig.push(rel.type === 'one-to-one' ? ` builder.HasOne(e => e.${refNavName(rel)}).WithOne().HasForeignKey<${spec.name}>(e => e.${fkName(rel)}).OnDelete(DeleteBehavior.${onDeleteBehavior(rel)});` : ` builder.HasOne(e => e.${refNavName(rel)}).WithMany().HasForeignKey(e => e.${fkName(rel)}).OnDelete(DeleteBehavior.${onDeleteBehavior(rel)});`) } // Cross-module owning — type-based HasOne, NO navigation. Both tables live in // the `extensions` schema / same DbContext, so EF resolves the principal type // without a stub. for (const rel of typeOnlyOwning) { relConfig.push(rel.type === 'one-to-one' ? ` builder.HasOne<${rel.targetEntity}>().WithOne().HasForeignKey<${spec.name}>(e => e.${fkName(rel)}).OnDelete(DeleteBehavior.${onDeleteBehavior(rel)});` : ` builder.HasOne<${rel.targetEntity}>().WithMany().HasForeignKey(e => e.${fkName(rel)}).OnDelete(DeleteBehavior.${onDeleteBehavior(rel)});`) } for (const rel of collectionRels) { relConfig.push(rel.type === 'many-to-many' ? ` builder.HasMany(e => e.${collNavName(rel)}).WithMany();` : ` builder.HasMany(e => e.${collNavName(rel)}).WithOne().OnDelete(DeleteBehavior.${onDeleteBehavior(rel)});`) } const relBlock = relConfig.length ? '\n\n' + relConfig.join('\n') : '' // ─── Data scope → marker interfaces (IOwnedEntity / IAssignedEntity) ─── // Verified package contract: both live in SmartStack.Domain.Common and expose // `Guid OwnerUserId` / `Guid? AssignedToUserId`. They are documentation-grade // markers (no automatic convention scans them) — the actual filtering contract // is the DataScopePolicy.Visibility expression emitted by scaffold-data-scope. // A custom column name satisfies the interface via an explicit member. const scopeIfaces = [scope && scopeOwns ? ', IOwnedEntity' : '', scope && scopeAssigns ? ', IAssignedEntity' : ''].join('') const scopeIfaceImpls: string[] = [] if (scope && scopeOwns && scope.ownerProperty !== 'OwnerUserId') { scopeIfaceImpls.push(` Guid IOwnedEntity.OwnerUserId => ${scope.ownerProperty};`) } if (scope && scopeAssigns && scope.assignedProperty !== 'AssignedToUserId') { scopeIfaceImpls.push(` Guid? IAssignedEntity.AssignedToUserId => ${scope.assignedProperty};`) } const scopeIfaceBlock = scopeIfaceImpls.length ? '\n\n' + scopeIfaceImpls.join('\n') : '' // ─── Coded entity (system-allocated business Code) ─── // The Code is ENGINE-assigned by the shared CodedEntitySaveHandler at insert // time — never a Create/Update input. Explicit interface members keep the // public surface to the `Code` property itself. const coded = spec.codedEntity const codedIface = coded ? ', ICodedEntity' : '' const codedProp = coded ? `\n /// System-allocated business code (pattern key "${coded.codeKey}") — assigned by the code-generation engine at insert.\n public string Code { get; private set; } = string.Empty;` : '' // GetCodeInputs feeds the derived tokens ({ABBR:Champ:n}, {SLUG:Champ}, …) // of the pattern's format: each referenced SCALAR field of the entity is // surfaced to the engine. The historical always-empty dictionary made every // derived-token format fail at allocation time. Only non-computed fields // resolve (a `formula` field has no Domain property); validate errs on an // unresolvable reference, so the filter here never silently drops one. const codedInputFields = coded?.format ? referencedFields(coded.format) .map((f) => spec.fields.find((x) => !x.formula && x.name.toLowerCase() === f.toLowerCase())?.name) .filter((x): x is string => x !== undefined) : [] const codedInputsLiteral = codedInputFields.length ? `new Dictionary\n {\n${codedInputFields.map((n) => ` ["${n}"] = ${n},`).join('\n')}\n }` : 'new Dictionary()' const codedImplBlock = coded ? ` string ICodedEntity.CodeKey => "${coded.codeKey}"; bool ICodedEntity.HasCode => !string.IsNullOrWhiteSpace(Code); IReadOnlyDictionary ICodedEntity.GetCodeInputs() => ${codedInputsLiteral}; void ICodedEntity.ApplyCode(string code) => Code = code;` : '' // ─── Versioned entity (rowversion optimistic-concurrency token) ─── // The PWA offline-write 409 path (socle IVersionedEntity seam): EF checks the // write against the token the client last read; a stale outbox replay raises // DbUpdateConcurrencyException → HTTP 409 instead of silently overwriting. // The column is EF/DB-managed — never a Create()/Update() input, so it stays // OUT of `columns` (no factory/update parameter, no generic Property line). const versionedIface = spec.versioned ? ', IVersionedEntity' : '' const versionedProp = spec.versioned ? ` /// /// Optimistic-concurrency token (rowversion, see ) — EF/DB-managed, /// never set by hand. Enables 409 stale-write detection when an offline edit is replayed against /// a row changed server-side. /// public byte[] RowVersion { get; private set; } = Array.Empty();` : '' const tenantIface = spec.tenantMode === 'strict' ? ', ITenantEntity' : spec.tenantMode === 'optional' ? ', IOptionalTenantEntity' : '' const tenantProp = spec.tenantMode === 'strict' ? '\n public Guid TenantId { get; private set; }' : spec.tenantMode === 'optional' ? '\n public Guid? TenantId { get; private set; }' : '' const schemaConstant = spec.schemaTarget === 'core' ? 'SchemaConstants.Core' : 'SchemaConstants.Extensions' // Entity usings — verified contract: the tenant + data-scope marker interfaces // live in SmartStack.Domain.Common; the soft-delete/domain-events base // (ExtensionBaseEntity) is the project-local shim shipped by `ss init`. const needsDomainCommon = spec.tenantMode !== 'none' || !!scope || !!spec.versioned const entityUsings = [ ...(needsDomainCommon ? ['SmartStack.Domain.Common'] : []), ...(coded ? ['SmartStack.Domain.CodeGeneration'] : []), ...coreUsings, `${ns}.Domain.Common`, ].sort().map(n => `using ${n};`).join('\n') const entity = `${entityUsings} namespace ${ns}.Domain.Entities; public class ${spec.name} : ExtensionBaseEntity${tenantIface}${scopeIfaces}${codedIface}${versionedIface} {${tenantProp}${codedProp} ${columns.map(f => ` public ${csType(f)} ${f.name} { get; private set; }${defaultInitializer(f)}`).join('\n')}${navBlock}${versionedProp}${scopeIfaceBlock}${codedImplBlock} private ${spec.name}() { } public static ${spec.name} Create(${createParams(reqFields, optionalCreateFields, spec.tenantMode !== 'none')}) { ${reqFields.filter(f => f.type === 'string').map(f => ` ArgumentException.ThrowIfNullOrWhiteSpace(${camel(f.name)});`).join('\n')} var entity = new ${spec.name} { ${reqFields.map(f => ` ${f.name} = ${camel(f.name)},`).join('\n')}${spec.tenantMode !== 'none' ? '\n TenantId = tenantId,' : ''}${optionalCreateFields.length ? '\n' + optionalCreateFields.map(f => ` ${f.name} = ${camel(f.name)},`).join('\n') : ''} }; entity.AddDomainEvent(new ${spec.name}CreatedEvent(entity.Id, DateTime.UtcNow)); return entity; } public void Update(${updateParams(updatableFields)}) { ${updatableFields.filter(f => f.type === 'string' && f.required).map(f => ` ArgumentException.ThrowIfNullOrWhiteSpace(${camel(f.name)});`).join('\n')} ${updatableFields.map(f => ` ${f.name} = ${camel(f.name)};`).join('\n')} UpdatedAt = DateTime.UtcNow; AddDomainEvent(new ${spec.name}UpdatedEvent(Id, DateTime.UtcNow)); } public void SoftDelete() { if (DeletedAt.HasValue) return; DeletedAt = DateTime.UtcNow; AddDomainEvent(new ${spec.name}DeletedEvent(Id, DateTime.UtcNow)); } public void Restore() { if (!DeletedAt.HasValue) return; DeletedAt = null; AddDomainEvent(new ${spec.name}RestoredEvent(Id, DateTime.UtcNow)); } } ` // IDomainEvent (SmartStack.Domain.Support.Events) REQUIRES OccurredAt — every // record carries it positionally. const events = `using SmartStack.Domain.Support.Events; namespace ${ns}.Domain.Entities; public record ${spec.name}CreatedEvent(Guid ${spec.name}Id, DateTime OccurredAt) : IDomainEvent; public record ${spec.name}UpdatedEvent(Guid ${spec.name}Id, DateTime OccurredAt) : IDomainEvent; public record ${spec.name}DeletedEvent(Guid ${spec.name}Id, DateTime OccurredAt) : IDomainEvent; public record ${spec.name}RestoredEvent(Guid ${spec.name}Id, DateTime OccurredAt) : IDomainEvent; ` const tableName = `${spec.domainPrefix}_${spec.pluralName ?? pluralize(spec.name)}` const indexedFields = columns.filter(f => f.indexed && !f.unique) // Column constraints only — NO HasColumnName. EF Core maps property → column in // PascalCase by default, matching SmartStack.app (which never remaps columns: // `FirstName`, `CreatedAt`, `ClientId`). A Property() line is emitted only when // it carries a constraint (required string / maxLength); otherwise the column // falls through to the default mapping. const propLines = columns .map(f => { const required = f.required && f.type === 'string' ? '.IsRequired()' : '' const maxLen = f.maxLength ? `.HasMaxLength(${f.maxLength})` : '' // decimal(p,s) — without it SQL Server defaults to decimal(18,2) and a // declared decimal(5,3) silently loses its third scale digit. const precision = f.precision !== undefined ? `.HasPrecision(${f.precision}, ${f.scale ?? 0})` : '' return required || maxLen || precision ? ` builder.Property(e => e.${f.name})${required}${maxLen}${precision};` : '' }) .filter(Boolean) const bodyLines: string[] = [ ` builder.ToTable("${tableName}", ${schemaConstant});`, ` builder.HasKey(e => e.Id);`, ` builder.HasQueryFilter(e => e.DeletedAt == null);`, ``, ` builder.HasIndex(e => e.CreatedAt);`, ] if (spec.tenantMode !== 'none') bodyLines.push(` builder.HasIndex(e => e.TenantId);`) if (coded) { bodyLines.push( ``, ` // System-allocated business code (coded-entities seam, key "${coded.codeKey}").`, ` // The unique index is the DB safety net under the allocation engine.`, ` builder.Property(e => e.Code).IsRequired().HasMaxLength(${coded.maxLength});`, ) if (coded.unique === false) { // Deliberate opt-out (validate.ts warns) — legacy non-unique index. bodyLines.push(` builder.HasIndex(e => e.Code);`) } else if (spec.tenantMode === 'strict') { bodyLines.push(` builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique();`) } else if (spec.tenantMode === 'optional') { // Nullable TenantId → the socle pattern: one filtered unique index per // population (tenant rows / global rows), like IX_ref_Departments_*. bodyLines.push( ` builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique().HasFilter("[TenantId] IS NOT NULL");`, ` builder.HasIndex(e => e.Code).IsUnique().HasFilter("[TenantId] IS NULL").HasDatabaseName("IX_${tableName}_Code_Global");`, ) } else { bodyLines.push(` builder.HasIndex(e => e.Code).IsUnique();`) } } if (spec.versioned) { bodyLines.push( ``, ` // Optimistic-concurrency token for offline-write 409 stale-write detection (IVersionedEntity).`, ` builder.Property(e => e.RowVersion).IsRowVersion();`, ) } if (propLines.length) bodyLines.push(``, ...propLines) if (indexedFields.length) bodyLines.push(``, ...indexedFields.map(f => ` builder.HasIndex(e => e.${f.name});`)) // BA-declared UNIQUE columns — same tenant-aware shape as the codedEntity // Code index: uniqueness is a PER-TENANT business invariant, and on an // optional-tenant entity the global rows need their own filtered index. const uniqueFields = columns.filter(f => f.unique) for (const f of uniqueFields) { bodyLines.push(``, ` // Declared unique (BA index) — the DB safety net under the uniqueness rule.`) if (spec.tenantMode === 'strict') { bodyLines.push(` builder.HasIndex(e => new { e.TenantId, e.${f.name} }).IsUnique();`) } else if (spec.tenantMode === 'optional') { bodyLines.push( ` builder.HasIndex(e => new { e.TenantId, e.${f.name} }).IsUnique().HasFilter("[TenantId] IS NOT NULL");`, ` builder.HasIndex(e => e.${f.name}).IsUnique().HasFilter("[TenantId] IS NULL").HasDatabaseName("IX_${tableName}_${f.name}_Global");`, ) } else { bodyLines.push(` builder.HasIndex(e => e.${f.name}).IsUnique();`) } } // Declared indexes carried VERBATIM from entité.md `**Index**` — the shapes // the per-field flags cannot represent: composites (the index that // PHYSICALLY prevents BR-009's double notification) and multi-column // FK-bearing uniques. Single-column entries already covered by a field flag // are skipped (no duplicate emission). // A single-column entry is a duplicate ONLY when the field flag already // emits the SAME strength: a declared `unique` is NOT covered by a field // that is merely `indexed` — skipping it there dropped the declared // uniqueness silently, i.e. the very §27 class, and left DEV-API-031 // erring on something no scaffold-entity re-run could heal. const columnByName = new Map(columns.map(f => [f.name, f])) for (const idx of spec.indexes ?? []) { if (idx.fields.length === 1) { const flagged = columnByName.get(idx.fields[0]!) const alreadyEmitted = idx.unique ? flagged?.unique === true : flagged?.indexed === true || flagged?.unique === true if (alreadyEmitted) continue } // The tenant discriminator is SYNTHESISED from tenantMode — never a // declared column. A BA `(TenantId, Code) unique` used to emit // `new { e.TenantId, e.TenantId, e.Code }` — CS0833, an uncompilable // Configuration — and under tenantMode none it names a column that does // not exist. Stripped where the unique branch re-prefixes it, or where no // TenantId column exists; a NON-unique `(TenantId, Status)` on a tenant // entity is a legitimate, different index and is kept as declared. const isTenant = (n: string): boolean => n.toLowerCase() === 'tenantid' const cols = idx.unique || spec.tenantMode === 'none' ? idx.fields.filter(n => !isTenant(n)) : idx.fields if (cols.length === 0) continue const plainExpr = cols.length === 1 ? `e.${cols[0]}` : `new { ${cols.map(n => `e.${n}`).join(', ')} }` if (!idx.unique) { bodyLines.push(``, ` builder.HasIndex(e => ${plainExpr});`) continue } const joined = cols.join('_') bodyLines.push(``, ` // Declared unique (BA index) — the DB safety net under the uniqueness rule.`) if (spec.tenantMode === 'strict') { bodyLines.push(` builder.HasIndex(e => new { e.TenantId, ${cols.map(n => `e.${n}`).join(', ')} }).IsUnique();`) } else if (spec.tenantMode === 'optional') { bodyLines.push( ` builder.HasIndex(e => new { e.TenantId, ${cols.map(n => `e.${n}`).join(', ')} }).IsUnique().HasFilter("[TenantId] IS NOT NULL");`, ` builder.HasIndex(e => ${plainExpr}).IsUnique().HasFilter("[TenantId] IS NULL").HasDatabaseName("IX_${tableName}_${joined}_Global");`, ) } else { bodyLines.push(` builder.HasIndex(e => ${plainExpr}).IsUnique();`) } } // No SmartStack.Infrastructure using: SchemaConstants is the PROJECT-LOCAL // shim ({ns}.Infrastructure.Persistence.SchemaConstants) resolved through the // enclosing namespace of this configuration. const configCoreUsings = coreUsingBlock ? '\n' + coreUsingBlock : '' const config = `using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using ${ns}.Domain.Entities;${configCoreUsings} namespace ${ns}.Infrastructure.Persistence.Configurations; public class ${spec.name}Configuration : IEntityTypeConfiguration<${spec.name}> { public void Configure(EntityTypeBuilder<${spec.name}> builder) { ${bodyLines.join('\n')}${relBlock} } } ` // No more local stub files: the SDK's SmartStackExtensionDbContext owns the // ExcludeFromMigrations mapping for every whitelist entity. Cross-module refs // resolve against the real target type in the same DbContext. // // Files are classified into // folders; the NAMESPACES stay FLAT // (`${ns}.Domain.Entities`, `${ns}.Infrastructure.Persistence.Configurations`) // — a cross-module FK config emits `builder.HasOne()` and must // resolve every entity type through one `using ${ns}.Domain.Entities;`, but the // scaffolder doesn't know a cross-module target's module. See lib/app-classification.ts. const entitiesDir = domainEntitiesDir(ns, spec.applicationCode, spec.module) const configDir = configurationsDir(ns, spec.applicationCode, spec.module) return [ { path: `${entitiesDir}/${spec.name}.cs`, content: entity }, { path: `${entitiesDir}/${spec.name}Events.cs`, content: events }, { path: `${configDir}/${spec.name}Configuration.cs`, content: config }, ] } /** * Pre-classification locations of this entity's files (flat Domain / flat * Configurations). The CLI deletes any that exist before writing the new * /-classified files, so re-running /ba-develop MOVES the entity * instead of leaving a duplicate IEntityTypeConfiguration / entity type in the * assembly (which would break EF / model building at boot). */ export function legacyPaths(spec: ScaffoldEntityInput): string[] { const ns = spec.namespace ?? spec.appCode const entitiesDir = legacyDomainEntitiesDir(ns) const configDir = legacyConfigurationsDir(ns) return [ `${entitiesDir}/${spec.name}.cs`, `${entitiesDir}/${spec.name}Events.cs`, `${configDir}/${spec.name}Configuration.cs`, ] } // ─── Helpers ─── function camel(s: string): string { return s.charAt(0).toLowerCase() + s.slice(1) } function csType(f: EntityField): string { // Defensive twin of the validate.ts fail-closed guard (callers may skip // validate): binary content is never a column — the raw fallback below // would otherwise emit uncompilable `public binary X { … }`. if (BINARY_TYPE_DENYLIST.has(f.type.toLowerCase())) { throw new Error( `Field "${f.name}": type "${f.type}" is not scaffoldable — file content never goes in the DB. ` + `Model a metadata entity (FileName, StoredFileName, ContentType, FileSizeBytes + parent FK) and ` + `store the bytes via the platform IFileStorageService. ` + `See development/backend/data-layer/references/file-storage.md`) } const map: Record = { string: 'string', int: 'int', integer: 'int', number: 'decimal', decimal: 'decimal', bool: 'bool', boolean: 'bool', datetime: 'DateTime', date: 'DateOnly', guid: 'Guid', } const base = map[f.type.toLowerCase()] ?? f.type return !f.required ? `${base}?` : base } function defaultInitializer(f: EntityField): string { if (f.required && f.type.toLowerCase() === 'string') return ' = null!;' return '' } function createParams(reqFields: EntityField[], optionalCreateFields: EntityField[], includeTenant: boolean): string { const parts = reqFields.map(f => `${csType(f)} ${camel(f.name)}`) if (includeTenant) parts.push('Guid tenantId') // Optional CREATION fields (non-phased) — defaulted parameters AFTER the // server-side args, so every existing positional call site stays valid; // scaffold-business binds them by NAME. Lifecycle-phased fields are never // factory parameters (the capturing action / edit surface writes them). for (const f of optionalCreateFields) parts.push(`${csType(f)} ${camel(f.name)} = null`) return parts.join(', ') } function updateParams(fields: EntityField[]): string { return fields.map(f => `${csType(f)} ${camel(f.name)}`).join(', ') } // ─── Relation helpers ─── /** FK column name on the owning side. Default `${targetEntity}Id`. */ function fkName(rel: EntityRelation): string { return rel.foreignKey ?? `${rel.targetEntity}Id` } /** Reference navigation property name (owning side). Default = targetEntity. */ function refNavName(rel: EntityRelation): string { return rel.navigationName ?? rel.targetEntity } /** Collection navigation property name (inverse side). Default = `${targetEntity}s`. */ function collNavName(rel: EntityRelation): string { return rel.navigationName ?? `${rel.targetEntity}s` } /** Maps the BA cascade vocabulary to an EF Core `DeleteBehavior` member. */ function onDeleteBehavior(rel: EntityRelation): string { const action = rel.onDelete ?? (rel.cascadeDelete ? 'cascade' : 'restrict') const map: Record = { 'restrict': 'Restrict', 'cascade': 'Cascade', 'set-null': 'SetNull', 'no-action': 'NoAction', } return map[action] ?? 'Restrict' } /** Scope of a relation target. Absent (legacy specs) ⇒ same-module. */ function relScope(rel: EntityRelation): 'same-module' | 'cross-module' | 'core' { return rel.targetScope ?? 'same-module' } /** * Does this relation get a lambda navigation property on the owning side? * Yes for same-module and for core-whitelist targets (the base class maps them); * no for cross-module (decoupled, type-only HasOne). */ function hasLambdaNav(rel: EntityRelation): boolean { const scope = relScope(rel) return scope === 'same-module' || (scope === 'core' && CORE_WHITELIST_V1.has(rel.targetEntity)) } /** Adds the namespace of a whitelisted Core entity to the using set, if known. */ function addCoreUsing(set: Set, entity: string): void { const ns = CORE_WHITELIST_V1_NAMESPACES[entity] if (ns) set.add(ns) }