/** * field-read-surface.ts — the SSOT of which entity fields belong to which DTO * surface, shared by scaffold-business (the C# records — the wire's authority) * and scaffold-api-client (the TS interfaces that must MIRROR them). * * The historical drift (§ « scaffold-types bâtit l'interface sur l'ENTITÉ »): * each generator re-derived its own inclusion rule, so every computed * (`formula`) or Core-projected (`source`) member the record sent was absent * from the TS interface — 34 members on one client project — while a formula * passed through could land in the TS Create DTO the C# record never had. * * Rules (transposed VERBATIM from scaffold-business): * - READ surfaces (ListDto / DetailDto) carry every user field — computed, * projected and derived members included (the API sends them); * - WRITE surfaces exclude read-only members BY CONSTRUCTION: * Create = required stored fields (minus the server-assigned data-scope * owner) PLUS optional non-phased stored fields (« Create honnête »); * Update = stored fields minus keys and the owner. */ export interface SurfaceField { name: string required?: boolean isKey?: boolean /** Lifecycle phase key — a phased field never rides Create. */ phase?: string | null /** Computed member (C# expression) — read-only by construction. */ formula?: string | null /** Core projection — read-only unless the person-optional overlay stores it * locally (fallbackLocal === its own name). */ source?: { fallbackLocal?: string | null } | null /** Derived column (nav/child projection — lib/derived-field) — read-only. */ derived?: unknown } /** System columns no user surface ever exposes as input. Matching is * case-insensitive: scaffold-business sees entité.md PascalCase names, * scaffold-api-client the camelCase mirror. */ export const RESERVED_SYSTEM_FIELDS: ReadonlySet = new Set(['id', 'createdat', 'updatedat', 'deletedat', 'tenantid']) function isReservedSystemField(name: string): boolean { return RESERVED_SYSTEM_FIELDS.has(name.toLowerCase()) } /** A `source` field is a PURE projection unless the person-optional overlay * keeps the local column writable (fallbackLocal === its own name). */ export function isPureProjection(f: SurfaceField): boolean { return f.source != null && f.source.fallbackLocal !== f.name } /** Read-only member: it appears on List/Detail, NEVER on Create/Update. */ export function isReadOnly(f: SurfaceField): boolean { return Boolean(f.formula) || isPureProjection(f) || f.derived != null } /** A real column of the entity's own table. */ export function isStoredField(f: SurfaceField): boolean { return !f.formula && !isPureProjection(f) && f.derived == null } export interface CreateSurfaceSplit { /** Required creation fields — the server-assigned owner excluded. */ required: T[] /** Optional non-phased stored fields (« Create honnête » — nullable members). */ optional: T[] } /** The CREATE surface, split (the factory consumes the halves separately). */ export function createSurfaceSplit( fields: readonly T[], ownerName: string | null = null, ): CreateSurfaceSplit { const user = fields.filter(f => !isReservedSystemField(f.name)) const stored = user.filter(isStoredField) return { required: stored.filter(f => f.required && f.name !== ownerName), optional: stored.filter(f => !f.required && !f.isKey && f.name !== ownerName && !f.phase), } } /** The CREATE surface, flat — required first, then the optional tail. */ export function createSurfaceFields( fields: readonly T[], ownerName: string | null = null, ): T[] { const { required, optional } = createSurfaceSplit(fields, ownerName) return [...required, ...optional] } /** The UPDATE surface — stored fields minus keys and the owner. */ export function updateSurfaceFields( fields: readonly T[], ownerName: string | null = null, ): T[] { return fields .filter(f => !isReservedSystemField(f.name)) .filter(isStoredField) .filter(f => !f.isKey && f.name !== ownerName) }