// Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import 'reflect-metadata'; import { AutoMap } from '@cratis/chronicle.contracts'; import { Constructor, Fields } from '@cratis/fundamentals'; import { TypeIntrospector } from '../../types/index.js'; import { constantValueExpression } from '../constantValueExpression.js'; import { eventContextPropertyExpression } from '../eventContextPropertyExpression.js'; import { notSetPropertyPath } from '../notSetPropertyPath.js'; import { getEventTypeFor } from '../../events/eventTypeDecorator.js'; import { getAddFromMetadata } from './addFrom.js'; import { ChildrenFromMetadata, getChildrenFromMetadata } from './childrenFrom.js'; import { getClearWithClassMetadata, getClearWithPropertyMetadata } from './clearWith.js'; import { getCountMetadata } from './count.js'; import { getDecrementMetadata } from './decrement.js'; import { getFromEventMetadata } from './fromEvent.js'; import { getIncrementMetadata } from './increment.js'; import { isNested } from './nested.js'; import { isNoAutoMap, isPropertyNoAutoMap } from './noAutoMap.js'; import { getRemovedWithClassMetadata, getRemovedWithPropertyMetadata } from './removedWith.js'; import { getRemovedWithJoinClassMetadata, getRemovedWithJoinPropertyMetadata } from './removedWithJoin.js'; import { getSetFromMetadata } from './setFrom.js'; import { getSetFromContextMetadata } from './setFromContext.js'; import { getSetValueMetadata } from './setValue.js'; import { getSubtractFromMetadata } from './subtractFrom.js'; /** The contract-level event type identifier shape used across projection definitions. */ export type ContractEventType = { Id: string; Generation: number; Tombstone: boolean }; /** Accumulated property mapping for a from clause. */ export interface FromRecord { Key: ContractEventType; Value: { Properties: Record; Key: string; ParentKey: string }; } /** Plain object shape matching the wire-level `ChildrenDefinition`. */ export interface ChildrenDefinitionLike { IdentifiedBy: string; From: FromRecord[]; Join: unknown[]; Children: Record; All: { Properties: Record; IncludeChildren: boolean; AutoMap: AutoMap }; RemovedWith: Array<{ Key: ContractEventType; Value: { Key: string; ParentKey: string } }>; RemovedWithJoin: Array<{ Key: ContractEventType; Value: { Key: string } }>; AutoMap: AutoMap; Nested: Record; NoAutoMapProperties: string[]; } const aggregateExpressions = ['$count', '$increment', '$decrement', '$add', '$subtract']; /** * Resolves the contract event type for a decorated event constructor. * @param eventTypeConstructor - The event class constructor. * @returns The contract-level event type identifier. */ export function toContractEventType(eventTypeConstructor: Function): ContractEventType { const eventType = getEventTypeFor(eventTypeConstructor); if (eventType.id.value === '') { throw new Error(`Event type '${eventTypeConstructor.name}' is not decorated with @eventType().`); } return { Id: eventType.id.value, Generation: eventType.generation.value, Tombstone: false }; } /** * Builds a stable map key for a contract event type. * @param eventType - The contract event type. * @returns A string key unique to the event type/generation/tombstone combination. */ export function getEventTypeMapKey(eventType: ContractEventType): string { return `${eventType.Id}:${eventType.Generation}:${eventType.Tombstone ? '1' : '0'}`; } /** * Gets or creates the `From` entry for an event type within the given map. * @param fromByEventType - The map of event type key to accumulated from entry. * @param eventTypeConstructor - The event constructor to resolve the entry for. * @returns The existing or newly created from entry. */ export function ensureFromEntry(fromByEventType: Map, eventTypeConstructor: Function): FromRecord { const eventType = toContractEventType(eventTypeConstructor); const key = getEventTypeMapKey(eventType); const existing = fromByEventType.get(key); if (existing) { return existing; } const created: FromRecord = { Key: eventType, Value: { Properties: {}, Key: '$eventSourceId', ParentKey: '' } }; fromByEventType.set(key, created); return created; } /** * Applies the property-mapping decorators (`@setFrom`, `@setFromContext`, `@setValue`, * `@addFrom`, `@subtractFrom`, `@increment`, `@decrement`, `@count`) declared on a single * property into the given from-by-event-type map. Shared by the root model-bound processing * loop and by child/nested type processing below, so both honor the same decorators. * @param prototype - The class prototype the property is declared on. * @param property - The property name. * @param fromByEventType - The map of event type key to accumulated from entry to populate. */ export function applyPropertyMappings(prototype: object, property: string, fromByEventType: Map): void { for (const mapping of getSetFromMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = mapping.eventPropertyName ?? property; } for (const mapping of getSetFromContextMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = eventContextPropertyExpression(mapping.contextPropertyName ?? property); } for (const mapping of getSetValueMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = constantValueExpression(mapping.value); } for (const mapping of getAddFromMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = `$add(${mapping.eventPropertyName ?? property})`; } for (const mapping of getSubtractFromMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = `$subtract(${mapping.eventPropertyName ?? property})`; } for (const mapping of getIncrementMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = '$increment'; if (mapping.constantKey) { entry.Value.Key = constantValueExpression(mapping.constantKey); } } for (const mapping of getDecrementMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = '$decrement'; if (mapping.constantKey) { entry.Value.Key = constantValueExpression(mapping.constantKey); } } for (const mapping of getCountMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, mapping.eventType); entry.Value.Properties[property] = '$count'; if (mapping.constantKey) { entry.Value.Key = constantValueExpression(mapping.constantKey); } } } function createEmptyChildrenDefinition(parentType: Function, childType: Function | undefined): ChildrenDefinitionLike { return { IdentifiedBy: '$eventSourceId', From: [], Join: [], Children: {}, All: { Properties: {}, IncludeChildren: false, AutoMap: AutoMap.Inherit }, RemovedWith: [], RemovedWithJoin: [], AutoMap: isNoAutoMap(parentType) || (childType !== undefined && isNoAutoMap(childType)) ? AutoMap.Disabled : AutoMap.Enabled, Nested: {}, NoAutoMapProperties: childType === undefined ? [] : TypeIntrospector.getTrackedProperties(childType) .filter(property => isPropertyNoAutoMap(childType.prototype, property)) }; } /** * Resolves the element type of a `@childrenFrom` collection property. TypeScript erases * generic type arguments at runtime, so the element type must be supplied by @childrenFrom * or `@field(Array, { genericArguments: [ItemType] })` - without it, * the children definition still registers correctly (structural Key/ParentKey wiring plus * AutoMap), it just cannot also translate the child type's own decorators. * @param type - The declaring class constructor. * @param property - The property name. * @returns The child element type constructor, or undefined when it cannot be resolved. */ export function resolveChildElementType(type: Function, property: string): Function | undefined { const field = Fields.getFieldsForType(type as Constructor).find(candidate => candidate.name === property); return getChildrenFromMetadata(type.prototype, property).find(metadata => metadata.childType)?.childType ?? field?.genericArguments?.[0]; } /** * Resolves the type of a `@nested` single-object property, preferring an explicit * `@field(NestedType)` declaration and falling back to `design:type` decorator metadata * (only available when compiled with `tsc`, not esbuild/tsx). * @param type - The declaring class constructor. * @param property - The property name. * @returns The nested type constructor, or undefined when it cannot be resolved. */ export function resolveNestedType(type: Function, property: string): Function | undefined { const field = Fields.getFieldsForType(type as Constructor).find(candidate => candidate.name === property); if (field?.type && field.type !== Object && field.type !== Array) { return field.type; } const designType = Reflect.getMetadata('design:type', type.prototype, property) as Function | undefined; if (designType && designType !== Object) { return designType; } const inferred = TypeIntrospector.getMembers(type).get(property); return inferred && inferred !== Object && inferred !== Array ? inferred : undefined; } /** * Discovers the child model property used to identify instances, by convention: a property * named `id` (case-insensitive), or a property matching the event key when no id is found. * TypeScript has no `[Key]` decorator equivalent. * @param childType - The child/nested model type, when resolvable. * @param eventKey - The configured event key, when present. * @returns The discovered property name, or undefined when no convention match is found. */ function discoverIdentifiedBy(childType: Function | undefined, eventKey: string | undefined): string | undefined { if (!childType) { return undefined; } const members = [...TypeIntrospector.getMembers(childType).keys()]; return members.find(name => name.toLowerCase() === 'id') ?? (eventKey !== undefined ? members.find(name => name.toLowerCase() === eventKey.toLowerCase()) : undefined); } /** * Populates a children/nested definition from a resolved child/nested type's own class-level * and property-level decorators (`@fromEvent`, `@clearWith`, `@removedWith`, * `@removedWithJoin`, and the property-mapping decorators), recursing into any further * `@childrenFrom`/`@nested` properties the child/nested type itself declares. * @param definition - The children definition being populated. * @param childType - The resolved child/nested type, when available. * @param path - The contract path of this child or nested definition. * @param collectSharedMapping - Collects member mappings into their owning All definition. */ function populateFromType( definition: ChildrenDefinitionLike, childType: Function | undefined, path: string, collectSharedMapping?: (prototype: object, property: string, all: ChildrenDefinitionLike['All'], path: string) => void ): void { if (!childType) { return; } const fromByEventType = new Map(); for (const entry of definition.From) { fromByEventType.set(getEventTypeMapKey(entry.Key), entry); } for (const fromEvent of getFromEventMetadata(childType)) { ensureFromEntry(fromByEventType, fromEvent.eventType); } const removedWithByEventType = new Map(); for (const entry of definition.RemovedWith) { removedWithByEventType.set(getEventTypeMapKey(entry.Key), entry); } for (const clearWith of getClearWithClassMetadata(childType)) { const eventType = toContractEventType(clearWith.eventType); removedWithByEventType.set(getEventTypeMapKey(eventType), { Key: eventType, Value: { Key: '$eventSourceId', ParentKey: '' } }); } for (const removed of getRemovedWithClassMetadata(childType)) { const eventType = toContractEventType(removed.eventType); removedWithByEventType.set(getEventTypeMapKey(eventType), { Key: eventType, Value: { Key: removed.key ?? '$eventSourceId', ParentKey: removed.parentKey ?? '' } }); } const removedWithJoinByEventType = new Map(); for (const entry of definition.RemovedWithJoin) { removedWithJoinByEventType.set(getEventTypeMapKey(entry.Key), entry); } for (const removed of getRemovedWithJoinClassMetadata(childType)) { const eventType = toContractEventType(removed.eventType); removedWithJoinByEventType.set(getEventTypeMapKey(eventType), { Key: eventType, Value: { Key: removed.key ?? '$eventSourceId' } }); } const prototype = childType.prototype; const properties = TypeIntrospector.getTrackedProperties(childType); for (const property of properties) collectSharedMapping?.(prototype, property, definition.All, `${path}.All`); for (const property of properties) { applyPropertyMappings(prototype, property, fromByEventType); // A clear on the member carrying @nested removes that nested object; a clear on // any other member maps the member to null in this child/nested definition. if (!isNested(prototype, property)) { for (const clearWith of getClearWithPropertyMetadata(prototype, property)) { const entry = ensureFromEntry(fromByEventType, clearWith.eventType); entry.Value.Properties[property] = '$null'; } } for (const removed of getRemovedWithPropertyMetadata(prototype, property)) { const eventType = toContractEventType(removed.eventType); removedWithByEventType.set(getEventTypeMapKey(eventType), { Key: eventType, Value: { Key: removed.key ?? '$eventSourceId', ParentKey: removed.parentKey ?? '' } }); } for (const removed of getRemovedWithJoinPropertyMetadata(prototype, property)) { const eventType = toContractEventType(removed.eventType); removedWithJoinByEventType.set(getEventTypeMapKey(eventType), { Key: eventType, Value: { Key: removed.key ?? '$eventSourceId' } }); } const childrenFromList = getChildrenFromMetadata(prototype, property); if (childrenFromList.length > 0) { definition.Children[property] = buildChildrenEntry(childType, property, childrenFromList, collectSharedMapping, `${path}.Children.${property}`); } if (isNested(prototype, property)) { definition.Nested[property] = buildNestedEntry(childType, property, collectSharedMapping, `${path}.Nested.${property}`); } } definition.From = Array.from(fromByEventType.values()); definition.RemovedWith = Array.from(removedWithByEventType.values()); definition.RemovedWithJoin = Array.from(removedWithJoinByEventType.values()); } /** * Builds the `ChildrenDefinition` for a `@childrenFrom` collection property. * @param type - The class declaring the property. * @param property - The property name. * @param metadataList - The `@childrenFrom` metadata entries declared on the property (one per creating event type). * @param collectSharedMapping - Collects member mappings into their owning All definition. * @param path - The contract path of this children definition. * @returns The wire-shaped children definition. */ export function buildChildrenEntry( type: Function, property: string, metadataList: ChildrenFromMetadata[], collectSharedMapping?: (prototype: object, property: string, all: ChildrenDefinitionLike['All'], path: string) => void, path = `Children.${property}` ): ChildrenDefinitionLike { const childType = resolveChildElementType(type, property); const definition = createEmptyChildrenDefinition(type, childType); const explicitIdentifiedBy = metadataList.find(metadata => metadata.identifiedBy)?.identifiedBy; definition.IdentifiedBy = explicitIdentifiedBy ?? discoverIdentifiedBy(childType, metadataList[0]?.key) ?? '$eventSourceId'; for (const metadata of metadataList) { const eventType = toContractEventType(metadata.eventType); definition.From.push({ Key: eventType, Value: { Key: metadata.key ?? '$eventSourceId', ParentKey: metadata.parentKey ?? '$eventSourceId', Properties: {} } }); } populateFromType(definition, childType, path, collectSharedMapping); // For non-aggregate-only creating events, populate an unmapped child identifier from // the creating event's key. IdentifiedBy selects the child property; it is not itself // a property mapping. TypeScript has no [Key] decorator, so an explicit identifiedBy // plays the same role as a discovered id for this default. const identifier = definition.IdentifiedBy; if (definition.AutoMap === AutoMap.Enabled && identifier !== '$eventSourceId') { const prototype = childType?.prototype; const hasExplicitMapping = prototype !== undefined && ( getSetFromMetadata(prototype, identifier).length > 0 || getSetFromContextMetadata(prototype, identifier).length > 0 || getSetValueMetadata(prototype, identifier).length > 0 || getAddFromMetadata(prototype, identifier).length > 0 || getSubtractFromMetadata(prototype, identifier).length > 0); if (!hasExplicitMapping) { for (const metadata of metadataList) { const entry = definition.From.find(candidate => getEventTypeMapKey(candidate.Key) === getEventTypeMapKey(toContractEventType(metadata.eventType)))!; const mappings = Object.values(entry.Value.Properties); // Preserve the kernel's aggregate-only AutoMap exemption: Changeset.AddChild already // initializes the child identifier from the resolved key before applying property mappers. if (mappings.length > 0 && mappings.every(expression => aggregateExpressions.some(aggregate => expression.startsWith(aggregate)))) { continue; } // Child property mappings may replace the creating key (for example, a constant-key count). const key = entry.Value.Key; // When an event property is also the identifier, AutoMap can fill it directly. if (key.toLowerCase() !== identifier.toLowerCase() && !(identifier in entry.Value.Properties)) { entry.Value.Properties[identifier] = key === '$eventSourceId' ? eventContextPropertyExpression('eventSourceId') : key; } } } } return definition; } /** * Builds the `ChildrenDefinition` for a `@nested` single-object property. * @param type - The class declaring the property. * @param property - The property name. * @param collectSharedMapping - Collects member mappings into their owning All definition. * @param path - The contract path of this nested definition. * @returns The wire-shaped nested definition. */ export function buildNestedEntry( type: Function, property: string, collectSharedMapping?: (prototype: object, property: string, all: ChildrenDefinitionLike['All'], path: string) => void, path = `Nested.${property}` ): ChildrenDefinitionLike { const nestedType = resolveNestedType(type, property); const definition = createEmptyChildrenDefinition(type, nestedType); definition.IdentifiedBy = notSetPropertyPath; // A @clearWith on the property carrying @nested clears this nested object, the same as a // class-level @clearWith on the nested type itself (which populateFromType also honors). for (const clearWith of getClearWithPropertyMetadata(type.prototype, property)) { const eventType = toContractEventType(clearWith.eventType); definition.RemovedWith.push({ Key: eventType, Value: { Key: '$eventSourceId', ParentKey: '' } }); } populateFromType(definition, nestedType, path, collectSharedMapping); return definition; }