From be8a759b035b3ea7be940167fbd7dfe7aaef6e7a Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 20 May 2026 11:09:33 +0200 Subject: [PATCH 1/6] TypeSchema: introduce SnapshotProfileTypeSchema and integrate with the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a writer-ready profile representation alongside the raw ProfileTypeSchema. Snapshots are flattened (fields merged across the hierarchy, extensions consolidated) and discriminated by `identifier.kind === "profile-snapshot"`. Type additions: - SnapshotProfileIdentifier in the Identifier union. - SnapshotProfileTypeSchema in the TypeSchema union. - Guards: isSnapshotProfileIdentifier, isSnapshotProfileTypeSchema. - Helper: snapshotIdentifier(id) — ProfileIdentifier → SnapshotProfileIdentifier. - Export PrimitiveTypeSchema (was implicitly public via TypeSchema; the resolve overload signature now needs the name directly). Index integration in mkTypeSchemaIndex: - New `snapshotIndex` (parallel to nestedIndex) holds pre-built snapshots. - Snapshots are eagerly built for every well-formed profile during index construction. Profiles whose hierarchy lacks a non-profile ancestor are skipped structurally via tryHierarchy + guard checks, so direct flatProfile callers still see the original exception path. - New `collectSnapshotProfiles()` returns all built snapshots. - `resolve` and `resolveType` route SnapshotProfileIdentifier (kind "profile-snapshot") to the snapshot store. - Both functions are now overloaded — passing a specific identifier variant (Resource, Profile, Snapshot, Binding, ValueSet, Logical, Primitive, ComplexType; plus Nested for resolveType) narrows the return type without a cast. Implementation uses a named callable type (ResolveFn/ResolveTypeFn) to avoid circular type inference through TypeSchemaIndex. - findLastSpecialization uses guard-based filter (skips both profile and profile-snapshot variants) so it composes when called via findLastSpecializationByIdentifier on a snapshot identifier. - entityTree includes the "profile-snapshot" bucket so EntityTree stays exhaustive over Identifier["kind"]. Tree-shake short-circuit: - treeShakeTypeSchema returns snapshots untouched (they're derived data; they never reach tree shaking in practice, but the typed switch needs to acknowledge them). --- src/typeschema/ir/tree-shake.ts | 9 ++- src/typeschema/types.ts | 31 +++++++++- src/typeschema/utils.ts | 101 ++++++++++++++++++++++++++++---- 3 files changed, 127 insertions(+), 14 deletions(-) diff --git a/src/typeschema/ir/tree-shake.ts b/src/typeschema/ir/tree-shake.ts index 7c273009..aafbceec 100644 --- a/src/typeschema/ir/tree-shake.ts +++ b/src/typeschema/ir/tree-shake.ts @@ -14,6 +14,7 @@ import { isNotChoiceDeclarationField, isPrimitiveTypeSchema, isProfileTypeSchema, + isSnapshotProfileTypeSchema, isSpecializationTypeSchema, isValueSetTypeSchema, type PkgName, @@ -189,7 +190,13 @@ const mutableFillReport = (report: TreeShakeReport, tsIndex: TypeSchemaIndex, sh export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _logger?: CodegenLog): TypeSchema => { schema = JSON.parse(JSON.stringify(schema)); - if (isPrimitiveTypeSchema(schema) || isValueSetTypeSchema(schema) || isBindingSchema(schema)) return schema; + if ( + isPrimitiveTypeSchema(schema) || + isValueSetTypeSchema(schema) || + isBindingSchema(schema) || + isSnapshotProfileTypeSchema(schema) + ) + return schema; if (rule.selectFields) { if (rule.ignoreFields) throw new Error("Cannot use both ignoreFields and selectFields in the same rule"); diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index ea99121d..62e3c992 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -108,6 +108,7 @@ export type ValueSetIdentifier = { kind: "value-set" } & IdentifierBase; export type NestedIdentifier = { kind: "nested" } & IdentifierBase; export type BindingIdentifier = { kind: "binding" } & IdentifierBase; export type ProfileIdentifier = { kind: "profile" } & IdentifierBase; +export type SnapshotProfileIdentifier = { kind: "profile-snapshot" } & IdentifierBase; export type LogicalIdentifier = { kind: "logical" } & IdentifierBase; export type Identifier = @@ -117,6 +118,7 @@ export type Identifier = | BindingIdentifier | ValueSetIdentifier | ProfileIdentifier + | SnapshotProfileIdentifier | LogicalIdentifier; export type TypeIdentifier = Identifier | NestedIdentifier; @@ -145,6 +147,15 @@ export const isProfileIdentifier = (id: TypeIdentifier | undefined): id is Profi return id?.kind === "profile"; }; +export const isSnapshotProfileIdentifier = (id: TypeIdentifier | undefined): id is SnapshotProfileIdentifier => { + return id?.kind === "profile-snapshot"; +}; + +export const snapshotIdentifier = (id: ProfileIdentifier): SnapshotProfileIdentifier => ({ + ...id, + kind: "profile-snapshot", +}); + export const isSpecializationIdentifier = ( id: TypeIdentifier | undefined, ): id is ResourceIdentifier | ComplexTypeIdentifier | LogicalIdentifier => { @@ -167,7 +178,8 @@ export type TypeSchema = | PrimitiveTypeSchema | ValueSetTypeSchema | BindingTypeSchema - | ProfileTypeSchema; + | ProfileTypeSchema + | SnapshotProfileTypeSchema; type TypeSchemaGuardInput = TypeSchema | NestedTypeSchema | undefined; @@ -211,7 +223,7 @@ export const isValueSetTypeSchema = (schema: TypeSchemaGuardInput): schema is Va return schema?.identifier.kind === "value-set"; }; -interface PrimitiveTypeSchema { +export interface PrimitiveTypeSchema { identifier: PrimitiveIdentifier; description?: string; base: TypeIdentifier; @@ -254,6 +266,21 @@ export interface ProfileTypeSchema { nested?: NestedTypeSchema[]; } +/** Flattened profile. */ +export interface SnapshotProfileTypeSchema { + identifier: SnapshotProfileIdentifier; + base: TypeIdentifier; + description?: string; + fields: Record; + extensions?: ProfileExtension[]; + dependencies?: TypeIdentifier[]; + nested?: NestedTypeSchema[]; +} + +export const isSnapshotProfileTypeSchema = (s: TypeSchemaGuardInput): s is SnapshotProfileTypeSchema => { + return s?.identifier.kind === "profile-snapshot"; +}; + export interface FieldSlicing { discriminator?: FS.FHIRSchemaDiscriminator[]; rules?: string; diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index a9e45eda..d1741f19 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -5,8 +5,11 @@ import * as YAML from "yaml"; import type { IrReport } from "./ir/types"; import type { Register } from "./register"; import { + type BindingIdentifier, + type BindingTypeSchema, type CanonicalUrl, type ChoiceFieldInstance, + type ComplexTypeIdentifier, type ComplexTypeTypeSchema, type ConstrainedChoiceInfo, concatIdentifiers, @@ -23,17 +26,30 @@ import { isProfileTypeSchema, isResourceIdentifier, isResourceTypeSchema, + isSnapshotProfileIdentifier, + isSnapshotProfileTypeSchema, isSpecializationTypeSchema, + type LogicalIdentifier, type LogicalTypeSchema, + type NestedIdentifier, type NestedTypeSchema, type PkgName, + type PrimitiveIdentifier, + type PrimitiveTypeSchema, type ProfileExtension, + type ProfileIdentifier, type ProfileTypeSchema, + type ResourceIdentifier, type ResourceTypeSchema, + type SnapshotProfileIdentifier, + type SnapshotProfileTypeSchema, type SpecializationTypeSchema, + snapshotIdentifier, type TypeFamily, type TypeIdentifier, type TypeSchema, + type ValueSetIdentifier, + type ValueSetTypeSchema, } from "./types"; /////////////////////////////////////////////////////////// @@ -292,6 +308,33 @@ const populateGeneric = ( /////////////////////////////////////////////////////////// // Type Schema Index +/** Overloaded `resolve` — passing a specific identifier kind narrows the return type. */ +type ResolveFn = { + (id: PrimitiveIdentifier): PrimitiveTypeSchema | undefined; + (id: ComplexTypeIdentifier): ComplexTypeTypeSchema | undefined; + (id: ResourceIdentifier): ResourceTypeSchema | undefined; + (id: LogicalIdentifier): LogicalTypeSchema | undefined; + (id: ValueSetIdentifier): ValueSetTypeSchema | undefined; + (id: BindingIdentifier): BindingTypeSchema | undefined; + (id: ProfileIdentifier): ProfileTypeSchema | undefined; + (id: SnapshotProfileIdentifier): SnapshotProfileTypeSchema | undefined; + (id: Identifier): TypeSchema | undefined; +}; + +/** Overloaded `resolveType` — same as ResolveFn plus a NestedIdentifier overload. */ +type ResolveTypeFn = { + (id: NestedIdentifier): NestedTypeSchema | undefined; + (id: PrimitiveIdentifier): PrimitiveTypeSchema | undefined; + (id: ComplexTypeIdentifier): ComplexTypeTypeSchema | undefined; + (id: ResourceIdentifier): ResourceTypeSchema | undefined; + (id: LogicalIdentifier): LogicalTypeSchema | undefined; + (id: ValueSetIdentifier): ValueSetTypeSchema | undefined; + (id: BindingIdentifier): BindingTypeSchema | undefined; + (id: ProfileIdentifier): ProfileTypeSchema | undefined; + (id: SnapshotProfileIdentifier): SnapshotProfileTypeSchema | undefined; + (id: TypeIdentifier): TypeSchema | NestedTypeSchema | undefined; +}; + export type TypeSchemaIndex = { _schemaIndex: Record>; schemas: TypeSchema[]; @@ -301,8 +344,9 @@ export type TypeSchemaIndex = { collectResources: () => ResourceTypeSchema[]; collectLogicalModels: () => LogicalTypeSchema[]; collectProfiles: () => ProfileTypeSchema[]; - resolve: (id: Identifier) => TypeSchema | undefined; - resolveType: (id: TypeIdentifier) => TypeSchema | NestedTypeSchema | undefined; + collectSnapshotProfiles: () => SnapshotProfileTypeSchema[]; + resolve: ResolveFn; + resolveType: ResolveTypeFn; resolveByUrl: (pkgName: PkgName, url: CanonicalUrl) => TypeSchema | NestedTypeSchema | undefined; tryHierarchy: (schema: TypeSchema) => TypeSchema[] | undefined; hierarchy: (schema: TypeSchema) => TypeSchema[]; @@ -314,7 +358,7 @@ export type TypeSchemaIndex = { baseTypeId: TypeIdentifier, sliceElements: string[], ) => ConstrainedChoiceInfo | undefined; - isWithMetaField: (profile: ProfileTypeSchema) => boolean; + isWithMetaField: (profile: ProfileTypeSchema | SnapshotProfileTypeSchema) => boolean; entityTree: () => EntityTree; exportTree: (filename: string) => Promise; irReport: () => IrReport; @@ -337,6 +381,7 @@ export const mkTypeSchemaIndex = ( ): TypeSchemaIndex => { const index: Record> = {}; const nestedIndex: Record> = {}; + const snapshotIndex: Record> = {}; const append = (schema: TypeSchema) => { const url = schema.identifier.url; const pkg = schema.identifier.package; @@ -366,13 +411,15 @@ export const mkTypeSchemaIndex = ( } populateTypeFamily(schemas); - const resolve = (id: Identifier): TypeSchema | undefined => { + const resolve = ((id: Identifier): TypeSchema | undefined => { + if (isSnapshotProfileIdentifier(id)) return snapshotIndex[id.url]?.[id.package]; return index[id.url]?.[id.package]; - }; - const resolveType = (id: TypeIdentifier): TypeSchema | NestedTypeSchema | undefined => { + }) as ResolveFn; + const resolveType = ((id: TypeIdentifier): TypeSchema | NestedTypeSchema | undefined => { if (isNestedIdentifier(id)) return nestedIndex[id.url]?.[id.package]; + if (isSnapshotProfileIdentifier(id)) return snapshotIndex[id.url]?.[id.package]; return index[id.url]?.[id.package]; - }; + }) as ResolveTypeFn; populateGeneric(schemas, resolveType); const resolveByUrl = (pkgName: PkgName, url: CanonicalUrl): TypeSchema | NestedTypeSchema | undefined => { @@ -410,10 +457,10 @@ export const mkTypeSchemaIndex = ( let cur: TypeSchema | undefined = schema; while (cur) { res.push(cur); - const base = (cur as SpecializationTypeSchema).base; + const base: TypeIdentifier | undefined = (cur as SpecializationTypeSchema).base; if (base === undefined) break; if (isNestedIdentifier(base)) break; - const resolved = resolve(base); + const resolved: TypeSchema | undefined = resolve(base); if (!resolved) { logger?.warn( "#resolveBase", @@ -435,7 +482,9 @@ export const mkTypeSchemaIndex = ( }; const findLastSpecialization = (schema: TypeSchema): TypeSchema => { - const nonConstraintSchema = hierarchy(schema).find((s) => s.identifier.kind !== "profile"); + const nonConstraintSchema = hierarchy(schema).find( + (s) => !isProfileTypeSchema(s) && !isSnapshotProfileTypeSchema(s), + ); if (!nonConstraintSchema) { throw new Error(`No non-constraint schema found in hierarchy for: ${schema.identifier.name}`); } @@ -549,6 +598,34 @@ export const mkTypeSchemaIndex = ( }; }; + const buildProfileSnapshot = (schema: ProfileTypeSchema): SnapshotProfileTypeSchema => { + const flat = flatProfile(schema); + return { + identifier: snapshotIdentifier(flat.identifier), + base: flat.base, + description: flat.description, + fields: flat.fields ?? {}, + extensions: flat.extensions, + dependencies: flat.dependencies, + nested: flat.nested, + }; + }; + + for (const schema of schemas) { + if (!isProfileTypeSchema(schema)) continue; + // Skip profiles whose hierarchy is incomplete — no resolvable base, or no + // non-profile root (e.g. constraint-on-constraint with nothing underneath). + // flatProfile would throw on these; direct callers still see the original exception. + const hier = tryHierarchy(schema); + if (!hier?.some((s) => !isProfileTypeSchema(s) && !isSnapshotProfileTypeSchema(s))) continue; + const snap = buildProfileSnapshot(schema); + const byPkg = (snapshotIndex[snap.identifier.url] ??= {}); + byPkg[snap.identifier.package] = snap; + } + + const collectSnapshotProfiles = (): SnapshotProfileTypeSchema[] => + Object.values(snapshotIndex).flatMap((byPkg) => Object.values(byPkg)); + const constrainedChoice = ( pkgName: PkgName, baseTypeId: TypeIdentifier, @@ -573,7 +650,7 @@ export const mkTypeSchemaIndex = ( return undefined; }; - const isWithMetaField = (profile: ProfileTypeSchema): boolean => { + const isWithMetaField = (profile: ProfileTypeSchema | SnapshotProfileTypeSchema): boolean => { const genealogy = tryHierarchy(profile); if (!genealogy) return false; return genealogy.filter(isSpecializationTypeSchema).some((schema) => { @@ -592,6 +669,7 @@ export const mkTypeSchemaIndex = ( nested: {}, binding: {}, profile: {}, + "profile-snapshot": {}, logical: {}, }; for (const schema of shemas) { @@ -617,6 +695,7 @@ export const mkTypeSchemaIndex = ( collectResources: () => schemas.filter(isResourceTypeSchema), collectLogicalModels: () => schemas.filter(isLogicalTypeSchema), collectProfiles: () => schemas.filter(isProfileTypeSchema), + collectSnapshotProfiles, resolve, resolveType, resolveByUrl, From a2607200f66c23ba8c06098cf95efc7f58ade15d Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 20 May 2026 11:09:44 +0200 Subject: [PATCH 2/6] TS: consume SnapshotProfileTypeSchema in the writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipes snapshots from the index through the writer end-to-end. - writer.generate() now collects via tsIndex.collectSnapshotProfiles(). - generateResourceModule dispatches on isSnapshotProfileTypeSchema; the inline `tsIndex.resolve(...)` call inside the profile branch is gone — the iterated schema is already the snapshot. - generateFhirPackageIndexFile and the per-package filter use isSnapshotProfileTypeSchema. - generateProfileIndexFile signature takes SnapshotProfileTypeSchema[]. - resolveExtensionProfile resolves the snapshot via the overloaded resolve() — no cast — and returns it as `snapshot` on ExtensionProfileInfo (replacing the previous `flatProfile` field). - All profile-generation helpers (factory info, slice defs, extension methods, validation, slice setters/getters) take `snapshot: SnapshotProfileTypeSchema` instead of `flatProfile: ProfileTypeSchema`. - Name helpers (tsProfileModuleName/FileName/ClassName) narrow to SnapshotProfileTypeSchema. tsProfileModuleName switches to findLastSpecializationByIdentifier so it no longer depends on the TypeSchema union. Drive-by: biome --write trimmed a redundant `!!canonicalUrl` (already inside an `&&` chain). --- src/api/writer-generator/typescript/name.ts | 12 +- .../typescript/profile-extensions.ts | 62 +++---- .../typescript/profile-slices.ts | 26 +-- .../typescript/profile-validation.ts | 16 +- .../writer-generator/typescript/profile.ts | 156 +++++++++--------- src/api/writer-generator/typescript/writer.ts | 19 +-- 6 files changed, 154 insertions(+), 137 deletions(-) diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index 82661edb..145f9a42 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -7,7 +7,7 @@ import { import { type CanonicalUrl, extractNameFromCanonical, - type ProfileTypeSchema, + type SnapshotProfileTypeSchema, type TypeIdentifier, } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; @@ -77,17 +77,17 @@ export const tsFieldName = (n: string): string => { return n; }; -export const tsProfileModuleName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { - const resourceSchema = tsIndex.findLastSpecialization(schema); - const resourceName = uppercaseFirstLetter(normalizeTsName(resourceSchema.identifier.name)); +export const tsProfileModuleName = (tsIndex: TypeSchemaIndex, schema: SnapshotProfileTypeSchema): string => { + const resourceId = tsIndex.findLastSpecializationByIdentifier(schema.identifier); + const resourceName = uppercaseFirstLetter(normalizeTsName(resourceId.name)); return `${resourceName}_${normalizeTsName(schema.identifier.name)}`; }; -export const tsProfileModuleFileName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { +export const tsProfileModuleFileName = (tsIndex: TypeSchemaIndex, schema: SnapshotProfileTypeSchema): string => { return `${tsProfileModuleName(tsIndex, schema)}.ts`; }; -export const tsProfileClassName = (schema: ProfileTypeSchema): string => { +export const tsProfileClassName = (schema: SnapshotProfileTypeSchema): string => { const name = normalizeTsName(schema.identifier.name); return name.endsWith("Profile") ? name : `${name}Profile`; }; diff --git a/src/api/writer-generator/typescript/profile-extensions.ts b/src/api/writer-generator/typescript/profile-extensions.ts index de23c910..a7fb9a5d 100644 --- a/src/api/writer-generator/typescript/profile-extensions.ts +++ b/src/api/writer-generator/typescript/profile-extensions.ts @@ -3,7 +3,8 @@ import { isChoiceDeclarationField, isProfileTypeSchema, type ProfileExtension, - type ProfileTypeSchema, + type SnapshotProfileTypeSchema, + snapshotIdentifier, type TypeIdentifier, } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; @@ -31,7 +32,7 @@ export type SubExtensionSliceInfo = { export type ExtensionProfileInfo = { className: string; modulePath: string; - flatProfile: ProfileTypeSchema; + snapshot: SnapshotProfileTypeSchema; }; /** @@ -77,8 +78,8 @@ export const valueFieldToTsType = (valueField: string): string => { * Collect sub-extension "flat input" info from an extension profile's own * slice definitions on its `extension` field. */ -export const collectSubExtensionSlices = (extProfile: ProfileTypeSchema): SubExtensionSliceInfo[] => { - const extensionField = extProfile.fields?.extension; +export const collectSubExtensionSlices = (extProfile: SnapshotProfileTypeSchema): SubExtensionSliceInfo[] => { + const extensionField = extProfile.fields.extension; if (!extensionField || isChoiceDeclarationField(extensionField) || !extensionField.slicing?.slices) return []; const result: SubExtensionSliceInfo[] = []; for (const [sliceName, slice] of Object.entries(extensionField.slicing.slices)) { @@ -112,10 +113,11 @@ export const resolveExtensionProfile = ( if (!schema || !isProfileTypeSchema(schema)) return undefined; // Only resolve extension profiles from the same package to avoid cross-package imports if (schema.identifier.package !== pkgName) return undefined; - const className = tsProfileClassName(schema); - const modulePath = `./${tsProfileModuleName(tsIndex, schema)}`; - const flatProfile = tsIndex.flatProfile(schema); - return { className, modulePath, flatProfile }; + const snapshot = tsIndex.resolve(snapshotIdentifier(schema.identifier)); + if (!snapshot) return undefined; + const className = tsProfileClassName(snapshot); + const modulePath = `./${tsProfileModuleName(tsIndex, snapshot)}`; + return { className, modulePath, snapshot }; }; /** Generate the body of a raw Extension branch: validate url, then push/upsert. */ @@ -208,7 +210,7 @@ const generateExtensionGetterOverloads = ( type ExtensionMethodInfo = { ext: ProfileExtension; - flatProfile: ProfileTypeSchema; + snapshot: SnapshotProfileTypeSchema; setMethodName: string; getMethodName: string; targetPath: string[]; @@ -218,11 +220,11 @@ type ExtensionMethodInfo = { // Complex extension — has sub-extensions (e.g., Race with ombCategory, detailed, text) const generateComplexExtensionSetter = (w: TypeScript, info: ExtensionMethodInfo) => { - const { ext, flatProfile, setMethodName, targetPath, extProfileInfo } = info; - const tsProfileName = tsResourceName(flatProfile.identifier); + const { ext, snapshot, setMethodName, targetPath, extProfileInfo } = info; + const tsProfileName = tsResourceName(snapshot.identifier); const inputTypeName = tsExtensionFlatTypeName(tsProfileName, ext.name); const extProfileHasFlatInput = extProfileInfo - ? collectSubExtensionSlices(extProfileInfo.flatProfile).length > 0 + ? collectSubExtensionSlices(extProfileInfo.snapshot).length > 0 : false; const useUpsert = ext.max === "1"; @@ -286,11 +288,11 @@ const generateComplexExtensionSetter = (w: TypeScript, info: ExtensionMethodInfo }; const generateComplexExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo) => { - const { ext, flatProfile, getMethodName, targetPath, extProfileInfo } = info; - const tsProfileName = tsResourceName(flatProfile.identifier); + const { ext, snapshot, getMethodName, targetPath, extProfileInfo } = info; + const tsProfileName = tsResourceName(snapshot.identifier); const inputTypeName = tsExtensionFlatTypeName(tsProfileName, ext.name); const extProfileHasFlatInput = extProfileInfo - ? collectSubExtensionSlices(extProfileInfo.flatProfile).length > 0 + ? collectSubExtensionSlices(extProfileInfo.snapshot).length > 0 : false; const inputType = extProfileHasFlatInput && extProfileInfo ? `${extProfileInfo.className}Flat` : inputTypeName; @@ -316,7 +318,7 @@ const generateSingleValueExtensionSetter = (w: TypeScript, tsIndex: TypeSchemaIn const useUpsert = ext.max === "1"; if (extProfileInfo) { - const extFactoryInfo = collectProfileFactoryInfo(tsIndex, extProfileInfo.flatProfile); + const extFactoryInfo = collectProfileFactoryInfo(tsIndex, extProfileInfo.snapshot); const extValueParam = extFactoryInfo.params.find((p) => p.name === valueField); const resolvedValueType = extValueParam?.tsType ?? valueType; const paramType = `${extProfileInfo.className} | Extension | ${resolvedValueType}`; @@ -395,15 +397,19 @@ const generateGenericExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo }); }; -export const generateExtensionMethods = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { - for (const ext of flatProfile.extensions ?? []) { +export const generateExtensionMethods = ( + w: TypeScript, + tsIndex: TypeSchemaIndex, + snapshot: SnapshotProfileTypeSchema, +) => { + for (const ext of snapshot.extensions ?? []) { if (!ext.url) continue; const baseName = ext.nameCandidates.recommended; const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); - const extProfileInfo = resolveExtensionProfile(tsIndex, flatProfile.identifier.package, ext.url); + const extProfileInfo = resolveExtensionProfile(tsIndex, snapshot.identifier.package, ext.url); const info: ExtensionMethodInfo = { ext, - flatProfile, + snapshot, setMethodName: `set${baseName}`, getMethodName: `get${baseName}`, targetPath, @@ -429,18 +435,18 @@ export const generateExtensionMethods = (w: TypeScript, tsIndex: TypeSchemaIndex export const collectTypesFromExtensions = ( tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, addType: (typeId: TypeIdentifier) => void, ): boolean => { let needsExtensionType = false; - for (const ext of flatProfile.extensions ?? []) { + for (const ext of snapshot.extensions ?? []) { if (ext.isComplex && ext.subExtensions) { needsExtensionType = true; for (const sub of ext.subExtensions) { if (!sub.valueFieldType) continue; const resolvedType = tsIndex.resolveByUrl( - flatProfile.identifier.package, + snapshot.identifier.package, sub.valueFieldType.url as CanonicalUrl, ); addType(resolvedType?.identifier ?? sub.valueFieldType); @@ -449,7 +455,7 @@ export const collectTypesFromExtensions = ( needsExtensionType = true; if (ext.valueFieldTypes[0]) { const resolvedType = tsIndex.resolveByUrl( - flatProfile.identifier.package, + snapshot.identifier.package, ext.valueFieldTypes[0].url as CanonicalUrl, ); addType(resolvedType?.identifier ?? ext.valueFieldTypes[0]); @@ -465,18 +471,18 @@ export const collectTypesFromExtensions = ( /** Collect types used in the FlatInput of extension profiles. */ export const collectTypesFromFlatInput = ( tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, addType: (typeId: TypeIdentifier) => void, ) => { - if (flatProfile.base.name !== "Extension") return; - const subSlices = collectSubExtensionSlices(flatProfile); + if (snapshot.base.name !== "Extension") return; + const subSlices = collectSubExtensionSlices(snapshot); for (const sub of subSlices) { const tsType = sub.tsType; // Primitive types (string, boolean, number) don't need imports if (["string", "boolean", "number"].includes(tsType)) continue; // Resolve complex FHIR type by name const fhirUrl = `http://hl7.org/fhir/StructureDefinition/${tsType}` as CanonicalUrl; - const schema = tsIndex.resolveByUrl(flatProfile.identifier.package, fhirUrl); + const schema = tsIndex.resolveByUrl(snapshot.identifier.package, fhirUrl); if (schema) addType(schema.identifier); } }; diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index db79bb7a..2bbaea0e 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -3,8 +3,8 @@ import { isChoiceDeclarationField, isNotChoiceDeclarationField, isPrimitiveIdentifier, - type ProfileTypeSchema, type RegularField, + type SnapshotProfileTypeSchema, type TypeIdentifier, } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; @@ -45,11 +45,11 @@ export const extractResourceTypeFromMatch = (match: Record): st export const collectTypesFromSlices = ( tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, addType: (typeId: TypeIdentifier) => void, ) => { - const pkgName = flatProfile.identifier.package; - for (const field of Object.values(flatProfile.fields ?? {})) { + const pkgName = snapshot.identifier.package; + for (const field of Object.values(snapshot.fields)) { if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) continue; const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false; for (const slice of Object.values(field.slicing.slices)) { @@ -114,13 +114,13 @@ export type SliceDef = { max: number; }; -export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema): SliceDef[] => - Object.entries(flatProfile.fields ?? {}) +export const collectSliceDefs = (tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema): SliceDef[] => + Object.entries(snapshot.fields) .filter(([_, field]) => isNotChoiceDeclarationField(field) && field.slicing?.slices) .flatMap(([fieldName, field]) => { if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) return []; const baseType = tsTypeFromIdentifier(field.type); - const pkgName = flatProfile.identifier.package; + const pkgName = snapshot.identifier.package; const choiceBaseNames = collectChoiceBaseNames(tsIndex, field.type); const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false; return Object.entries(field.slicing.slices) @@ -154,9 +154,9 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT }); }); -export const generateSliceSetters = (w: TypeScript, sliceDefs: SliceDef[], flatProfile: ProfileTypeSchema) => { - const profileClassName = tsProfileClassName(flatProfile); - const tsProfileName = tsResourceName(flatProfile.identifier); +export const generateSliceSetters = (w: TypeScript, sliceDefs: SliceDef[], snapshot: SnapshotProfileTypeSchema) => { + const profileClassName = tsProfileClassName(snapshot); + const tsProfileName = tsResourceName(snapshot.identifier); for (const sliceDef of sliceDefs) { const baseName = sliceDef.baseName; const methodName = `set${baseName}`; @@ -222,9 +222,9 @@ export const generateSliceSetters = (w: TypeScript, sliceDefs: SliceDef[], flatP } }; -export const generateSliceGetters = (w: TypeScript, sliceDefs: SliceDef[], flatProfile: ProfileTypeSchema) => { - const profileClassName = tsProfileClassName(flatProfile); - const tsProfileName = tsResourceName(flatProfile.identifier); +export const generateSliceGetters = (w: TypeScript, sliceDefs: SliceDef[], snapshot: SnapshotProfileTypeSchema) => { + const profileClassName = tsProfileClassName(snapshot); + const tsProfileName = tsResourceName(snapshot.identifier); const defaultMode = w.opts.sliceGetterDefault ?? "flat"; for (const sliceDef of sliceDefs) { const baseName = sliceDef.baseName; diff --git a/src/api/writer-generator/typescript/profile-validation.ts b/src/api/writer-generator/typescript/profile-validation.ts index d97da3df..e9c833b2 100644 --- a/src/api/writer-generator/typescript/profile-validation.ts +++ b/src/api/writer-generator/typescript/profile-validation.ts @@ -2,8 +2,8 @@ import { type ChoiceFieldInstance, isChoiceDeclarationField, isChoiceInstanceField, - type ProfileTypeSchema, type RegularField, + type SnapshotProfileTypeSchema, type TypeIdentifier, } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; @@ -78,12 +78,16 @@ export const collectRegularFieldValidation = ( } }; -export const generateValidateMethod = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { - const fields = flatProfile.fields ?? {}; - const profileName = flatProfile.identifier.name; - const canonicalUrl = flatProfile.identifier.url; +export const generateValidateMethod = ( + w: TypeScript, + tsIndex: TypeSchemaIndex, + snapshot: SnapshotProfileTypeSchema, +) => { + const fields = snapshot.fields; + const profileName = snapshot.identifier.name; + const canonicalUrl = snapshot.identifier.url; const canonicalUrlExpr = canonicalUrl - ? { url: canonicalUrl, expr: `${tsProfileClassName(flatProfile)}.canonicalUrl` } + ? { url: canonicalUrl, expr: `${tsProfileClassName(snapshot)}.canonicalUrl` } : undefined; w.curlyBlock(["validate(): { errors: string[]; warnings: string[] }"], () => { w.line(`const profileName = "${profileName}"`); diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index d7057daf..0c947149 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -7,9 +7,9 @@ import { isNotChoiceDeclarationField, isPrimitiveIdentifier, isResourceIdentifier, - type ProfileTypeSchema, packageMeta, packageMetaToFhir, + type SnapshotProfileTypeSchema, type TypeIdentifier, } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; @@ -57,11 +57,11 @@ type ProfileFactoryInfo = { }; const collectChoiceAccessors = ( - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, promotedChoices: Set, ): ProfileFactoryInfo["accessors"] => { const accessors: ProfileFactoryInfo["accessors"] = []; - for (const [name, field] of Object.entries(flatProfile.fields ?? {})) { + for (const [name, field] of Object.entries(snapshot.fields)) { if (field.excluded) continue; if (!isChoiceInstanceField(field)) continue; if (promotedChoices.has(name)) continue; @@ -73,8 +73,8 @@ const collectChoiceAccessors = ( /** Try to promote a required single-choice declaration to a direct param */ const tryPromoteChoice = ( - field: NonNullable[string], - fields: NonNullable, + field: SnapshotProfileTypeSchema["fields"][string], + fields: SnapshotProfileTypeSchema["fields"], params: ProfileFactoryInfo["params"], promotedChoices: Set, resolveRef?: (ref: TypeIdentifier) => TypeIdentifier, @@ -100,20 +100,20 @@ export const mkIsFamilyType = export const collectProfileFactoryInfo = ( tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, ): ProfileFactoryInfo => { const autoFields: ProfileFactoryInfo["autoFields"] = []; const sliceAutoFields: ProfileFactoryInfo["sliceAutoFields"] = []; const params: ProfileFactoryInfo["params"] = []; const autoAccessors: ProfileFactoryInfo["accessors"] = []; const fixedFields = new Set(); - const fields = flatProfile.fields ?? {}; + const fields = snapshot.fields; const promotedChoices = new Set(); const resolveRef = tsIndex.findLastSpecializationByIdentifier; const isFamilyType = mkIsFamilyType(tsIndex); - if (isResourceIdentifier(flatProfile.base)) { - autoFields.push({ name: "resourceType", value: JSON.stringify(flatProfile.base.name) }); + if (isResourceIdentifier(snapshot.base)) { + autoFields.push({ name: "resourceType", value: JSON.stringify(snapshot.base.name) }); } for (const [name, field] of Object.entries(fields)) { @@ -161,7 +161,7 @@ export const collectProfileFactoryInfo = ( collectBaseRequiredParams( tsIndex, - flatProfile, + snapshot, resolveRef, params, [ @@ -173,21 +173,21 @@ export const collectProfileFactoryInfo = ( isFamilyType, ); - const accessors = [...autoAccessors, ...collectChoiceAccessors(flatProfile, promotedChoices)]; + const accessors = [...autoAccessors, ...collectChoiceAccessors(snapshot, promotedChoices)]; return { autoFields, sliceAutoFields, params, accessors, fixedFields }; }; /** Include base-type required fields not already covered by profile constraints */ const collectBaseRequiredParams = ( tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, resolveRef: TypeSchemaIndex["findLastSpecializationByIdentifier"], params: ProfileFactoryInfo["params"], coveredNames: string[], isFamilyType?: (ref: TypeIdentifier) => boolean, ) => { const covered = new Set(coveredNames); - const baseSchema = tsIndex.resolveType(flatProfile.base); + const baseSchema = tsIndex.resolveType(snapshot.base); if (!baseSchema || !("fields" in baseSchema) || !baseSchema.fields) return; for (const [name, field] of Object.entries(baseSchema.fields)) { if (covered.has(name)) continue; @@ -204,15 +204,15 @@ const collectBaseRequiredParams = ( export const generateProfileIndexFile = ( w: TypeScript, tsIndex: TypeSchemaIndex, - initialProfiles: ProfileTypeSchema[], + snapshots: SnapshotProfileTypeSchema[], ) => { - if (initialProfiles.length === 0) return; + if (snapshots.length === 0) return; w.cd("profiles", () => { w.cat("index.ts", () => { const exports: Map = new Map(); - for (const profile of initialProfiles) { - const className = tsProfileClassName(profile); - const moduleName = tsProfileModuleName(tsIndex, profile); + for (const snapshot of snapshots) { + const className = tsProfileClassName(snapshot); + const moduleName = tsProfileModuleName(tsIndex, snapshot); if (!exports.has(className)) { exports.set(className, `export { ${className} } from "./${moduleName}"`); } @@ -227,16 +227,16 @@ export const generateProfileIndexFile = ( const generateProfileHelpersImport = ( w: TypeScript, tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, sliceDefs: SliceDef[], factoryInfo: ProfileFactoryInfo, ) => { - const extensions = flatProfile.extensions ?? []; - const hasMeta = tsIndex.isWithMetaField(flatProfile); - const canonicalUrl = flatProfile.identifier.url; + const extensions = snapshot.extensions ?? []; + const hasMeta = tsIndex.isWithMetaField(snapshot); + const canonicalUrl = snapshot.identifier.url; const imports: string[] = []; - if (flatProfile.base.name === "Extension" && !!canonicalUrl && collectSubExtensionSlices(flatProfile).length > 0) + if (snapshot.base.name === "Extension" && canonicalUrl && collectSubExtensionSlices(snapshot).length > 0) imports.push("isRawExtensionInput"); if (canonicalUrl && hasMeta) imports.push("ensureProfile"); if (sliceDefs.length > 0 || factoryInfo.sliceAutoFields.length > 0) @@ -250,7 +250,7 @@ const generateProfileHelpersImport = ( imports.push("isExtension", "getExtensionValue", "pushExtension"); if (extensions.some((ext) => ext.url && ext.max === "1")) imports.push("upsertExtension"); } - if (Object.keys(flatProfile.fields ?? {}).length > 0) + if (Object.keys(snapshot.fields).length > 0) imports.push( "validateRequired", "validateExcluded", @@ -268,7 +268,11 @@ const generateProfileHelpersImport = ( } }; -export const generateProfileImports = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { +export const generateProfileImports = ( + w: TypeScript, + tsIndex: TypeSchemaIndex, + snapshot: SnapshotProfileTypeSchema, +) => { const usedTypes = new Map(); const getModulePath = (typeId: TypeIdentifier): string => { @@ -287,19 +291,19 @@ export const generateProfileImports = (w: TypeScript, tsIndex: TypeSchemaIndex, } }; - addType(flatProfile.base); - collectTypesFromSlices(tsIndex, flatProfile, addType); - const needsExtensionType = collectTypesFromExtensions(tsIndex, flatProfile, addType); - collectTypesFromFlatInput(tsIndex, flatProfile, addType); + addType(snapshot.base); + collectTypesFromSlices(tsIndex, snapshot, addType); + const needsExtensionType = collectTypesFromExtensions(tsIndex, snapshot, addType); + collectTypesFromFlatInput(tsIndex, snapshot, addType); - const factoryInfo = collectProfileFactoryInfo(tsIndex, flatProfile); + const factoryInfo = collectProfileFactoryInfo(tsIndex, snapshot); for (const param of factoryInfo.params) addType(param.typeId); for (const f of factoryInfo.sliceAutoFields) addType(f.typeId); for (const accessor of factoryInfo.accessors) addType(accessor.typeId); if (needsExtensionType) { const extensionUrl = "http://hl7.org/fhir/StructureDefinition/Extension" as CanonicalUrl; - const extensionSchema = tsIndex.resolveByUrl(flatProfile.identifier.package, extensionUrl); + const extensionSchema = tsIndex.resolveByUrl(snapshot.identifier.package, extensionUrl); if (extensionSchema) addType(extensionSchema.identifier); } @@ -320,12 +324,12 @@ export const generateProfileImports = (w: TypeScript, tsIndex: TypeSchemaIndex, // Import extension profile classes for delegation in setters const extProfileImports = new Map(); - for (const ext of flatProfile.extensions ?? []) { + for (const ext of snapshot.extensions ?? []) { if (!ext.url) continue; - const info = resolveExtensionProfile(tsIndex, flatProfile.identifier.package, ext.url); + const info = resolveExtensionProfile(tsIndex, snapshot.identifier.package, ext.url); if (!info) continue; if (!extProfileImports.has(info.className)) { - const hasFlatInput = collectSubExtensionSlices(info.flatProfile).length > 0; + const hasFlatInput = collectSubExtensionSlices(info.snapshot).length > 0; extProfileImports.set(info.className, { modulePath: info.modulePath, hasFlatInput }); } } @@ -359,12 +363,12 @@ const generateStaticSliceFields = (w: TypeScript, sliceDefs: SliceDef[]) => { const generateFactoryMethods = ( w: TypeScript, tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, + snapshot: SnapshotProfileTypeSchema, factoryInfo: ProfileFactoryInfo, ) => { - const profileClassName = tsProfileClassName(flatProfile); - const tsBaseResourceName = tsTypeFromIdentifier(flatProfile.base); - const hasMeta = tsIndex.isWithMetaField(flatProfile); + const profileClassName = tsProfileClassName(snapshot); + const tsBaseResourceName = tsTypeFromIdentifier(snapshot.base); + const hasMeta = tsIndex.isWithMetaField(snapshot); const hasParams = factoryInfo.params.length > 0 || factoryInfo.sliceAutoFields.length > 0; const createArgsTypeName = `${profileClassName}Raw`; const paramSignature = hasParams ? `args: ${createArgsTypeName}` : ""; @@ -391,13 +395,13 @@ const generateFactoryMethods = ( w.lineSM("return profile"); }); w.line(); - const canEmitIs = (hasMeta && isResourceIdentifier(flatProfile.base)) || flatProfile.base.name === "Extension"; + const canEmitIs = (hasMeta && isResourceIdentifier(snapshot.base)) || snapshot.base.name === "Extension"; if (canEmitIs) { w.curlyBlock(["static", "is", "(resource: unknown)", `: resource is ${tsBaseResourceName}`], () => { w.line(`if (typeof resource !== "object" || resource === null) return false;`); - if (hasMeta && isResourceIdentifier(flatProfile.base)) { + if (hasMeta && isResourceIdentifier(snapshot.base)) { w.line(`const r = resource as { resourceType?: string; meta?: { profile?: string[] } };`); - w.line(`if (r.resourceType !== ${JSON.stringify(flatProfile.base.name)}) return false;`); + w.line(`if (r.resourceType !== ${JSON.stringify(snapshot.base.name)}) return false;`); w.lineSM(`return (r.meta?.profile ?? []).includes(${profileClassName}.canonicalUrl)`); } else { w.lineSM(`return (resource as { url?: string }).url === ${profileClassName}.canonicalUrl`); @@ -409,7 +413,7 @@ const generateFactoryMethods = ( if (hasMeta) { w.lineSM(`ensureProfile(resource, ${profileClassName}.canonicalUrl)`); } - if (flatProfile.base.name === "Extension" && flatProfile.identifier.url) { + if (snapshot.base.name === "Extension" && snapshot.identifier.url) { w.lineSM(`resource.url = ${profileClassName}.canonicalUrl`); } const applyAutoFields = factoryInfo.autoFields.filter((f) => f.name !== "resourceType"); @@ -436,7 +440,7 @@ const generateFactoryMethods = ( w.line(); // For extension profiles with sub-extension slices: generate resolveInput helper, // widen createResource and create to accept Input | Raw - const subSlicesForInput = flatProfile.base.name === "Extension" ? collectSubExtensionSlices(flatProfile) : []; + const subSlicesForInput = snapshot.base.name === "Extension" ? collectSubExtensionSlices(snapshot) : []; const hasInputHelper = subSlicesForInput.length > 0; if (hasInputHelper) { @@ -549,7 +553,7 @@ const generateFactoryMethods = ( if (factoryInfo.sliceAutoFields.length > 0) { w.line(); } - if (isPrimitiveIdentifier(flatProfile.base)) { + if (isPrimitiveIdentifier(snapshot.base)) { w.lineSM(`const resource = undefined as unknown as ${tsBaseResourceName}`); } else { const hasMetaParam = allFields.some((f) => f.name === "meta"); @@ -618,13 +622,17 @@ const generateFieldAccessors = (w: TypeScript, factoryInfo: ProfileFactoryInfo) }; /** Generate inline extension input types only for complex extensions without a resolved FlatInput profile */ -const generateInlineExtensionInputTypes = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { - const tsProfileName = tsResourceName(flatProfile.identifier); - const complexExtensions = (flatProfile.extensions ?? []).filter((ext) => ext.isComplex && ext.subExtensions); +const generateInlineExtensionInputTypes = ( + w: TypeScript, + tsIndex: TypeSchemaIndex, + snapshot: SnapshotProfileTypeSchema, +) => { + const tsProfileName = tsResourceName(snapshot.identifier); + const complexExtensions = (snapshot.extensions ?? []).filter((ext) => ext.isComplex && ext.subExtensions); for (const ext of complexExtensions) { if (!ext.url) continue; - const extProfileInfo = resolveExtensionProfile(tsIndex, flatProfile.identifier.package, ext.url); - const hasFlatInput = extProfileInfo ? collectSubExtensionSlices(extProfileInfo.flatProfile).length > 0 : false; + const extProfileInfo = resolveExtensionProfile(tsIndex, snapshot.identifier.package, ext.url); + const hasFlatInput = extProfileInfo ? collectSubExtensionSlices(extProfileInfo.snapshot).length > 0 : false; if (hasFlatInput) continue; const typeName = tsExtensionFlatTypeName(tsProfileName, ext.name); w.curlyBlock(["export", "type", typeName, "="], () => { @@ -654,9 +662,9 @@ const valueToTypeLiteral = (value: unknown): string => { return "unknown"; }; -const generateSliceInputTypes = (w: TypeScript, flatProfile: ProfileTypeSchema, sliceDefs: SliceDef[]) => { +const generateSliceInputTypes = (w: TypeScript, snapshot: SnapshotProfileTypeSchema, sliceDefs: SliceDef[]) => { if (sliceDefs.length === 0) return; - const tsProfileName = tsResourceName(flatProfile.identifier); + const tsProfileName = tsResourceName(snapshot.identifier); for (const sliceDef of sliceDefs) { const inputTypeName = tsSliceFlatTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); const flatTypeName = tsSliceFlatAllTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); @@ -707,12 +715,12 @@ const generateSliceInputTypes = (w: TypeScript, flatProfile: ProfileTypeSchema, } }; -const generateRawType = (w: TypeScript, flatProfile: ProfileTypeSchema, factoryInfo: ProfileFactoryInfo) => { +const generateRawType = (w: TypeScript, snapshot: SnapshotProfileTypeSchema, factoryInfo: ProfileFactoryInfo) => { const hasParams = factoryInfo.params.length > 0 || factoryInfo.sliceAutoFields.length > 0; - const subSlices = flatProfile.base.name === "Extension" ? collectSubExtensionSlices(flatProfile) : []; + const subSlices = snapshot.base.name === "Extension" ? collectSubExtensionSlices(snapshot) : []; if (!hasParams && subSlices.length === 0) return; - const createArgsTypeName = `${tsProfileClassName(flatProfile)}Raw`; + const createArgsTypeName = `${tsProfileClassName(snapshot)}Raw`; w.curlyBlock(["export", "type", createArgsTypeName, "="], () => { for (const p of factoryInfo.params) { w.lineSM(`${p.name}: ${p.tsType}`); @@ -730,11 +738,11 @@ const generateRawType = (w: TypeScript, flatProfile: ProfileTypeSchema, factoryI w.line(); }; -const generateFlatInputType = (w: TypeScript, flatProfile: ProfileTypeSchema) => { - const subSlices = flatProfile.base.name === "Extension" ? collectSubExtensionSlices(flatProfile) : []; +const generateFlatInputType = (w: TypeScript, snapshot: SnapshotProfileTypeSchema) => { + const subSlices = snapshot.base.name === "Extension" ? collectSubExtensionSlices(snapshot) : []; if (subSlices.length === 0) return; - const flatInputTypeName = `${tsProfileClassName(flatProfile)}Flat`; + const flatInputTypeName = `${tsProfileClassName(snapshot)}Flat`; w.curlyBlock(["export", "type", flatInputTypeName, "="], () => { for (const sub of subSlices) { const opt = sub.isRequired ? "" : "?"; @@ -745,22 +753,22 @@ const generateFlatInputType = (w: TypeScript, flatProfile: ProfileTypeSchema) => w.line(); }; -export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { - const tsBaseResourceName = tsTypeFromIdentifier(flatProfile.base); - const profileClassName = tsProfileClassName(flatProfile); - const sliceDefs = collectSliceDefs(tsIndex, flatProfile); - const factoryInfo = collectProfileFactoryInfo(tsIndex, flatProfile); +export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema) => { + const tsBaseResourceName = tsTypeFromIdentifier(snapshot.base); + const profileClassName = tsProfileClassName(snapshot); + const sliceDefs = collectSliceDefs(tsIndex, snapshot); + const factoryInfo = collectProfileFactoryInfo(tsIndex, snapshot); - generateInlineExtensionInputTypes(w, tsIndex, flatProfile); - generateSliceInputTypes(w, flatProfile, sliceDefs); + generateInlineExtensionInputTypes(w, tsIndex, snapshot); + generateSliceInputTypes(w, snapshot, sliceDefs); - generateProfileHelpersImport(w, tsIndex, flatProfile, sliceDefs, factoryInfo); + generateProfileHelpersImport(w, tsIndex, snapshot, sliceDefs, factoryInfo); - generateRawType(w, flatProfile, factoryInfo); - generateFlatInputType(w, flatProfile); + generateRawType(w, snapshot, factoryInfo); + generateFlatInputType(w, snapshot); - const canonicalUrl = flatProfile.identifier.url; - w.comment("CanonicalURL:", canonicalUrl, `(pkg: ${packageMetaToFhir(packageMeta(flatProfile))})`); + const canonicalUrl = snapshot.identifier.url; + w.comment("CanonicalURL:", canonicalUrl, `(pkg: ${packageMetaToFhir(packageMeta(snapshot))})`); w.curlyBlock(["export", "class", profileClassName], () => { w.lineSM(`static readonly canonicalUrl = ${JSON.stringify(canonicalUrl)}`); @@ -768,18 +776,18 @@ export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, fl generateStaticSliceFields(w, sliceDefs); w.lineSM(`private resource: ${tsBaseResourceName}`); w.line(); - generateFactoryMethods(w, tsIndex, flatProfile, factoryInfo); + generateFactoryMethods(w, tsIndex, snapshot, factoryInfo); generateFieldAccessors(w, factoryInfo); w.line("// Extensions"); - generateExtensionMethods(w, tsIndex, flatProfile); + generateExtensionMethods(w, tsIndex, snapshot); w.line("// Slices"); - generateSliceSetters(w, sliceDefs, flatProfile); - generateSliceGetters(w, sliceDefs, flatProfile); + generateSliceSetters(w, sliceDefs, snapshot); + generateSliceGetters(w, sliceDefs, snapshot); w.line("// Validation"); - generateValidateMethod(w, tsIndex, flatProfile); + generateValidateMethod(w, tsIndex, snapshot); }); w.line(); }; diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index 885d4c92..6ebee66a 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -8,8 +8,8 @@ import { isLogicalTypeSchema, isNestedTypeSchema, isPrimitiveIdentifier, - isProfileTypeSchema, isResourceTypeSchema, + isSnapshotProfileTypeSchema, isSpecializationTypeSchema, type NestedTypeSchema, packageMeta, @@ -104,7 +104,7 @@ export class TypeScript extends Writer { generateFhirPackageIndexFile(schemas: TypeSchema[]) { this.cat("index.ts", () => { - const profiles = schemas.filter(isProfileTypeSchema); + const profiles = schemas.filter(isSnapshotProfileTypeSchema); if (profiles.length > 0) { this.lineSM(`export * from "./profiles"`); } @@ -112,7 +112,7 @@ export class TypeScript extends Writer { let exports = schemas .flatMap((schema) => { const resourceName = tsResourceName(schema.identifier); - const typeExports = isProfileTypeSchema(schema) + const typeExports = isSnapshotProfileTypeSchema(schema) ? [] : [ resourceName, @@ -345,13 +345,12 @@ export class TypeScript extends Writer { } generateResourceModule(tsIndex: TypeSchemaIndex, schema: TypeSchema) { - if (isProfileTypeSchema(schema)) { + if (isSnapshotProfileTypeSchema(schema)) { this.cd("profiles", () => { this.cat(`${tsProfileModuleFileName(tsIndex, schema)}`, () => { this.generateDisclaimer(); - const flatProfile = tsIndex.flatProfile(schema); - generateProfileImports(this, tsIndex, flatProfile); - generateProfileClass(this, tsIndex, flatProfile); + generateProfileImports(this, tsIndex, schema); + generateProfileClass(this, tsIndex, schema); }); }); } else if (isSpecializationTypeSchema(schema)) { @@ -380,11 +379,11 @@ export class TypeScript extends Writer { ...tsIndex.collectComplexTypes(), ...tsIndex.collectResources(), ...tsIndex.collectLogicalModels(), - ...(this.opts.generateProfile ? tsIndex.collectProfiles() : []), + ...(this.opts.generateProfile ? tsIndex.collectSnapshotProfiles() : []), ]; const grouped = groupByPackages(typesToGenerate); - const hasProfiles = this.opts.generateProfile && typesToGenerate.some(isProfileTypeSchema); + const hasProfiles = this.opts.generateProfile && typesToGenerate.some(isSnapshotProfileTypeSchema); this.cd("/", () => { if (hasProfiles) { @@ -397,7 +396,7 @@ export class TypeScript extends Writer { for (const schema of packageSchemas) { this.generateResourceModule(tsIndex, schema); } - generateProfileIndexFile(this, tsIndex, packageSchemas.filter(isProfileTypeSchema)); + generateProfileIndexFile(this, tsIndex, packageSchemas.filter(isSnapshotProfileTypeSchema)); this.generateFhirPackageIndexFile(packageSchemas); }); } From ab839ddd4aa5226d9dbceefd1412903e4626d4c6 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 20 May 2026 11:09:49 +0200 Subject: [PATCH 3/6] test: regenerate tree-shake snapshot for new "profile-snapshot" entity bucket --- test/unit/typeschema/ir/__snapshots__/tree-shake.test.ts.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/typeschema/ir/__snapshots__/tree-shake.test.ts.snap b/test/unit/typeschema/ir/__snapshots__/tree-shake.test.ts.snap index 555db34a..1dac3820 100644 --- a/test/unit/typeschema/ir/__snapshots__/tree-shake.test.ts.snap +++ b/test/unit/typeschema/ir/__snapshots__/tree-shake.test.ts.snap @@ -4946,6 +4946,7 @@ exports[`treeShake specific TypeSchema Only Bundle & Operation Outcome without e "http://hl7.org/fhir/StructureDefinition/xhtml": {}, }, "profile": {}, + "profile-snapshot": {}, "resource": { "http://hl7.org/fhir/StructureDefinition/Bundle": {}, "http://hl7.org/fhir/StructureDefinition/DomainResource": {}, @@ -4977,6 +4978,7 @@ exports[`treeShake specific TypeSchema Only Bundle & Operation Outcome without e "nested": {}, "primitive-type": {}, "profile": {}, + "profile-snapshot": {}, "resource": {}, "value-set": {}, }, From 47200c2a26af6b2503b805dcc1a17822efefb360 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 20 May 2026 11:09:53 +0200 Subject: [PATCH 4/6] TS: regenerate type-tree YAMLs for new "profile-snapshot" entity bucket --- examples/typescript-r4/fhir-types/type-tree.yaml | 2 ++ examples/typescript-us-core/fhir-types/type-tree.yaml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/examples/typescript-r4/fhir-types/type-tree.yaml b/examples/typescript-r4/fhir-types/type-tree.yaml index c7440755..7e2cf205 100644 --- a/examples/typescript-r4/fhir-types/type-tree.yaml +++ b/examples/typescript-r4/fhir-types/type-tree.yaml @@ -74,6 +74,7 @@ hl7.fhir.r4.core: http://hl7.org/fhir/StructureDefinition/bp: {} http://hl7.org/fhir/StructureDefinition/vitalsigns: {} http://hl7.org/fhir/StructureDefinition/humanname-own-prefix: {} + profile-snapshot: {} logical: {} shared: primitive-type: {} @@ -137,4 +138,5 @@ shared: urn:fhir:binding:UsageContextType: {} urn:fhir:binding:VitalSigns: {} profile: {} + profile-snapshot: {} logical: {} diff --git a/examples/typescript-us-core/fhir-types/type-tree.yaml b/examples/typescript-us-core/fhir-types/type-tree.yaml index d4aa5017..d6bde973 100644 --- a/examples/typescript-us-core/fhir-types/type-tree.yaml +++ b/examples/typescript-us-core/fhir-types/type-tree.yaml @@ -20,6 +20,7 @@ hl7.fhir.us.core: http://hl7.org/fhir/us/core/StructureDefinition/us-core-race: {} http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation: {} http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs: {} + profile-snapshot: {} logical: {} hl7.fhir.r4.core: primitive-type: @@ -90,6 +91,7 @@ hl7.fhir.r4.core: binding: {} profile: http://hl7.org/fhir/StructureDefinition/vitalsigns: {} + profile-snapshot: {} logical: {} shared: primitive-type: {} @@ -150,4 +152,5 @@ shared: urn:fhir:binding:UsageContextType: {} urn:fhir:binding:VitalSigns: {} profile: {} + profile-snapshot: {} logical: {} From 97a9c7dde15fa6a83c89f2fa546d6e615e40e600 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 20 May 2026 15:54:27 +0200 Subject: [PATCH 5/6] docs: classify the methods the TS writer emits for profiles Captures the 8 emit categories the TS writer currently produces inside a profile module (constants, factory, lifecycle, field/slice/extension accessors, validator, type aliases), the descriptor data each needs, and the migration target: a `methods` array on SnapshotProfileTypeSchema where each entry is a tagged descriptor and the writer becomes a renderer that dispatches on `entry.kind`. This is the foundation for the follow-up PR series that moves method derivation out of the writer. --- docs/design/profile-methods.md | 132 +++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/design/profile-methods.md diff --git a/docs/design/profile-methods.md b/docs/design/profile-methods.md new file mode 100644 index 00000000..d488fcb3 --- /dev/null +++ b/docs/design/profile-methods.md @@ -0,0 +1,132 @@ +# Profile Method Taxonomy + +Status: Proposed. Captures the categories of code the TS writer currently emits inside a profile module, and the descriptors each emit needs. + +This doc is the foundation for moving method derivation out of the TS writer and onto `SnapshotProfileTypeSchema` — once every emit is classified, the writer becomes a renderer that walks a typed list of descriptors instead of computing decisions inline. + +## Categories + +### 1. Module type aliases + +Emitted before the class body. Each carries shape information consumers need to call factory / accessor methods. + +| Name | Source helper | When emitted | +|---|---|---| +| `XxxProfile_extName_Flat` | `generateInlineExtensionInputTypes` | per complex extension that lacks a resolved profile class | +| `XxxProfile_field_sliceFlat` | `generateSliceInputTypes` | per slice — setter input, discriminator fields omitted | +| `XxxProfile_field_sliceFlatAll` | `generateSliceInputTypes` | per slice — getter return, includes discriminator values as readonly literals | +| `XxxProfileRaw` | `generateRawType` | factory args type — emitted when there are params or extension sub-slices | +| `XxxProfileFlat` | `generateFlatInputType` | Extension-profile-only flat input shape | + +### 2. Class constants + +| Name | Source | +|---|---| +| `static readonly canonicalUrl` | `generateProfileClass` | +| `private static readonly XxxSliceMatch: Record` | `generateStaticSliceFields`, one per slice | + +### 3. Static factory methods + +| Method | Role | Emit condition | +|---|---|---| +| `static from(resource)` | wrap an existing FHIR resource and validate it | always | +| `static is(resource): resource is T` | type predicate via `meta.profile` (resource) or `url` (Extension) | resource with `meta` ancestor, or Extension base | +| `static apply(resource): Profile` | tag with canonicalUrl + auto-fields, return wrapper | always | +| `static createResource(args): T` | build the underlying FHIR object | always (signature varies on hasParams) | +| `static create(args): Profile` | `apply(createResource(args))` | always | +| `private static resolveInput(args)` | Extension-only: convert Flat input → `Extension[]` | Extension base **and** has sub-extension slices | + +### 4. Instance lifecycle + +| Method | Role | +|---|---| +| `constructor(resource)` | store the resource reference | +| `toResource(): T` | unwrap to the underlying FHIR object | + +### 5. Field accessors + +Pair of `getXxx() / setXxx(value)` per field. Three sub-flavors, all driven by `factoryInfo` from `collectProfileFactoryInfo`: + +| Flavor | Source | Setter emitted? | Notes | +|---|---|---|---| +| **param accessor** | `factoryInfo.params` | yes | required fields constrained by the profile, plus base-required fields not otherwise covered | +| **auto accessor** | `factoryInfo.accessors` (autoAccessors branch) | only if not in `factoryInfo.fixedFields` | for `valueConstraint`-driven fields and required-slice array fields | +| **choice accessor** | `factoryInfo.accessors` (choice instances) | yes | non-promoted choice instances | + +### 6. Slice accessors + +Pair of `setXxxSlice(input) / getXxxSlice(mode?)` per `SliceDef` from `collectSliceDefs`. Sub-flavors driven by `SliceDef` fields: + +| Sub-flavor | Triggering shape | Setter | Getter | +|---|---|---|---| +| **unbounded** | `def.array && max === 0/undefined` | accepts `(T \| Flat)[]`, replaces all matched | returns `T[] \| undefined`, with `mode: 'flat'\|'raw'` overloads | +| **single-element** | `def.max === 1` | accepts `T \| Flat`, replaces single match | returns `T \| undefined`, with `mode` overloads | +| **constrained-choice** | `def.constrainedChoice !== undefined` | wraps via `wrapSliceChoice` around the variant | unwraps via `unwrapSliceChoice` | +| **type-discriminator** | `def.typeDiscriminator === true` | uses typed base (`BundleEntry`) | same | + +### 7. Extension accessors + +Pair of `setXxxExt(value) / getXxxExt(mode?)` per `ProfileExtension`. Three branches: + +| Branch | Triggering condition | Setter shape | Getter overloads | +|---|---|---|---| +| **complex** | `ext.isComplex && ext.subExtensions` | `Flat \| ProfileClass \| Extension` (or just `Flat` if no profile class resolved) | `'flat' \| 'profile' \| 'raw'` | +| **single-value** | exactly one `valueFieldTypes` entry | value-type \| `ProfileClass \| Extension` if profile class resolved | `'flat' \| 'profile' \| 'raw'` | +| **generic** | otherwise | `Omit \| Extension` | returns `Extension \| undefined` (no overloads) | + +Cross-cutting concern: nested-path extensions (e.g. `Patient.address.extension`) use `ensurePath` to walk into the target object before pushing. + +### 8. Validation + +| Method | Source | +|---|---| +| `validate(): { errors: string[]; warnings: string[] }` | `generateValidateMethod` | + +Per-field checks emitted into the body, from `collectRegularFieldValidation`: +- `validateRequired` — required fields +- `validateExcluded` — excluded fields and choice prohibitions +- `validateFixedValue` — `valueConstraint`-driven exact values +- `validateEnum` — closed enums (errors) vs open enums (warnings) +- `validateMustSupport` — `mustSupport && !required` (warning) +- `validateReference` — reference target type names +- `validateSliceCardinality` — slice min/max +- `validateSliceFields` — required fields inside matched slice elements (incl. constrained-choice variant) +- `validateChoiceRequired` — required choice declarations + +## Helper imports + +Static asset imports from `assets/api/writer-generator/typescript/profile-helpers.ts`, gated by emitted-method footprint via `generateProfileHelpersImport`: + +`ensureProfile`, `isRawExtensionInput`, `applySliceMatch`, `matchesValue`, `setArraySlice`, `getArraySlice`, `setArraySliceAll`, `getArraySliceAll`, `ensureSliceDefaults`, `ensurePath`, `extractComplexExtension`, `wrapSliceChoice`, `unwrapSliceChoice`, `isExtension`, `getExtensionValue`, `pushExtension`, `upsertExtension`, plus the `validate*` set. + +## Proposed descriptor model + +The taxonomy collapses into eight emit roles. Each carries the data needed to render: + +``` +StaticConstant — canonicalUrl, slice-match records +Factory — from, is, apply, create, createResource, resolveInput +Lifecycle — constructor, toResource +FieldAccessor — param / auto / choice +SliceAccessor — single / unbounded / constrained-choice / type-discriminator +ExtensionAccessor — complex / single-value / generic +Validator — validate() +ModuleTypeAlias — Raw, Flat, *SliceFlat, *SliceFlatAll, *_extFlat +``` + +The migration target: a `methods` array on `SnapshotProfileTypeSchema` (or a paired structure) where each entry is a tagged descriptor in one of these eight categories. The writer then dispatches on `entry.kind` and renders — no decision logic in the writer body. + +## What this enables + +- **Multi-language reuse** — Python/C# generators can consume the same descriptors with their own renderers. +- **Snapshot inspectability** — `bun run src/cli/index.ts typeschema generate ...` could dump descriptors for review without running a language writer. +- **Testability** — descriptors are pure data; today's tests assert generated text against snapshots, which is coarse. +- **Stability** — adding a new method kind (e.g. a future `clone()` or `diff(other)`) is "add a descriptor variant + a renderer" instead of "edit the writer's monolithic generator". + +## Out of scope + +- Concrete method-spec record types (the `Param`, `SliceDef`, `ExtensionMethodSpec` shapes already exist in the writer; the migration step formalizes them as snapshot-level types). +- Migration order across writer modules. +- Multi-language renderer details. + +Those land in follow-up design notes once the descriptor model is agreed. From 824a2bf60af50b1061ef0b8506a50e118ecc5558 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 20 May 2026 16:10:53 +0200 Subject: [PATCH 6/6] docs: draft ProfileMethod descriptor union for SnapshotProfileTypeSchema.methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pass at the language-neutral descriptor model that future code generation will dispatch on. Adds a "methods field type — first draft" section to the design doc: - Shared records (AutoField, ParamField, SliceAutoField, SliceMatchConstant, FieldAccessorSpec, SliceSpec, ExtensionAccessorSpec, ValidationCheck, SubExtensionSlice). - Tagged ProfileMethod union with 22 variants covering all eight taxonomy categories. - Open questions: ordering, hasMeta inlining vs derivation, flavor as sub-field vs sub-variant, where validation lives, helper-import gating, type aliases as a separate array. --- docs/design/profile-methods.md | 228 ++++++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 3 deletions(-) diff --git a/docs/design/profile-methods.md b/docs/design/profile-methods.md index d488fcb3..e3970fa9 100644 --- a/docs/design/profile-methods.md +++ b/docs/design/profile-methods.md @@ -123,10 +123,232 @@ The migration target: a `methods` array on `SnapshotProfileTypeSchema` (or a pai - **Testability** — descriptors are pure data; today's tests assert generated text against snapshots, which is coarse. - **Stability** — adding a new method kind (e.g. a future `clone()` or `diff(other)`) is "add a descriptor variant + a renderer" instead of "edit the writer's monolithic generator". -## Out of scope +## `methods` field type — first draft + +The descriptors live on `SnapshotProfileTypeSchema` as an ordered array. Each entry is a tagged record; the discriminator `kind` selects the variant. **Everything is language-neutral** — no TS-specific type names, no rendered strings. Renderers consume the records and emit syntax. + +### Shared records + +```ts +/** Direct or dotted path inside the resource. Single segment = top-level field; + * longer = nested (e.g. `["meta", "profile"]` or a backbone path). */ +export type FieldPath = string[]; + +/** Auto-assigned field with a fixed value the renderer formats per its syntax. */ +export type AutoField = { + name: string; + /** Raw value (string, number, boolean, object, array). Renderer literalizes it. */ + value: unknown; + /** Wrap in an array literal when emitting. */ + array: boolean; +}; + +/** Required parameter on factory methods + the type of the corresponding accessor. */ +export type ParamField = { + name: string; + type: TypeIdentifier; + reference?: TypeIdentifier[]; + array: boolean; + binding?: BindingIdentifier; +}; + +/** Array field with a required slice — factory takes an optional value and auto-merges + * stub elements derived from the slice's match record. */ +export type SliceAutoField = { + name: string; + type: TypeIdentifier; + array: boolean; + /** Names of the slice-match constants to seed defaults from. */ + sliceMatchNames: string[]; +}; + +/** Named constant holding a slice's match record, emitted on the class. */ +export type SliceMatchConstant = { + /** Symbol name (e.g. "VSCatSliceMatch"). */ + staticName: string; + /** The match record itself (raw JSON-like). */ + match: Record; +}; + +/** Get/set target for an accessor that lives on the resource. */ +export type FieldAccessorSpec = { + name: string; + /** Path inside the resource — `[name]` for direct, longer for backbone paths. */ + path: FieldPath; + type: TypeIdentifier; + reference?: TypeIdentifier[]; + array: boolean; + optional: boolean; +}; + +/** Slice descriptor — captures every fact a renderer needs without going back to the schema. */ +export type SliceSpec = { + fieldName: string; + sliceName: string; + /** Collision-free PascalCase base (e.g. "VSCat", "SystolicBP"). */ + baseName: string; + /** Base type of the field being sliced (e.g. CodeableConcept, ObservationComponent). */ + baseType: TypeIdentifier; + matchStaticName: string; + match: Record; + /** Required fields inside the slice element, with match-keys/choice-bases removed. */ + required: string[]; + excluded: string[]; + /** Whether the parent field is an array. */ + array: boolean; + /** unbounded = max 0/undefined, single = max 1. */ + cardinality: "unbounded" | "single"; + /** When the slice constrains a choice declaration to a single variant. */ + constrainedChoice?: ConstrainedChoiceInfo; + /** Slice discriminated by `resourceType` (e.g. Bundle entries by Patient/Observation). */ + typeDiscriminator: boolean; + /** When the slice's discriminator yields a concrete resource type (only meaningful + * for typeDiscriminator slices). Renderer parameterizes the base type with this. */ + typeDiscriminatorTarget?: ResourceIdentifier; +}; + +/** Extension accessor descriptor — captures the source extension plus the writer-side + * derivations the original code computed inline (flavor, target path, profile link). */ +export type ExtensionAccessorSpec = { + /** Source extension definition (carries url, name, valueFieldTypes, subExtensions, …). */ + ext: ProfileExtension; + /** Method base name (e.g. "Race" → `setRaceExt`/`getRaceExt`). */ + methodBaseName: string; + /** Where on the resource the extension lives — empty for root, else nested path. */ + targetPath: FieldPath; + /** Drives setter/getter shape. */ + flavor: "complex" | "single-value" | "generic"; + /** Resolved extension-profile class (when the extension URL points to a profile we generate). */ + profile?: { + /** Snapshot identifier — the renderer resolves to a module/symbol per its naming. */ + identifier: SnapshotProfileIdentifier; + /** Whether the linked profile exposes a Flat input variant (sub-extension slices). */ + hasFlatInput: boolean; + }; +}; + +/** Validation check, language-neutral. The renderer maps each to its own helper invocation. */ +export type ValidationCheck = + | { kind: "required"; field: string } + | { kind: "excluded"; field: string } + | { kind: "fixed-value"; field: string; value: unknown } + | { kind: "enum"; field: string; values: string[]; severity: "error" | "warning" } + | { kind: "must-support"; field: string } + | { kind: "reference"; field: string; allowed: string[] } + | { kind: "slice-cardinality"; field: string; sliceName: string; match: Record; min: number; max: number } + | { kind: "slice-fields"; field: string; sliceName: string; match: Record; required: string[] } + | { kind: "choice-required"; choices: string[] }; + +/** Sub-extension slice on an Extension profile — drives Flat input shape + resolveInput. */ +export type SubExtensionSlice = { + name: string; + url: string; + valueField: string; + /** Resolved type for the value (TypeIdentifier when complex, undefined for primitives). */ + type?: TypeIdentifier; + /** Primitive fallback name ("string"/"boolean"/"number") when `type` is undefined. */ + primitive?: string; + array: boolean; + required: boolean; +}; +``` + +### The tagged union + +```ts +export type ProfileMethod = + // Class constants + | { kind: "static-canonical-url" } + | { kind: "static-slice-match"; def: SliceMatchConstant } + + // Static factory + | { kind: "static-from"; baseType: TypeIdentifier; hasMeta: boolean } + | { kind: "static-is"; baseType: TypeIdentifier; mode: "resource" | "extension" } + | { + kind: "static-apply"; + baseType: TypeIdentifier; + autoFields: AutoField[]; + sliceAutoFields: SliceAutoField[]; + /** Set `resource.url = canonicalUrl` — Extension-profile only. */ + setExtensionUrl: boolean; + hasMeta: boolean; + } + | { + kind: "static-create-resource"; + baseType: TypeIdentifier; + params: ParamField[]; + sliceAutoFields: SliceAutoField[]; + autoFields: AutoField[]; + /** Args go through resolveInput → Extension[] (Extension profiles with sub-slices). */ + viaResolveInput: boolean; + hasMeta: boolean; + } + | { kind: "static-create"; viaResolveInput: boolean } + | { kind: "static-resolve-input"; subSlices: SubExtensionSlice[] } + + // Lifecycle + | { kind: "constructor"; baseType: TypeIdentifier } + | { kind: "to-resource"; baseType: TypeIdentifier } + + // Field accessors — separate get/set so renderers can position or omit either + | { kind: "field-get"; field: FieldAccessorSpec } + | { kind: "field-set"; field: FieldAccessorSpec } + + // Slice accessors + | { kind: "slice-get"; def: SliceSpec } + | { kind: "slice-set"; def: SliceSpec } + + // Extension accessors + | { kind: "extension-get"; spec: ExtensionAccessorSpec } + | { kind: "extension-set"; spec: ExtensionAccessorSpec } + + // Validator + | { kind: "validate"; checks: ValidationCheck[] } + + // Module-level type aliases — emitted outside the class body + | { + kind: "type-alias-raw"; + params: ParamField[]; + sliceAutoFields: SliceAutoField[]; + /** Extension profile with sub-slices but no `extension` param: include `extension?`. */ + includeExtensionField: boolean; + } + | { kind: "type-alias-flat"; subSlices: SubExtensionSlice[] } + | { + kind: "type-alias-slice-flat"; + def: SliceSpec; + /** Final excluded list (match keys + slice.excluded + choice-base/variants). */ + excluded: string[]; + /** Fields the input must require. */ + required: string[]; + } + | { + kind: "type-alias-slice-flat-all"; + def: SliceSpec; + /** Literals to emit as readonly discriminator fields on the getter return. */ + matchLiterals: Record; + } + | { + kind: "type-alias-extension-flat"; + ext: ProfileExtension; + /** Skip when the extension URL resolves to a profile that exposes its own Flat. */ + shouldEmit: boolean; + }; +``` + +### Notes / open questions + +- **Ordering matters.** The descriptors are an ordered list — renderers emit them in sequence (canonicalUrl first, then static fields, then constructor, then accessors, then validate). Alternative: bucket by kind in named arrays. Single ordered list is simpler and lets the snapshot builder pick a deliberate order once. +- **`hasMeta` / `viaResolveInput` flags vs. derived rendering.** Both are facts about the underlying profile. Renderers could compute them from the snapshot independently, but inlining keeps descriptors self-contained. +- **Extension `flavor` and slice `cardinality` could be encoded as variant kinds.** E.g. `"extension-set-complex"`, `"extension-set-single-value"`, `"extension-set-generic"`. That makes the variant set huge but each renderer dispatch becomes a single switch arm. The current draft keeps three flavors as a sub-field; renderers branch internally. The trade-off: tagged subvariants are nicer when the bodies diverge sharply; sub-fields are nicer when they share a lot of common code. **Recommendation: keep flavor as a sub-field for slice and extension** — the common path (lookup → format → emit) dominates. +- **Validation checks** could move out of `ProfileMethod` as their own array (separate concern from "methods"). Current draft keeps them under the `validate` method so the descriptor sequence is one ordered list. Open to splitting. +- **Helper-import gating** is not encoded — currently derived in `generateProfileHelpersImport` by examining the method footprint. Either: (a) renderers re-derive (simple, slight duplication), or (b) the snapshot builder emits a `required-helpers: Set` alongside. (a) seems fine. +- **Module type aliases** are mixed in with methods. They're not really "methods" — could split into `methods` and `typeAliases` arrays, or rename the field to `emits`. + +## Out of scope (still) -- Concrete method-spec record types (the `Param`, `SliceDef`, `ExtensionMethodSpec` shapes already exist in the writer; the migration step formalizes them as snapshot-level types). +- Concrete renderer implementations (TS first, Python/C# later). - Migration order across writer modules. - Multi-language renderer details. -Those land in follow-up design notes once the descriptor model is agreed. +Those land in follow-up notes once the descriptor model is agreed.