// Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { AutoMap } from '@cratis/chronicle.contracts'; import { getEventTypeFor } from '../../events/eventTypeDecorator.js'; import { FromBuilder } from './FromBuilder.js'; import { IFromBuilder } from './IFromBuilder.js'; import { IFromEveryBuilder } from './IFromEveryBuilder.js'; import { IJoinBuilder } from './IJoinBuilder.js'; import { IRemovedWithBuilder } from './IRemovedWithBuilder.js'; import { IRemovedWithJoinBuilder } from './IRemovedWithJoinBuilder.js'; import { JoinBuilder } from './JoinBuilder.js'; import { FromEveryBuilder } from './FromEveryBuilder.js'; import { RemovedWithBuilder } from './RemovedWithBuilder.js'; import { RemovedWithJoinBuilder } from './RemovedWithJoinBuilder.js'; import { ChildAdditionEntry } from './AddChildBuilder.js'; import { eventContractPath } from '../eventContractPath.js'; import { getProjectionBuilderProvenance, recordChildDeclaration, recordFromEveryDeclaration, recordKeyDeclaration } from './projectionBuilderProvenance.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 }; } /** Accumulated property mapping for a join clause. */ export interface JoinRecord { Key: ContractEventType; Value: { On: string; Properties: Record; Key: string }; } /** Accumulated removal mapping for a removedWith clause. */ export interface RemovedWithRecord { Key: ContractEventType; Value: { Key: string; ParentKey: string }; } /** Accumulated removal mapping for a removedWithJoin clause. */ export interface RemovedWithJoinRecord { Key: ContractEventType; Value: { Key: string }; } /** * Plain object shape matching the wire-level `ChildrenDefinition` used for both children * collections and nested single-object projections. */ export interface ChildrenDefinitionLike { IdentifiedBy: string; From: FromRecord[]; Join: JoinRecord[]; Children: Record; All: { Properties: Record; IncludeChildren: boolean; AutoMap: AutoMap }; FromEventProperty?: { Event: ContractEventType | undefined; PropertyExpression: string }; RemovedWith: RemovedWithRecord[]; RemovedWithJoin: RemovedWithJoinRecord[]; AutoMap: AutoMap; Nested: Record; } /** * Base accumulator shared by every projection-shaped builder (the top-level * {@link ProjectionBuilderFor}, and the child/nested builders reached through * `.children()`/`.nested()`). Holds the from/join/fromEvery/removedWith/removedWithJoin/ * children/nested state and the mapping operations common to all of them, mirroring the * shared `ProjectionBuilder` base class on the C# client. * @template TReadModel - The read model type this builder produces. * @template TBuilder - The concrete builder type returned for fluent chaining. */ export abstract class ProjectionBuilderCore { protected _autoMap: AutoMap = AutoMap.Inherit; protected _initialState: string = '{}'; protected readonly _from: FromRecord[] = []; protected readonly _join: JoinRecord[] = []; private readonly _joinEventNames = new Map(); protected readonly _removedWith: RemovedWithRecord[] = []; protected readonly _removedWithJoin: RemovedWithJoinRecord[] = []; protected readonly _children: Record = {}; protected readonly _nested: Record = {}; protected _all: { Properties: Record; IncludeChildren: boolean; AutoMap: AutoMap } = { Properties: {}, IncludeChildren: false, AutoMap: AutoMap.Inherit }; /** @inheritdoc */ autoMap(): TBuilder { this._autoMap = AutoMap.Enabled; return this as unknown as TBuilder; } /** @inheritdoc */ noAutoMap(): TBuilder { this._autoMap = AutoMap.Disabled; return this as unknown as TBuilder; } /** @inheritdoc */ withInitialValues(initialValueProvider: () => TReadModel): TBuilder { this._initialState = JSON.stringify(initialValueProvider()); return this as unknown as TBuilder; } /** @inheritdoc */ from( eventType: new (...args: any[]) => TEvent, builderCallback?: (builder: IFromBuilder) => void ): TBuilder { const contractType = this.toContractEventType(eventType); const fromBuilder = new FromBuilder(); builderCallback?.(fromBuilder); this._from.push({ Key: contractType, Value: { Properties: fromBuilder.entry.properties, Key: fromBuilder.entry.key, ParentKey: fromBuilder.entry.parentKey } }); for (const [field, declaration] of getProjectionBuilderProvenance(fromBuilder).keys) { recordKeyDeclaration(this, `${eventContractPath('From', contractType)}.${field}`, declaration); } for (const child of fromBuilder.entry.children) recordChildDeclaration(this, `Children.${child.targetProperty}`, '.from().addChild'); this.mergeChildAdditions(contractType, fromBuilder.entry.children); return this as unknown as TBuilder; } /** @inheritdoc */ join( eventType: new (...args: any[]) => TEvent, builderCallback?: (builder: IJoinBuilder) => void ): TBuilder { const contractType = this.toContractEventType(eventType); const joinBuilder = new JoinBuilder(); builderCallback?.(joinBuilder); const record: JoinRecord = { Key: contractType, Value: { On: joinBuilder.entry.on, Properties: joinBuilder.entry.properties, Key: joinBuilder.entry.key } }; this._join.push(record); this._joinEventNames.set(record, eventType.name); this.mergeChildAdditions(contractType, joinBuilder.entry.children); return this as unknown as TBuilder; } /** @inheritdoc */ fromEvery(builderCallback: (builder: IFromEveryBuilder) => void): TBuilder { recordFromEveryDeclaration(this); const builder = new FromEveryBuilder(); builderCallback(builder); this._all = { Properties: { ...this._all.Properties, ...builder.entry.properties }, IncludeChildren: builder.entry.includeChildren, AutoMap: AutoMap.Inherit }; return this as unknown as TBuilder; } /** @inheritdoc */ removedWith( eventType: new (...args: any[]) => TEvent, builderCallback?: (builder: IRemovedWithBuilder) => void ): TBuilder { const contractType = this.toContractEventType(eventType); const removedWithBuilder = new RemovedWithBuilder(); builderCallback?.(removedWithBuilder); this._removedWith.push({ Key: contractType, Value: { Key: removedWithBuilder.entry.key, ParentKey: removedWithBuilder.entry.parentKey } }); return this as unknown as TBuilder; } /** @inheritdoc */ removedWithJoin( eventType: new (...args: any[]) => TEvent, builderCallback?: (builder: IRemovedWithJoinBuilder) => void ): TBuilder { const contractType = this.toContractEventType(eventType); const removedWithJoinBuilder = new RemovedWithJoinBuilder(); builderCallback?.(removedWithJoinBuilder); this._removedWithJoin.push({ Key: contractType, Value: { Key: removedWithJoinBuilder.entry.key } }); return this as unknown as TBuilder; } /** Resolves join defaults once the entire projection (including identifiedBy) is configured. */ protected resolveJoins(): void { for (const record of this._join) { record.Value.On ||= this.defaultJoinOn() ?? ''; if (!record.Value.On) { throw new Error(`A join with event '${this._joinEventNames.get(record)}' requires an on property.`); } } } /** Returns a default join-on path for children, if one is available. */ protected defaultJoinOn(): string | undefined { return undefined; } /** * Creates an empty {@link ChildrenDefinitionLike} accumulator for a children/nested entry. * @returns A fresh, empty children definition. */ protected createEmptyChildrenDefinition(): ChildrenDefinitionLike { return { IdentifiedBy: '$eventSourceId', From: [], Join: [], Children: {}, All: { Properties: {}, IncludeChildren: false, AutoMap: AutoMap.Inherit }, RemovedWith: [], RemovedWithJoin: [], AutoMap: AutoMap.Inherit, Nested: {} }; } /** * Merges `.addChild()` accumulations captured on a from/join builder into this builder's * children definitions, creating the target child entry on first use. * @param eventType - The contract event type the child additions were captured for. * @param children - The accumulated child additions. */ private mergeChildAdditions(eventType: ContractEventType, children: ChildAdditionEntry[]): void { for (const child of children) { const childDefinition = this._children[child.targetProperty] ??= this.createEmptyChildrenDefinition(); if (child.identifiedBy) { childDefinition.IdentifiedBy = child.identifiedBy; } if (child.fromEventProperty) { childDefinition.FromEventProperty = { Event: eventType, PropertyExpression: child.fromEventProperty }; } childDefinition.From.push({ Key: eventType, Value: { Key: child.usingKey ?? '$eventSourceId', ParentKey: child.usingParentKey ?? '$eventSourceId', Properties: {} } }); } } /** * Resolves the contract event type for a decorated event constructor. * @param eventTypeConstructor - The event class constructor. * @returns The contract-level event type identifier. */ protected 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 }; } }