import { isDeclarable, type Declarable } from "./declarable"; import { setProvenance } from "./provenance"; /** * Marker symbol for Composite type identification. */ export const COMPOSITE_MARKER = Symbol.for("chant.composite"); /** * A record of named members produced by a composite factory. * * What a consumer reads off `instance.members`. Deliberately narrow: every * value here is a real Declarable, so `.entityType` and friends resolve without * narrowing. What a factory may RETURN is wider — see * {@link CompositeFactoryMembers}. */ export type CompositeMembers = Record; /** * What a factory is allowed to RETURN — {@link CompositeMembers} plus * `undefined`, used only as the generic constraint. * * A member produced by a conditional spread (`...(cond ? { policy } : {})`) is * typed optional, and an optional property is not assignable to a * required-value record. Widening the constraint lets such a factory typecheck; * widening `CompositeMembers` itself would make every member possibly-undefined * for everyone reading `instance.members`, which is a worse trade. * * The key is absent at runtime rather than present-and-undefined, so nothing * reaches the validation in `Composite` below. */ export type CompositeFactoryMembers = // eslint-disable-next-line @typescript-eslint/no-explicit-any | Record | undefined> // A pass-through composite returns another composite's INSTANCE rather than // building a record (`return FargateService({...})`). That works at runtime // because `members` and `_definition` are defined non-enumerable below // precisely so an instance exposes only its member resources — but a type // cannot say "non-enumerable", so the instance has to be admitted directly. // eslint-disable-next-line @typescript-eslint/no-explicit-any | CompositeInstance; /** * The result of instantiating a composite — contains the marker and expanded members. */ export interface CompositeInstance { readonly [COMPOSITE_MARKER]: true; readonly members: M; // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly _definition: CompositeDefinition; } /** * A composite definition: a callable that produces a CompositeInstance. */ export interface CompositeDefinition { (props: P): CompositeInstance & M; readonly compositeName: string; readonly _id: symbol; } /** * Type guard: is this value a CompositeInstance? */ export function isCompositeInstance(value: unknown): value is CompositeInstance { return ( typeof value === "object" && value !== null && COMPOSITE_MARKER in value && (value as Record)[COMPOSITE_MARKER] === true ); } /** * Global registry of composite definitions. */ export class CompositeRegistry { private static definitions = new Map>(); static register(definition: CompositeDefinition): void { this.definitions.set(definition._id, definition); } static getAll(): CompositeDefinition[] { return Array.from(this.definitions.values()); } static clear(): void { this.definitions.clear(); } static get size(): number { return this.definitions.size; } } /** * Creates a composite definition from a factory closure. * * Usage: * ```ts * const SecureStorage = Composite<{ name: string }>((props) => { * const bucket = new Bucket({ bucketName: props.name }); * const role = new Role({ policies: [{ resource: bucket.arn }] }); * return { bucket, role }; * }); * export const storage = SecureStorage({ name: "data" }); * ``` */ export function Composite( factory: (props: P) => M, name?: string, ): CompositeDefinition { const id = Symbol(); const compositeName = name ?? "anonymous"; const definition = ((props: P): CompositeInstance & M => { const members = factory(props); for (const [key, value] of Object.entries(members)) { if (!isDeclarable(value) && !isCompositeInstance(value)) { throw new Error( `Composite "${compositeName}" member "${key}" is not a Declarable or CompositeInstance`, ); } } // Define `members` and `_definition` as non-enumerable so spreading a // composite instance (`...someComposite`) only exposes the actual member // resources, not the framework's bookkeeping properties. Without this, a // parent composite that does `...childResult` ends up with a `members` key // pointing at the child's CompositeMembers record — not a Declarable — // which then trips the parent's own member validation. const instance = {} as CompositeInstance; Object.defineProperty(instance, COMPOSITE_MARKER, { value: true, enumerable: false }); Object.defineProperty(instance, "members", { value: members, enumerable: false }); Object.defineProperty(instance, "_definition", { value: definition, enumerable: false }); return Object.assign(instance, members) as CompositeInstance & M; }) as CompositeDefinition; Object.defineProperty(definition, "compositeName", { value: compositeName, writable: false }); Object.defineProperty(definition, "_id", { value: id, writable: false }); CompositeRegistry.register(definition as CompositeDefinition); return definition; } /** * Expands a CompositeInstance into a flat Map of prefixed entity names to Declarables. * Handles nested composites recursively. */ export function expandComposite( prefix: string, instance: CompositeInstance, ): Map { const result = new Map(); const shared = (instance as unknown as Record)[SHARED_PROPS] as Record | undefined; const compositeName = instance._definition?.compositeName; for (const [memberName, member] of Object.entries(instance.members)) { const fullName = `${prefix}${memberName[0].toUpperCase()}${memberName.slice(1)}`; if (isCompositeInstance(member)) { const nested = expandComposite(fullName, member); for (const [nestedName, nestedEntity] of nested) { // Inner composite already stamped; `??=` keeps the most-specific one. if (compositeName) setProvenance(nestedEntity, { composite: compositeName }); result.set(nestedName, nestedEntity); } } else { if (compositeName) setProvenance(member as Declarable, { composite: compositeName }); result.set(fullName, member as Declarable); } } if (shared) { for (const entity of result.values()) { if ("props" in entity) { // Merge shared props onto each member's ORIGINAL props, not its current // props. Members are module-level singletons, so without stashing the // original a second expansion (e.g. building the same tree twice in one // process) would re-merge shared arrays like tags onto the already-merged // value, duplicating them on every rebuild (#1032). Stashing the original // once makes expansion idempotent: same input -> same output, every time. const store = entity as unknown as Record; let existing = store[ORIGINAL_PROPS] as Record | undefined; if (existing === undefined) { existing = entity.props as Record; Object.defineProperty(entity, ORIGINAL_PROPS, { value: existing, enumerable: false, configurable: true, }); } const merged: Record = {}; for (const [k, v] of Object.entries(shared)) { if (v !== undefined) { merged[k] = v; } } for (const [k, v] of Object.entries(existing)) { if (v !== undefined) { if (Array.isArray(v) && Array.isArray(merged[k])) { merged[k] = [...(merged[k] as unknown[]), ...v]; } else { merged[k] = v; } } } Object.defineProperty(entity, "props", { value: merged, enumerable: false, configurable: true, }); } } } return result; } /** * Type helpers for withDefaults. */ type PartialByDefault> = Omit & Partial>; type Simplify = { [K in keyof T]: T[K] }; /** * Wraps a CompositeDefinition with pre-applied default values. * Props that have defaults become optional in the returned type. * * ```ts * const SecureApi = withDefaults(LambdaApi, { runtime: "nodejs20.x", timeout: 10 }); * const api = SecureApi({ name: "myApi", code: "./dist" }); // runtime and timeout are optional * ``` */ export function withDefaults>( definition: CompositeDefinition, defaults: D | ((props: Partial

) => D), ): CompositeDefinition>, M> { const wrapped = ((props: Simplify>) => { const resolved = typeof defaults === "function" ? defaults(props as Partial

) : defaults; return definition({ ...resolved, ...props } as P); }) as CompositeDefinition>, M>; Object.defineProperty(wrapped, "compositeName", { value: definition.compositeName, writable: false, }); Object.defineProperty(wrapped, "_id", { value: definition._id, writable: false, }); return wrapped; } /** * Symbol key for shared props attached by propagate(). */ export const SHARED_PROPS = Symbol.for("chant.composite.shared"); /** * Symbol key stashing a member's original (pre-merge) props, so expandComposite() * is idempotent across repeated expansions of the same singleton instance (#1032). */ export const ORIGINAL_PROPS = Symbol.for("chant.composite.origProps"); /** * Attaches shared properties to a composite instance. * During expandComposite(), shared props are merged into every member's props. * * Merge semantics: * - Scalars: member-specific value wins * - Arrays (e.g. tags): concatenate shared + member-specific * - undefined values in shared props are stripped * * ```ts * export const storage = propagate( * SecureStorage({ name: "data" }), * { tags: [{ key: "env", value: "prod" }] }, * ); * ``` */ export function propagate( instance: CompositeInstance & M, sharedProps: Record, ): CompositeInstance & M { Object.defineProperty(instance, SHARED_PROPS, { value: sharedProps, enumerable: false, }); return instance; } /** * Marker function for resource declarations within composites. * At runtime, simply calls `new Type(props)` and returns the result. * Exists so lint tooling can validate composite member construction (EVL005). */ export function resource( Type: new (props: P, attributes?: Record) => T, props: P, attributes?: Record, ): T { return new Type(props, attributes); } /** * Shallow-merges override values into a base props object. * * Merge semantics: * - `undefined` values in overrides are skipped * - Arrays: concatenated (base + overrides), matching `propagate()` semantics * - Scalars / objects: override wins * * No deep merge — too dangerous with IaC props where nested objects * (e.g. policy documents) should be replaced wholesale. */ export function mergeDefaults>( base: T, overrides?: Partial, ): T { if (!overrides) return base; const result = { ...base }; for (const [key, value] of Object.entries(overrides)) { if (value === undefined) continue; const existing = result[key as keyof T]; if (Array.isArray(existing) && Array.isArray(value)) { (result as Record)[key] =[...existing, ...value]; } else if ( existing != null && typeof existing === "object" && !Array.isArray(existing) && value != null && typeof value === "object" && !Array.isArray(value) ) { (result as Record)[key] =mergeDefaults( existing as Record, value as Record, ); } else { (result as Record)[key] =value; } } return result; }