diff --git a/Documentation/index.md b/Documentation/index.md index 2fda7ad..6c485de 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -8,7 +8,7 @@ Welcome to the Chronicle TypeScript client documentation. - Appending events to event sequences - Managing event stores and namespaces -- Defining reactors (side-effect handlers) and reducers (state-folders) using TypeScript decorators +- Defining reactors, reducers, projections, and constraints using TypeScript decorators ## Guides diff --git a/README.md b/README.md index 468d9a6..76a0911 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A TypeScript-idiomatic client for [Cratis Chronicle](https://github.com/Cratis/C `@cratis/chronicle` provides a clean, type-safe TypeScript API for interacting with the Chronicle Kernel. It builds on top of [`@cratis/chronicle.contracts`](https://www.npmjs.com/package/@cratis/chronicle.contracts) (the gRPC contracts package) and exposes idiomatic TypeScript constructs including: -- **Decorators** — `@eventType`, `@reactor`, `@reducer` mirror the C# attribute-based API +- **Decorators** — `@eventType`, `@readModel`, `@reactor`, `@reducer`, `@constraint`, `@projection`, `@modelBoundProjection` - **Value objects** — `EventSequenceNumber`, `EventTypeId`, `EventStoreName`, etc. - **Fluent client** — `ChronicleClient` → `EventStore` → `EventLog` → `append()` diff --git a/Source/EventSequences/AppendError.ts b/Source/EventSequences/AppendError.ts new file mode 100644 index 0000000..6ff37f4 --- /dev/null +++ b/Source/EventSequences/AppendError.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents an error that occurred while appending an event. + */ +export interface AppendError { + /** The error message describing the failure. */ + readonly message: string; +} diff --git a/Source/EventSequences/AppendOptions.ts b/Source/EventSequences/AppendOptions.ts new file mode 100644 index 0000000..fdaf126 --- /dev/null +++ b/Source/EventSequences/AppendOptions.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Guid } from '@cratis/fundamentals'; + +/** + * Options for appending an event to an event sequence. + */ +export interface AppendOptions { + /** Optional correlation identifier for tracking the append operation. */ + correlationId?: string | Guid; + + /** Optional explicit sequence number to use for the event. */ + eventSourceId?: string; +} diff --git a/Source/EventSequences/AppendResult.ts b/Source/EventSequences/AppendResult.ts index e3a9f73..b71c439 100644 --- a/Source/EventSequences/AppendResult.ts +++ b/Source/EventSequences/AppendResult.ts @@ -1,30 +1,10 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { AppendError } from './AppendError'; +import { ConstraintViolation } from './ConstraintViolation'; import { EventSequenceNumber } from './EventSequenceNumber'; -/** - * Represents an error that occurred while appending an event. - */ -export interface AppendError { - /** The error message describing the failure. */ - readonly message: string; -} - -/** - * Represents a constraint violation that occurred while appending an event. - */ -export interface ConstraintViolation { - /** The constraint identifier that was violated. */ - readonly constraintId: string; - - /** The violation message. */ - readonly message: string; - - /** Additional details about the violation. */ - readonly details: Readonly>; -} - /** * Represents the result of appending a single event to an event sequence. */ diff --git a/Source/EventSequences/ConstraintViolation.ts b/Source/EventSequences/ConstraintViolation.ts new file mode 100644 index 0000000..df4b013 --- /dev/null +++ b/Source/EventSequences/ConstraintViolation.ts @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents a constraint violation that occurred while appending an event. + */ +export interface ConstraintViolation { + /** The constraint identifier that was violated. */ + readonly constraintId: string; + + /** The violation message. */ + readonly message: string; + + /** Additional details about the violation. */ + readonly details: Readonly>; +} diff --git a/Source/EventSequences/EventSequence.ts b/Source/EventSequences/EventSequence.ts index 865a2fe..50f9020 100644 --- a/Source/EventSequences/EventSequence.ts +++ b/Source/EventSequences/EventSequence.ts @@ -6,8 +6,10 @@ import { SpanStatusCode } from '@opentelemetry/api'; import { Guid } from '@cratis/fundamentals'; import { getEventTypeFor } from '../Events/eventTypeDecorator'; import { Grpc } from '../Grpc'; -import { AppendOptions, IEventSequence } from './IEventSequence'; -import { AppendResult, ConstraintViolation } from './AppendResult'; +import { AppendOptions } from './AppendOptions'; +import { AppendResult } from './AppendResult'; +import { ConstraintViolation } from './ConstraintViolation'; +import { IEventSequence } from './IEventSequence'; import { EventSequenceId } from './EventSequenceId'; import { EventSequenceNumber } from './EventSequenceNumber'; import { ChronicleTracer } from '../Tracing'; diff --git a/Source/EventSequences/IEventSequence.ts b/Source/EventSequences/IEventSequence.ts index 7f47bd6..3266967 100644 --- a/Source/EventSequences/IEventSequence.ts +++ b/Source/EventSequences/IEventSequence.ts @@ -1,21 +1,10 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { AppendOptions } from './AppendOptions'; import { AppendResult } from './AppendResult'; import { EventSequenceId } from './EventSequenceId'; import { EventSequenceNumber } from './EventSequenceNumber'; -import { Guid } from '@cratis/fundamentals'; - -/** - * Options for appending an event to an event sequence. - */ -export interface AppendOptions { - /** Optional correlation identifier for tracking the append operation. */ - correlationId?: string | Guid; - - /** Optional explicit sequence number to use for the event. */ - eventSourceId?: string; -} /** * Defines the API surface for an event sequence. diff --git a/Source/EventSequences/index.ts b/Source/EventSequences/index.ts index 274de9f..5713280 100644 --- a/Source/EventSequences/index.ts +++ b/Source/EventSequences/index.ts @@ -3,8 +3,11 @@ export { EventSequenceId } from './EventSequenceId'; export { EventSequenceNumber } from './EventSequenceNumber'; -export type { AppendResult, AppendError, ConstraintViolation } from './AppendResult'; -export type { IEventSequence, AppendOptions } from './IEventSequence'; +export type { AppendError } from './AppendError'; +export type { ConstraintViolation } from './ConstraintViolation'; +export type { AppendResult } from './AppendResult'; +export type { AppendOptions } from './AppendOptions'; +export type { IEventSequence } from './IEventSequence'; export type { IEventLog } from './IEventLog'; export { EventSequence } from './EventSequence'; export { EventLog } from './EventLog'; diff --git a/Source/EventStore.ts b/Source/EventStore.ts index 00c6588..21844b1 100644 --- a/Source/EventStore.ts +++ b/Source/EventStore.ts @@ -12,7 +12,17 @@ import { Grpc } from './Grpc'; import { EventStoreName } from './EventStoreName'; import { EventStoreNamespaceName } from './EventStoreNamespaceName'; import { IEventStore } from './IEventStore'; -import { ChronicleTracer } from './Tracing'; +import { EventTypes } from './Events/EventTypes'; +import { IEventTypes } from './Events/IEventTypes'; +import { Constraints } from './Events/Constraints/Constraints'; +import { IConstraints } from './Events/Constraints/IConstraints'; +import { Projections } from './Projections/Projections'; +import { IProjections } from './Projections/IProjections'; +import { Reactors } from './Reactors/Reactors'; +import { IReactors } from './Reactors/IReactors'; +import { Reducers } from './Reducers/Reducers'; +import { IReducers } from './Reducers/IReducers'; +import { DefaultClientArtifactsProvider } from './artifacts/DefaultClientArtifactsProvider'; /** * Implements {@link IEventStore} by communicating with the Chronicle Kernel @@ -20,6 +30,12 @@ import { ChronicleTracer } from './Tracing'; */ export class EventStore implements IEventStore { readonly eventLog: IEventLog; + readonly eventTypes: IEventTypes; + readonly constraints: IConstraints; + readonly projections: IProjections; + readonly reactors: IReactors; + readonly reducers: IReducers; + private readonly _sequences: Map = new Map(); constructor( @@ -29,6 +45,28 @@ export class EventStore implements IEventStore { ) { this.eventLog = new EventLog(name.value, namespace.value, _connection); this._sequences.set(EventSequenceId.eventLog.value, this.eventLog); + + const artifacts = DefaultClientArtifactsProvider.default; + this.eventTypes = new EventTypes(name.value, _connection, artifacts); + this.constraints = new Constraints(name.value, _connection, artifacts); + this.projections = new Projections(artifacts); + this.reactors = new Reactors(artifacts); + this.reducers = new Reducers(artifacts); + } + + /** + * Registers all discovered artifacts with the Chronicle Kernel. + * Called on initial connect and on reconnect. + * @returns A promise that resolves when all registrations are complete. + */ + async registerArtifacts(): Promise { + await Promise.all([ + this.eventTypes.register(), + this.constraints.register(), + this.projections.register(), + this.reactors.register(), + this.reducers.register() + ]); } /** @inheritdoc */ diff --git a/Source/Events/CausationEntry.ts b/Source/Events/CausationEntry.ts new file mode 100644 index 0000000..5228330 --- /dev/null +++ b/Source/Events/CausationEntry.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents a single entry in the causation chain of an event. + */ +export interface CausationEntry { + /** The type identifier of the causing operation. */ + readonly type: string; + + /** The properties associated with the causation entry. */ + readonly properties: Readonly>; +} diff --git a/Source/Events/Constraints/ConstraintBuilder.ts b/Source/Events/Constraints/ConstraintBuilder.ts new file mode 100644 index 0000000..50aa421 --- /dev/null +++ b/Source/Events/Constraints/ConstraintBuilder.ts @@ -0,0 +1,86 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { getEventTypeFor } from '../eventTypeDecorator'; +import { IConstraintBuilder } from './IConstraintBuilder'; +import { IUniqueConstraintBuilder } from './IUniqueConstraintBuilder'; +import { UniqueConstraintBuilder, UniqueConstraintCapture } from './UniqueConstraintBuilder'; + +/** Represents the captured scope for a constraint. */ +export interface ConstraintScopeCapture { + perEventSourceType: boolean; + perEventStreamType: boolean; + perEventStreamId: boolean; +} + +/** Represents the captured definition of a unique event type constraint. */ +export interface UniqueEventTypeCapture { + eventTypeId: string; + message?: string; + name?: string; +} + +/** Represents the full captured definition of a constraint. */ +export interface ConstraintCapture { + name: string; + scope: ConstraintScopeCapture; + uniqueConstraint?: UniqueConstraintCapture; + uniqueEventType?: UniqueEventTypeCapture; +} + +/** + * Implements {@link IConstraintBuilder}, capturing the constraint definition + * for later serialization and registration with the Kernel. + */ +export class ConstraintBuilder implements IConstraintBuilder { + readonly capture: ConstraintCapture; + private _uniqueBuilder?: UniqueConstraintBuilder; + + constructor(name: string) { + this.capture = { + name, + scope: { perEventSourceType: false, perEventStreamType: false, perEventStreamId: false } + }; + } + + /** @inheritdoc */ + perEventSourceType(): IConstraintBuilder { + this.capture.scope.perEventSourceType = true; + return this; + } + + /** @inheritdoc */ + perEventStreamType(): IConstraintBuilder { + this.capture.scope.perEventStreamType = true; + return this; + } + + /** @inheritdoc */ + perEventStreamId(): IConstraintBuilder { + this.capture.scope.perEventStreamId = true; + return this; + } + + /** @inheritdoc */ + unique(callback: (builder: IUniqueConstraintBuilder) => void): IConstraintBuilder { + const uniqueCapture: UniqueConstraintCapture = { + eventDefinitions: [], + ignoreCasing: false + }; + this.capture.uniqueConstraint = uniqueCapture; + this._uniqueBuilder = new UniqueConstraintBuilder(uniqueCapture); + callback(this._uniqueBuilder); + return this; + } + + /** @inheritdoc */ + uniqueFor(eventType: Function, message?: string, name?: string): IConstraintBuilder { + const et = getEventTypeFor(eventType); + this.capture.uniqueEventType = { + eventTypeId: et.id.value, + message, + name + }; + return this; + } +} diff --git a/Source/Events/Constraints/ConstraintId.ts b/Source/Events/Constraints/ConstraintId.ts new file mode 100644 index 0000000..86f38ee --- /dev/null +++ b/Source/Events/Constraints/ConstraintId.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Unique identifier for a constraint. + */ +export class ConstraintId { + constructor(readonly value: string) {} + + /** @inheritdoc */ + toString(): string { + return this.value; + } +} diff --git a/Source/Events/Constraints/Constraints.ts b/Source/Events/Constraints/Constraints.ts new file mode 100644 index 0000000..b67e70f --- /dev/null +++ b/Source/Events/Constraints/Constraints.ts @@ -0,0 +1,122 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ChronicleConnection, ConstraintType } from '@cratis/chronicle.contracts'; +import { IClientArtifactsProvider } from '../../artifacts'; +import { Grpc } from '../../Grpc'; +import { ConstraintId } from './ConstraintId'; +import { IConstraint } from './IConstraint'; +import { IConstraints } from './IConstraints'; +import { ConstraintBuilder, ConstraintCapture } from './ConstraintBuilder'; +import { getConstraintMetadata } from './constraint'; + +/** + * Manages discovery and registration of constraints with the Chronicle Kernel. + */ +export class Constraints implements IConstraints { + private readonly _captures = new Map(); + + /** + * Creates a new {@link Constraints} instance. + * @param _eventStore - The name of the event store these constraints belong to. + * @param _connection - The connection used to communicate with the Kernel. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor( + private readonly _eventStore: string, + private readonly _connection: ChronicleConnection, + private readonly _clientArtifacts: IClientArtifactsProvider + ) {} + + /** @inheritdoc */ + async discover(): Promise { + this._captures.clear(); + for (const type of this._clientArtifacts.constraints) { + const metadata = getConstraintMetadata(type); + if (!metadata) continue; + + const builder = new ConstraintBuilder(metadata.id.value); + const instance = new (type as new () => IConstraint)(); + instance.define(builder); + this._captures.set(metadata.id.value, builder.capture); + } + } + + /** @inheritdoc */ + async register(): Promise { + if (this._captures.size === 0) { + await this.discover(); + } + + const constraints = [...this._captures.values()].map(capture => { + const scope = { + EventSourceType: capture.scope.perEventSourceType ? '*' : '', + EventStreamType: capture.scope.perEventStreamType ? '*' : '', + EventStreamId: capture.scope.perEventStreamId ? '*' : '' + }; + + if (capture.uniqueConstraint) { + const uc = capture.uniqueConstraint; + return { + Name: capture.name, + Type: ConstraintType.Unique, + RemovedWith: uc.removedWithEventTypeId ?? '', + Definition: { + Value0: { + EventDefinitions: uc.eventDefinitions.map(ed => ({ + EventTypeId: ed.eventTypeId, + Properties: ed.properties + })), + IgnoreCasing: uc.ignoreCasing + }, + Value1: undefined + }, + Scope: scope + }; + } + + if (capture.uniqueEventType) { + const uet = capture.uniqueEventType; + return { + Name: uet.name ?? capture.name, + Type: ConstraintType.UniqueEventType, + RemovedWith: '', + Definition: { + Value0: undefined, + Value1: { + EventTypeId: uet.eventTypeId + } + }, + Scope: scope + }; + } + + return { + Name: capture.name, + Type: ConstraintType.Unknown, + RemovedWith: '', + Definition: undefined, + Scope: scope + }; + }); + + if (constraints.length === 0) { + return; + } + + await Grpc.call(callback => + this._connection.constraints.register( + { + EventStore: this._eventStore, + Constraints: constraints + }, + callback + ) + ); + } + + /** @inheritdoc */ + hasFor(id: ConstraintId): boolean { + return this._captures.has(id.value); + } +} diff --git a/Source/Events/Constraints/IConstraint.ts b/Source/Events/Constraints/IConstraint.ts new file mode 100644 index 0000000..0732f12 --- /dev/null +++ b/Source/Events/Constraints/IConstraint.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { IConstraintBuilder } from './IConstraintBuilder'; + +/** + * Defines a constraint for events. + * Implement this interface on any class decorated with {@link constraint} to describe + * the constraint rules that the Chronicle server should enforce. + * Matches the C# IConstraint contract. + */ +export interface IConstraint { + /** + * Defines the constraint rules. + * @param builder - The {@link IConstraintBuilder} used to configure constraint rules. + */ + define(builder: IConstraintBuilder): void; +} diff --git a/Source/Events/Constraints/IConstraintBuilder.ts b/Source/Events/Constraints/IConstraintBuilder.ts new file mode 100644 index 0000000..a58a66f --- /dev/null +++ b/Source/Events/Constraints/IConstraintBuilder.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { IUniqueConstraintBuilder } from './IUniqueConstraintBuilder'; + +/** + * Defines the builder for building constraints. + * Matches the C# IConstraintBuilder contract. + */ +export interface IConstraintBuilder { + /** + * Scopes the constraint per event source type. + * @returns This builder for fluent chaining. + */ + perEventSourceType(): IConstraintBuilder; + + /** + * Scopes the constraint per event stream type. + * @returns This builder for fluent chaining. + */ + perEventStreamType(): IConstraintBuilder; + + /** + * Scopes the constraint per event stream identifier. + * @returns This builder for fluent chaining. + */ + perEventStreamId(): IConstraintBuilder; + + /** + * Starts building a unique constraint using a fluent builder callback. + * @param callback - Callback that configures the unique constraint via {@link IUniqueConstraintBuilder}. + * @returns This builder for fluent chaining. + */ + unique(callback: (builder: IUniqueConstraintBuilder) => void): IConstraintBuilder; + + /** + * Adds a unique constraint for a specific event type. + * This means there can only be one instance of this event type per event source identifier. + * @param message - Optional violation message. + * @param name - Optional constraint name. + * @returns This builder for fluent chaining. + */ + uniqueFor(eventType: Function, message?: string, name?: string): IConstraintBuilder; +} diff --git a/Source/Events/Constraints/IConstraints.ts b/Source/Events/Constraints/IConstraints.ts new file mode 100644 index 0000000..02ca0a9 --- /dev/null +++ b/Source/Events/Constraints/IConstraints.ts @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ConstraintId } from './ConstraintId'; + +/** + * Defines a system to work with constraints, including discovery and registration with the Kernel. + */ +export interface IConstraints { + /** + * Discovers all constraints from the registered client artifacts. + * @returns A promise that resolves when discovery is complete. + */ + discover(): Promise; + + /** + * Registers all discovered constraints with the Chronicle Kernel. + * @returns A promise that resolves when registration is complete. + */ + register(): Promise; + + /** + * Checks whether a constraint exists for the given identifier. + * @param id - The constraint identifier to look up. + * @returns True if a matching constraint exists; otherwise false. + */ + hasFor(id: ConstraintId): boolean; +} diff --git a/Source/Events/Constraints/IUniqueConstraintBuilder.ts b/Source/Events/Constraints/IUniqueConstraintBuilder.ts new file mode 100644 index 0000000..8f42656 --- /dev/null +++ b/Source/Events/Constraints/IUniqueConstraintBuilder.ts @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines the builder for building unique constraints. + * Matches the C# IUniqueConstraintBuilder contract. + */ +export interface IUniqueConstraintBuilder { + /** + * Defines the name of the unique constraint. + * The name is optional and if not provided, it will use the type name the constraint belongs to. + * @param name - Name to use. + * @returns This builder for fluent chaining. + */ + withName(name: string): IUniqueConstraintBuilder; + + /** + * Constrains on specific properties of an event type. + * @param properties - Property accessor expressions for specifying the properties on the event. + * @returns This builder for fluent chaining. + */ + on(...properties: PropertyAccessor[]): IUniqueConstraintBuilder; + + /** + * Ignores casing when comparing property values during constraint evaluation. + * @returns This builder for fluent chaining. + */ + ignoreCasing(): IUniqueConstraintBuilder; + + /** + * Specifies the event type that removes this unique constraint (e.g. a deletion event). + * @param eventType - The event constructor that removes the constraint. + * @returns This builder for fluent chaining. + */ + removedWith(eventType: Function): IUniqueConstraintBuilder; + + /** + * Specifies a static message to use when the unique constraint is violated. + * @param message - The violation message. + * @returns This builder for fluent chaining. + */ + withMessage(message: string): IUniqueConstraintBuilder; + + /** + * Specifies a provider function that produces the violation message dynamically. + * @param messageProvider - Callback that returns the violation message. + * @returns This builder for fluent chaining. + */ + withMessageFrom(messageProvider: () => string): IUniqueConstraintBuilder; +} diff --git a/Source/Events/Constraints/UniqueConstraintBuilder.ts b/Source/Events/Constraints/UniqueConstraintBuilder.ts new file mode 100644 index 0000000..631ad7f --- /dev/null +++ b/Source/Events/Constraints/UniqueConstraintBuilder.ts @@ -0,0 +1,97 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor, PropertyPathResolverProxyHandler } from '@cratis/fundamentals'; +import { getEventTypeFor } from '../eventTypeDecorator'; +import { IUniqueConstraintBuilder } from './IUniqueConstraintBuilder'; + +/** Resolves a property path string from a {@link PropertyAccessor}. */ +function resolvePropertyPath(accessor: PropertyAccessor): string { + const handler = new PropertyPathResolverProxyHandler(); + const proxy = new Proxy({}, handler); + accessor(proxy as T); + return handler.path; +} + +/** Captured definition of a unique constraint event entry. */ +export interface UniqueConstraintEventEntry { + eventTypeId: string; + properties: string[]; +} + +/** Captured definition of a unique constraint. */ +export interface UniqueConstraintCapture { + name?: string; + eventDefinitions: UniqueConstraintEventEntry[]; + ignoreCasing: boolean; + removedWithEventTypeId?: string; + message?: string; +} + +/** + * Implements {@link IUniqueConstraintBuilder}, capturing the unique constraint definition + * for later serialization and registration with the Kernel. + */ +export class UniqueConstraintBuilder implements IUniqueConstraintBuilder { + private readonly _capture: UniqueConstraintCapture; + private _currentEventTypeId?: string; + + constructor(capture: UniqueConstraintCapture) { + this._capture = capture; + } + + /** @inheritdoc */ + withName(name: string): IUniqueConstraintBuilder { + this._capture.name = name; + return this; + } + + /** @inheritdoc */ + on(...properties: PropertyAccessor[]): IUniqueConstraintBuilder { + if (!this._currentEventTypeId) { + return this; + } + const paths = properties.map(p => resolvePropertyPath(p)); + const existing = this._capture.eventDefinitions.find(d => d.eventTypeId === this._currentEventTypeId); + if (existing) { + existing.properties.push(...paths); + } else { + this._capture.eventDefinitions.push({ eventTypeId: this._currentEventTypeId, properties: paths }); + } + return this; + } + + /** @inheritdoc */ + ignoreCasing(): IUniqueConstraintBuilder { + this._capture.ignoreCasing = true; + return this; + } + + /** @inheritdoc */ + removedWith(eventType: Function): IUniqueConstraintBuilder { + const et = getEventTypeFor(eventType); + this._capture.removedWithEventTypeId = et.id.value; + return this; + } + + /** @inheritdoc */ + withMessage(message: string): IUniqueConstraintBuilder { + this._capture.message = message; + return this; + } + + /** @inheritdoc */ + withMessageFrom(messageProvider: () => string): IUniqueConstraintBuilder { + this._capture.message = messageProvider(); + return this; + } + + /** + * Sets the current event type context for subsequent {@link on} calls. + * @param eventTypeId - The event type identifier string. + */ + withEventType(eventTypeId: string): UniqueConstraintBuilder { + this._currentEventTypeId = eventTypeId; + return this; + } +} diff --git a/Source/Events/Constraints/constraint.ts b/Source/Events/Constraints/constraint.ts new file mode 100644 index 0000000..b767d5a --- /dev/null +++ b/Source/Events/Constraints/constraint.ts @@ -0,0 +1,56 @@ +// 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 { Constructor } from '@cratis/fundamentals'; +import { ConstraintId } from './ConstraintId'; +import { DecoratorType, TypeDiscoverer } from '../../types'; + +/** Metadata key used to store constraint information on a class. */ +const CONSTRAINT_METADATA_KEY = 'chronicle:constraint'; + +/** + * Metadata stored on a constraint class. + */ +export interface ConstraintMetadata { + /** The unique identifier for the constraint. */ + readonly id: ConstraintId; +} + +/** + * TypeScript decorator that marks a class as an event constraint. + * The decorated class may implement {@link IConstraint} to define the constraint rules. + * @param id - The unique identifier for the constraint. Defaults to the class name if omitted. + * @returns A class decorator. + */ +export function constraint(id: string = ''): ClassDecorator { + return (target: object) => { + const constructor = target as Function; + const constraintId = new ConstraintId(id || constructor.name); + const metadata: ConstraintMetadata = { id: constraintId }; + Reflect.defineMetadata(CONSTRAINT_METADATA_KEY, metadata, target); + TypeDiscoverer.default.register( + DecoratorType.Constraint, + constructor as Constructor, + constraintId.value + ); + }; +} + +/** + * Gets the {@link ConstraintMetadata} associated with a class decorated with {@link constraint}. + * @param target - The class constructor to retrieve metadata for. + * @returns The associated metadata, or undefined if not decorated. + */ +export function getConstraintMetadata(target: Function): ConstraintMetadata | undefined { + return Reflect.getMetadata(CONSTRAINT_METADATA_KEY, target); +} + +/** + * Checks whether a class has been decorated with {@link constraint}. + * @param target - The class constructor to check. + * @returns True if the class has a constraint decorator; false otherwise. + */ +export function isConstraint(target: Function): boolean { + return Reflect.hasMetadata(CONSTRAINT_METADATA_KEY, target); +} diff --git a/Source/Events/Constraints/index.ts b/Source/Events/Constraints/index.ts new file mode 100644 index 0000000..e8e3689 --- /dev/null +++ b/Source/Events/Constraints/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { ConstraintId } from './ConstraintId'; +export type { IConstraint } from './IConstraint'; +export type { IConstraintBuilder } from './IConstraintBuilder'; +export type { IUniqueConstraintBuilder } from './IUniqueConstraintBuilder'; +export { constraint, getConstraintMetadata, isConstraint } from './constraint'; +export type { ConstraintMetadata } from './constraint'; +export type { IConstraints } from './IConstraints'; +export { Constraints } from './Constraints'; +export { ConstraintBuilder } from './ConstraintBuilder'; +export type { ConstraintCapture, ConstraintScopeCapture, UniqueEventTypeCapture } from './ConstraintBuilder'; +export { UniqueConstraintBuilder } from './UniqueConstraintBuilder'; +export type { UniqueConstraintCapture, UniqueConstraintEventEntry } from './UniqueConstraintBuilder'; diff --git a/Source/Events/EventContext.ts b/Source/Events/EventContext.ts index 9652872..c33746c 100644 --- a/Source/Events/EventContext.ts +++ b/Source/Events/EventContext.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { EventType } from './EventType'; +import { CausationEntry } from './CausationEntry'; /** * Represents contextual information about an appended event. @@ -25,14 +26,3 @@ export interface EventContext { /** The causation chain for the event. */ readonly causation: ReadonlyArray; } - -/** - * Represents a single entry in the causation chain of an event. - */ -export interface CausationEntry { - /** The type identifier of the causing operation. */ - readonly type: string; - - /** The properties associated with the causation entry. */ - readonly properties: Readonly>; -} diff --git a/Source/Events/EventTypes.ts b/Source/Events/EventTypes.ts new file mode 100644 index 0000000..97014ab --- /dev/null +++ b/Source/Events/EventTypes.ts @@ -0,0 +1,101 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Constructor } from '@cratis/fundamentals'; +import { ChronicleConnection } from '@cratis/chronicle.contracts'; +import { IClientArtifactsProvider } from '../artifacts'; +import { Grpc } from '../Grpc'; +import { EventTypeId } from './EventTypeId'; +import { IEventTypes } from './IEventTypes'; +import { getEventTypeMetadata, getEventTypeJsonSchemaFor } from './eventTypeDecorator'; + +/** + * Implements {@link IEventTypes}, managing discovery and registration of event types + * with the Chronicle Kernel. + */ +export class EventTypes implements IEventTypes { + private readonly _types = new Map(); + + /** + * Creates a new {@link EventTypes} instance. + * @param _eventStore - The name of the event store these types belong to. + * @param _connection - The connection used to communicate with the Kernel. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor( + private readonly _eventStore: string, + private readonly _connection: ChronicleConnection, + private readonly _clientArtifacts: IClientArtifactsProvider + ) {} + + /** @inheritdoc */ + get all(): Constructor[] { + return [...this._types.values()]; + } + + /** @inheritdoc */ + async discover(): Promise { + this._types.clear(); + for (const type of this._clientArtifacts.eventTypes) { + const metadata = getEventTypeMetadata(type); + if (metadata) { + this._types.set(metadata.eventType.id.value, type); + } + } + } + + /** @inheritdoc */ + async register(): Promise { + if (this._types.size === 0) { + await this.discover(); + } + + const registrations = [...this._types.entries()].map(([, type]) => { + const metadata = getEventTypeMetadata(type)!; + const schema = getEventTypeJsonSchemaFor(type); + return { + Type: { + Id: metadata.eventType.id.value, + Generation: metadata.eventType.generation.value, + Tombstone: false + }, + Schema: JSON.stringify(schema), + Generations: [{ + Generation: metadata.eventType.generation.value, + Schema: JSON.stringify(schema) + }], + Migrations: [], + EventStore: this._eventStore + }; + }); + + if (registrations.length === 0) { + return; + } + + await Grpc.call(callback => + this._connection.eventTypes.register( + { + EventStore: this._eventStore, + Types: registrations, + DisableValidation: false + }, + callback + ) + ); + } + + /** @inheritdoc */ + hasFor(id: EventTypeId): boolean { + return this._types.has(id.value); + } + + /** @inheritdoc */ + getTypeFor(id: EventTypeId): Constructor { + const type = this._types.get(id.value); + if (!type) { + throw new Error(`No event type registered for id '${id.value}'.`); + } + return type; + } +} diff --git a/Source/Events/IEventTypes.ts b/Source/Events/IEventTypes.ts new file mode 100644 index 0000000..6607226 --- /dev/null +++ b/Source/Events/IEventTypes.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Constructor } from '@cratis/fundamentals'; +import { EventTypeId } from './EventTypeId'; + +/** + * Defines a system to work with event types, including discovery and registration with the Kernel. + */ +export interface IEventTypes { + /** Gets all registered event type constructors. */ + readonly all: Constructor[]; + + /** + * Discovers all event types from the registered client artifacts. + * @returns A promise that resolves when discovery is complete. + */ + discover(): Promise; + + /** + * Registers all discovered event types with the Chronicle Kernel. + * @returns A promise that resolves when registration is complete. + */ + register(): Promise; + + /** + * Checks whether an event type constructor exists for the given identifier. + * @param id - The event type identifier to look up. + * @returns True if a matching type exists; otherwise false. + */ + hasFor(id: EventTypeId): boolean; + + /** + * Gets the event type constructor for the given identifier. + * @param id - The event type identifier to look up. + * @returns The constructor for the event type. + */ + getTypeFor(id: EventTypeId): Constructor; +} diff --git a/Source/Events/eventTypeDecorator.ts b/Source/Events/eventTypeDecorator.ts index 9b3fa8e..ea054b3 100644 --- a/Source/Events/eventTypeDecorator.ts +++ b/Source/Events/eventTypeDecorator.ts @@ -6,11 +6,26 @@ import { Constructor } from '@cratis/fundamentals'; import { EventType } from './EventType'; import { EventTypeId } from './EventTypeId'; import { EventTypeGeneration } from './EventTypeGeneration'; -import { DecoratorType, TypeDiscoverer } from '../types'; +import { DecoratorType, TypeDiscoverer, TypeIntrospector } from '../types'; +import { JsonSchema, JsonSchemaGenerator } from '../Schemas'; /** Metadata key used to store event type information on a class. */ const EVENT_TYPE_METADATA_KEY = 'chronicle:eventType'; +/** + * Metadata stored for an event type class. + */ +export interface EventTypeMetadata { + /** The event type descriptor for the event class. */ + readonly eventType: EventType; + + /** The reflected members and their runtime types. */ + readonly members: ReadonlyMap; + + /** The generated schema for the event class. */ + readonly schema: JsonSchema; +} + /** * TypeScript decorator that marks a class as an event type and associates it with a specific * {@link EventType} identifier and generation. This is the TypeScript equivalent of the @@ -33,7 +48,13 @@ export function eventType(id: string = '', generation: number = EventTypeGenerat const constructor = target as Function; const eventTypeId = new EventTypeId(id || constructor.name); const eventTypeInstance = new EventType(eventTypeId, new EventTypeGeneration(generation)); - Reflect.defineMetadata(EVENT_TYPE_METADATA_KEY, eventTypeInstance, target); + const members = TypeIntrospector.getMembers(constructor); + const metadata: EventTypeMetadata = { + eventType: eventTypeInstance, + members, + schema: JsonSchemaGenerator.generate(constructor, members) + }; + Reflect.defineMetadata(EVENT_TYPE_METADATA_KEY, metadata, target); TypeDiscoverer.default.register( DecoratorType.EventType, constructor as Constructor, @@ -48,7 +69,7 @@ export function eventType(id: string = '', generation: number = EventTypeGenerat * @returns The associated EventType, or EventType.unknown if not decorated. */ export function getEventTypeFor(target: Function): EventType { - return Reflect.getMetadata(EVENT_TYPE_METADATA_KEY, target) ?? EventType.unknown; + return getEventTypeMetadata(target)?.eventType ?? EventType.unknown; } /** @@ -57,5 +78,28 @@ export function getEventTypeFor(target: Function): EventType { * @returns True if the class has an event type decorator; false otherwise. */ export function hasEventType(target: Function): boolean { - return Reflect.hasMetadata(EVENT_TYPE_METADATA_KEY, target); + return getEventTypeMetadata(target) !== undefined; +} + +/** + * Gets the metadata associated with a class decorated with {@link eventType}. + * @param target - The class constructor to retrieve metadata for. + * @returns The associated metadata, or undefined if not decorated. + */ +export function getEventTypeMetadata(target: Function): EventTypeMetadata | undefined { + return Reflect.getMetadata(EVENT_TYPE_METADATA_KEY, target); +} + +/** + * Generates a JSON schema for the provided event type class. + * @param target - The event class constructor to generate schema for. + * @returns The generated JSON schema. + */ +export function getEventTypeJsonSchemaFor(target: Function): JsonSchema { + const metadata = getEventTypeMetadata(target); + if (metadata) { + return metadata.schema; + } + + return JsonSchemaGenerator.createEmptySchema(target.name); } diff --git a/Source/Events/index.ts b/Source/Events/index.ts index 3e2ca09..f003321 100644 --- a/Source/Events/index.ts +++ b/Source/Events/index.ts @@ -4,6 +4,11 @@ export { EventType } from './EventType'; export { EventTypeId } from './EventTypeId'; export { EventTypeGeneration } from './EventTypeGeneration'; -export { eventType, getEventTypeFor, hasEventType } from './eventTypeDecorator'; -export type { EventContext, CausationEntry } from './EventContext'; +export { eventType, getEventTypeFor, hasEventType, getEventTypeMetadata, getEventTypeJsonSchemaFor } from './eventTypeDecorator'; +export type { EventTypeMetadata } from './eventTypeDecorator'; +export type { EventContext } from './EventContext'; +export type { CausationEntry } from './CausationEntry'; export type { AppendedEvent } from './AppendedEvent'; +export type { IEventTypes } from './IEventTypes'; +export { EventTypes } from './EventTypes'; +export * from './Constraints'; diff --git a/Source/IEventStore.ts b/Source/IEventStore.ts index e31e4ac..f2d8862 100644 --- a/Source/IEventStore.ts +++ b/Source/IEventStore.ts @@ -6,6 +6,11 @@ import { IEventSequence } from './EventSequences/IEventSequence'; import { EventSequenceId } from './EventSequences/EventSequenceId'; import { EventStoreName } from './EventStoreName'; import { EventStoreNamespaceName } from './EventStoreNamespaceName'; +import { IEventTypes } from './Events/IEventTypes'; +import { IConstraints } from './Events/Constraints/IConstraints'; +import { IProjections } from './Projections/IProjections'; +import { IReactors } from './Reactors/IReactors'; +import { IReducers } from './Reducers/IReducers'; /** * Defines the API surface for an event store. @@ -21,6 +26,21 @@ export interface IEventStore { /** The primary event log sequence for this event store. */ readonly eventLog: IEventLog; + /** The event types manager for this event store. */ + readonly eventTypes: IEventTypes; + + /** The constraints manager for this event store. */ + readonly constraints: IConstraints; + + /** The projections manager for this event store. */ + readonly projections: IProjections; + + /** The reactors manager for this event store. */ + readonly reactors: IReactors; + + /** The reducers manager for this event store. */ + readonly reducers: IReducers; + /** * Gets an event sequence by its identifier. * @param id - The identifier of the event sequence to retrieve. diff --git a/Source/Projections/IProjections.ts b/Source/Projections/IProjections.ts new file mode 100644 index 0000000..72fd212 --- /dev/null +++ b/Source/Projections/IProjections.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Defines a system to work with projections, including discovery and registration with the Kernel. + */ +export interface IProjections { + /** + * Discovers all projections from the registered client artifacts. + * @returns A promise that resolves when discovery is complete. + */ + discover(): Promise; + + /** + * Registers all discovered projections with the Chronicle Kernel. + * @returns A promise that resolves when registration is complete. + */ + register(): Promise; +} diff --git a/Source/Projections/ProjectionId.ts b/Source/Projections/ProjectionId.ts new file mode 100644 index 0000000..037877e --- /dev/null +++ b/Source/Projections/ProjectionId.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Unique identifier for a projection. + */ +export class ProjectionId { + constructor(readonly value: string) {} + + /** @inheritdoc */ + toString(): string { + return this.value; + } +} diff --git a/Source/Projections/Projections.ts b/Source/Projections/Projections.ts new file mode 100644 index 0000000..d1ddfee --- /dev/null +++ b/Source/Projections/Projections.ts @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Constructor } from '@cratis/fundamentals'; +import { IClientArtifactsProvider } from '../artifacts'; +import { IProjections } from './IProjections'; +import { getProjectionMetadata } from './declarative/projection'; +import { getModelBoundMetadata } from './modelBound/modelBound'; + +/** + * Implements {@link IProjections}, managing discovery and registration of projections + * with the Chronicle Kernel. + */ +export class Projections implements IProjections { + private readonly _declarative = new Map(); + private readonly _modelBound = new Map(); + + /** + * Creates a new {@link Projections} instance. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor(private readonly _clientArtifacts: IClientArtifactsProvider) {} + + /** @inheritdoc */ + async discover(): Promise { + this._declarative.clear(); + this._modelBound.clear(); + + for (const type of this._clientArtifacts.projections) { + const metadata = getProjectionMetadata(type); + if (metadata) { + this._declarative.set(metadata.id.value, type); + } + } + + for (const type of this._clientArtifacts.modelBoundProjections) { + const metadata = getModelBoundMetadata(type); + if (metadata) { + this._modelBound.set(metadata.id.value, type); + } + } + } + + /** @inheritdoc */ + async register(): Promise { + if (this._declarative.size === 0 && this._modelBound.size === 0) { + await this.discover(); + } + + // Full projection definition serialization and gRPC registration will be + // added once the projection engine contract supports TypeScript clients. + } +} diff --git a/Source/Projections/declarative/IAddBuilder.ts b/Source/Projections/declarative/IAddBuilder.ts new file mode 100644 index 0000000..f813835 --- /dev/null +++ b/Source/Projections/declarative/IAddBuilder.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines a builder for configuring an add operation on a read model property. + * @template TEvent - The event type. + * @template TParentBuilder - The parent builder type for fluent return. + */ +export interface IAddBuilder { + /** + * Adds the value of the specified event property to the read model property. + * @param eventPropertyAccessor - Accessor for the source property on the event. + * @returns The parent builder for fluent chaining. + */ + with(eventPropertyAccessor: PropertyAccessor): TParentBuilder; +} diff --git a/Source/Projections/declarative/IAddChildBuilder.ts b/Source/Projections/declarative/IAddChildBuilder.ts new file mode 100644 index 0000000..bd59d83 --- /dev/null +++ b/Source/Projections/declarative/IAddChildBuilder.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines a builder for configuring how a child model is added from an event. + * @template TChildModel - The child model type. + * @template TEvent - The event type. + */ +export interface IAddChildBuilder { + /** + * Specifies the property on the child model used to identify instances in the collection. + * @param childPropertyAccessor - Accessor for the identifying property on the child model. + * @returns This builder for fluent chaining. + */ + identifiedBy(childPropertyAccessor: PropertyAccessor): IAddChildBuilder; + + /** + * Specifies the event property used as the key when adding a child. + * @param eventPropertyAccessor - Accessor for the key property on the event. + * @returns This builder for fluent chaining. + */ + usingKey(eventPropertyAccessor: PropertyAccessor): IAddChildBuilder; +} diff --git a/Source/Projections/declarative/IAllSetBuilder.ts b/Source/Projections/declarative/IAllSetBuilder.ts new file mode 100644 index 0000000..84cbece --- /dev/null +++ b/Source/Projections/declarative/IAllSetBuilder.ts @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines a builder for set operations that apply to all events (fromEvery). + * @template TReadModel - The read model type. + * @template TParentBuilder - The parent builder type for fluent return. + */ +export interface IAllSetBuilder { + /** + * Maps the value to the specified read model property. + * @param readModelPropertyAccessor - Accessor for the target property on the read model. + * @returns The parent builder for fluent chaining. + */ + to(readModelPropertyAccessor: PropertyAccessor): TParentBuilder; + + /** + * Maps the value from a named event context property. + * @param contextPropertyName - The name of the event context property to read from. + * @returns The parent builder for fluent chaining. + */ + toEventContextProperty(contextPropertyName: string): TParentBuilder; + + /** + * Maps the value from the event source identifier. + * @returns The parent builder for fluent chaining. + */ + toEventSourceId(): TParentBuilder; +} diff --git a/Source/Projections/declarative/IChildrenBuilder.ts b/Source/Projections/declarative/IChildrenBuilder.ts new file mode 100644 index 0000000..98f39b3 --- /dev/null +++ b/Source/Projections/declarative/IChildrenBuilder.ts @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; +import { IProjectionBuilder } from './IProjectionBuilder'; + +/** + * Defines the builder for a children collection sub-projection. + * @template TParentReadModel - The parent read model type. + * @template TChildReadModel - The child read model type. + */ +export interface IChildrenBuilder + extends IProjectionBuilder> { + /** + * Sets the property on the child model that identifies instances in the collection. + * @param propertyAccessor - Accessor for the identifying property on the child model. + * @returns This builder for fluent chaining. + */ + identifiedBy( + propertyAccessor: PropertyAccessor + ): IChildrenBuilder; + + /** + * Defines the event and property from which the child is created as a value. + * @param propertyAccessor - Accessor for the property on the event representing the child. + * @returns This builder for fluent chaining. + */ + fromEventProperty( + propertyAccessor: PropertyAccessor + ): IChildrenBuilder; +} diff --git a/Source/Projections/declarative/ICompositeKeyBuilder.ts b/Source/Projections/declarative/ICompositeKeyBuilder.ts new file mode 100644 index 0000000..7a81f05 --- /dev/null +++ b/Source/Projections/declarative/ICompositeKeyBuilder.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines a builder for constructing composite keys from multiple event properties. + * @template TKeyType - The composite key type. + * @template TEvent - The event type. + */ +export interface ICompositeKeyBuilder { + /** + * Maps a source event property to a target key property. + * @param targetPropertyAccessor - Accessor for the property on the key type to populate. + * @param sourcePropertyAccessor - Accessor for the property on the event to read from. + * @returns This builder for fluent chaining. + */ + set( + targetPropertyAccessor: PropertyAccessor, + sourcePropertyAccessor: PropertyAccessor + ): ICompositeKeyBuilder; +} diff --git a/Source/Projections/declarative/IFromBuilder.ts b/Source/Projections/declarative/IFromBuilder.ts new file mode 100644 index 0000000..3d2b49a --- /dev/null +++ b/Source/Projections/declarative/IFromBuilder.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { IReadModelPropertiesBuilder } from './IReadModelPropertiesBuilder'; + +/** + * Defines the builder for configuring property mappings from a specific event type. + * @template TReadModel - The read model type. + * @template TEvent - The event type. + */ +export interface IFromBuilder + extends IReadModelPropertiesBuilder> {} diff --git a/Source/Projections/declarative/IFromEveryBuilder.ts b/Source/Projections/declarative/IFromEveryBuilder.ts new file mode 100644 index 0000000..ef883fa --- /dev/null +++ b/Source/Projections/declarative/IFromEveryBuilder.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; +import { IAllSetBuilder } from './IAllSetBuilder'; + +/** + * Defines the builder for configuring property mappings that apply to every projected event. + * @template TReadModel - The read model type. + */ +export interface IFromEveryBuilder { + /** + * Begins a set operation on the specified read model property. + * @param readModelPropertyAccessor - Accessor for the read model property to set. + * @returns An all-set builder for specifying the value source. + */ + set(readModelPropertyAccessor: PropertyAccessor): IAllSetBuilder>; + + /** + * Excludes child projections from the fromEvery definition. + * @returns This builder for fluent chaining. + */ + excludeChildProjections(): IFromEveryBuilder; +} diff --git a/Source/Projections/declarative/IJoinBuilder.ts b/Source/Projections/declarative/IJoinBuilder.ts new file mode 100644 index 0000000..6e73040 --- /dev/null +++ b/Source/Projections/declarative/IJoinBuilder.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; +import { IReadModelPropertiesBuilder } from './IReadModelPropertiesBuilder'; + +/** + * Defines the builder for configuring a join projection from a specific event type. + * @template TReadModel - The read model type. + * @template TEvent - The event type. + */ +export interface IJoinBuilder + extends IReadModelPropertiesBuilder> { + /** + * Sets the property on the read model that forms the relationship for the join. + * @param readModelPropertyAccessor - Accessor for the read model property to join on. + * @returns This builder for fluent chaining. + */ + on(readModelPropertyAccessor: PropertyAccessor): IJoinBuilder; +} diff --git a/Source/Projections/declarative/INestedBuilder.ts b/Source/Projections/declarative/INestedBuilder.ts new file mode 100644 index 0000000..a4ab358 --- /dev/null +++ b/Source/Projections/declarative/INestedBuilder.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { IProjectionBuilder } from './IProjectionBuilder'; + +/** + * Defines the builder for a nested single-object sub-projection. + * @template TParentReadModel - The parent read model type. + * @template TNestedReadModel - The nested object read model type. + */ +export interface INestedBuilder + extends IProjectionBuilder> { + /** + * Specifies the event type that clears (sets to null) this nested object. + * @param eventType - The event constructor that triggers clearing this nested object. + * @returns This builder for fluent chaining. + */ + clearWith(eventType: Function): INestedBuilder; +} diff --git a/Source/Projections/declarative/IProjectionBuilder.ts b/Source/Projections/declarative/IProjectionBuilder.ts new file mode 100644 index 0000000..73a8cfb --- /dev/null +++ b/Source/Projections/declarative/IProjectionBuilder.ts @@ -0,0 +1,94 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; +import { IChildrenBuilder } from './IChildrenBuilder'; +import { IFromBuilder } from './IFromBuilder'; +import { IFromEveryBuilder } from './IFromEveryBuilder'; +import { IJoinBuilder } from './IJoinBuilder'; +import { INestedBuilder } from './INestedBuilder'; +import { IRemovedWithBuilder } from './IRemovedWithBuilder'; +import { IRemovedWithJoinBuilder } from './IRemovedWithJoinBuilder'; + +/** + * Defines the core builder interface for configuring a projection. + * @template TReadModel - The read model type this projection produces. + * @template TBuilder - The concrete builder type for fluent return. + */ +export interface IProjectionBuilder { + /** + * Enables automatic property mapping by naming convention. + * @returns This builder for fluent chaining. + */ + autoMap(): TBuilder; + + /** + * Disables automatic property mapping by naming convention. + * @returns This builder for fluent chaining. + */ + noAutoMap(): TBuilder; + + /** + * Provides initial property values for newly created read model instances. + * @param initialValueProvider - A factory function that returns the initial values. + * @returns This builder for fluent chaining. + */ + withInitialValues(initialValueProvider: () => TReadModel): TBuilder; + + /** + * Configures a projection from a specific event type. + * @param builderCallback - Optional callback for configuring property mappings. + * @returns This builder for fluent chaining. + */ + from(builderCallback?: (builder: IFromBuilder) => void): TBuilder; + + /** + * Configures a join projection from a specific event type. + * @param builderCallback - Optional callback for configuring the join mappings. + * @returns This builder for fluent chaining. + */ + join(builderCallback?: (builder: IJoinBuilder) => void): TBuilder; + + /** + * Configures property mappings that apply to every projected event type. + * @param builderCallback - Callback for configuring the fromEvery mappings. + * @returns This builder for fluent chaining. + */ + fromEvery(builderCallback: (builder: IFromEveryBuilder) => void): TBuilder; + + /** + * Specifies the event type that causes this read model instance to be removed. + * @param builderCallback - Optional callback for configuring the removal behavior. + * @returns This builder for fluent chaining. + */ + removedWith(builderCallback?: (builder: IRemovedWithBuilder) => void): TBuilder; + + /** + * Specifies the joined event type that causes this read model instance to be removed. + * @param builderCallback - Optional callback for configuring the removal behavior. + * @returns This builder for fluent chaining. + */ + removedWithJoin(builderCallback?: (builder: IRemovedWithJoinBuilder) => void): TBuilder; + + /** + * Configures a child collection projection on the specified read model property. + * @param targetPropertyAccessor - Accessor for the collection property on the read model. + * @param builderCallback - Callback for configuring the children projection. + * @returns This builder for fluent chaining. + */ + children( + targetPropertyAccessor: PropertyAccessor, + builderCallback: (builder: IChildrenBuilder) => void + ): TBuilder; + + /** + * Configures a nested single-object projection on the specified read model property. + * @param targetPropertyAccessor - Accessor for the nested property on the read model. + * @param builderCallback - Callback for configuring the nested projection. + * @returns This builder for fluent chaining. + */ + nested( + targetPropertyAccessor: PropertyAccessor, + builderCallback: (builder: INestedBuilder) => void + ): TBuilder; +} diff --git a/Source/Projections/declarative/IProjectionBuilderFor.ts b/Source/Projections/declarative/IProjectionBuilderFor.ts new file mode 100644 index 0000000..957b240 --- /dev/null +++ b/Source/Projections/declarative/IProjectionBuilderFor.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { IProjectionBuilder } from './IProjectionBuilder'; + +/** + * Defines the top-level projection builder for a specific read model type. + * Extends the core builder with projection-wide configuration options. + * @template TReadModel - The read model type this projection produces. + */ +export interface IProjectionBuilderFor + extends IProjectionBuilder> { + /** + * Specifies the event sequence this projection should read from. + * @param eventSequenceId - The identifier of the event sequence. + * @returns This builder for fluent chaining. + */ + fromEventSequence(eventSequenceId: string): IProjectionBuilderFor; + + /** + * Sets the container name used to store read model instances (e.g., collection or table name). + * @param name - The container name. + * @returns This builder for fluent chaining. + */ + containerName(name: string): IProjectionBuilderFor; + + /** + * Marks this projection as not rewindable, preventing historical event replay. + * @returns This builder for fluent chaining. + */ + notRewindable(): IProjectionBuilderFor; + + /** + * Marks this projection as passive, meaning it will not actively observe events. + * @returns This builder for fluent chaining. + */ + passive(): IProjectionBuilderFor; +} diff --git a/Source/Projections/declarative/IProjectionFor.ts b/Source/Projections/declarative/IProjectionFor.ts new file mode 100644 index 0000000..14f7149 --- /dev/null +++ b/Source/Projections/declarative/IProjectionFor.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { IProjectionBuilderFor } from './IProjectionBuilderFor'; + +/** + * Defines the contract for a declarative projection class bound to a specific read model type. + * Implement this interface on any class decorated with {@link projection} to provide a type-safe + * builder-based configuration for the projection. + * @template TReadModel - The read model type this projection produces. + */ +export interface IProjectionFor { + /** + * Configures the projection using the provided builder. + * @param builder - The projection builder to configure. + */ + define(builder: IProjectionBuilderFor): void; +} diff --git a/Source/Projections/declarative/IReadModelPropertiesBuilder.ts b/Source/Projections/declarative/IReadModelPropertiesBuilder.ts new file mode 100644 index 0000000..4f0fc5c --- /dev/null +++ b/Source/Projections/declarative/IReadModelPropertiesBuilder.ts @@ -0,0 +1,132 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; +import { ICompositeKeyBuilder } from './ICompositeKeyBuilder'; +import { IAddBuilder } from './IAddBuilder'; +import { IAddChildBuilder } from './IAddChildBuilder'; +import { ISetBuilder } from './ISetBuilder'; +import { ISubtractBuilder } from './ISubtractBuilder'; + +/** + * Defines the common read model property mapping operations shared by from and join builders. + * @template TReadModel - The read model type. + * @template TEvent - The event type. + * @template TBuilder - The concrete builder type for fluent return. + */ +export interface IReadModelPropertiesBuilder { + /** + * Uses an event property as the key for this projection. + * @param keyAccessor - Accessor for the event property used as key. + * @returns This builder for fluent chaining. + */ + usingKey(keyAccessor: PropertyAccessor): TBuilder; + + /** + * Uses a named event context property as the key for this projection. + * @param contextPropertyName - The name of the context property. + * @returns This builder for fluent chaining. + */ + usingKeyFromContext(contextPropertyName: string): TBuilder; + + /** + * Uses an event property as the parent key for child projections. + * @param keyAccessor - Accessor for the event property used as parent key. + * @returns This builder for fluent chaining. + */ + usingParentKey(keyAccessor: PropertyAccessor): TBuilder; + + /** + * Uses a named event context property as the parent key for child projections. + * @param contextPropertyName - The name of the context property. + * @returns This builder for fluent chaining. + */ + usingParentKeyFromContext(contextPropertyName: string): TBuilder; + + /** + * Uses a composite key built from multiple event properties. + * @param builderCallback - Callback for configuring the composite key builder. + * @returns This builder for fluent chaining. + */ + usingCompositeKey(builderCallback: (builder: ICompositeKeyBuilder) => void): TBuilder; + + /** + * Uses a composite parent key built from multiple event properties. + * @param builderCallback - Callback for configuring the composite key builder. + * @returns This builder for fluent chaining. + */ + usingParentCompositeKey(builderCallback: (builder: ICompositeKeyBuilder) => void): TBuilder; + + /** + * Uses a constant string value as the key for this projection. + * @param value - The constant key value. + * @returns This builder for fluent chaining. + */ + usingConstantKey(value: string): TBuilder; + + /** + * Uses a constant string value as the parent key for this projection. + * @param value - The constant parent key value. + * @returns This builder for fluent chaining. + */ + usingConstantParentKey(value: string): TBuilder; + + /** + * Increments the specified read model property by one when this event occurs. + * @param readModelPropertyAccessor - Accessor for the read model property to increment. + * @returns This builder for fluent chaining. + */ + increment(readModelPropertyAccessor: PropertyAccessor): TBuilder; + + /** + * Decrements the specified read model property by one when this event occurs. + * @param readModelPropertyAccessor - Accessor for the read model property to decrement. + * @returns This builder for fluent chaining. + */ + decrement(readModelPropertyAccessor: PropertyAccessor): TBuilder; + + /** + * Begins an add operation on the specified read model property. + * @param readModelPropertyAccessor - Accessor for the read model property to add to. + * @returns An add builder for specifying the event property value. + */ + add(readModelPropertyAccessor: PropertyAccessor): IAddBuilder; + + /** + * Begins a subtract operation on the specified read model property. + * @param readModelPropertyAccessor - Accessor for the read model property to subtract from. + * @returns A subtract builder for specifying the event property value. + */ + subtract(readModelPropertyAccessor: PropertyAccessor): ISubtractBuilder; + + /** + * Counts the number of matching events into the specified read model property. + * @param readModelPropertyAccessor - Accessor for the read model property to store the count in. + * @returns This builder for fluent chaining. + */ + count(readModelPropertyAccessor: PropertyAccessor): TBuilder; + + /** + * Adds a child model from the event to a collection property on the read model. + * @param targetPropertyAccessor - Accessor for the collection property on the read model. + * @param eventPropertyAccessorOrBuilderCallback - Either a direct event property accessor or a builder callback. + * @returns This builder for fluent chaining. + */ + addChild( + targetPropertyAccessor: PropertyAccessor, + eventPropertyAccessorOrBuilderCallback: PropertyAccessor | ((builder: IAddChildBuilder) => void) + ): TBuilder; + + /** + * Begins a set operation to map an event property to the specified read model property. + * @param readModelPropertyAccessor - Accessor for the read model property to set. + * @returns A set builder for specifying the event property value. + */ + set(readModelPropertyAccessor: PropertyAccessor): ISetBuilder; + + /** + * Begins a set operation that maps the entire event value to a read model property. + * @returns A set builder for specifying the target read model property. + */ + setThisValue(): ISetBuilder; +} diff --git a/Source/Projections/declarative/IRemovedWithBuilder.ts b/Source/Projections/declarative/IRemovedWithBuilder.ts new file mode 100644 index 0000000..c7cbf4b --- /dev/null +++ b/Source/Projections/declarative/IRemovedWithBuilder.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines the builder for configuring the removal of a read model instance via an event. + * @template TReadModel - The read model type. + * @template TEvent - The event type that triggers removal. + */ +export interface IRemovedWithBuilder { + /** + * Uses an event property as the key to identify the instance to remove. + * @param keyAccessor - Accessor for the event property used as key. + * @returns This builder for fluent chaining. + */ + usingKey(keyAccessor: PropertyAccessor): IRemovedWithBuilder; + + /** + * Uses a named event context property as the key to identify the instance to remove. + * @param contextPropertyName - The name of the event context property. + * @returns This builder for fluent chaining. + */ + usingKeyFromContext(contextPropertyName: string): IRemovedWithBuilder; +} diff --git a/Source/Projections/declarative/IRemovedWithJoinBuilder.ts b/Source/Projections/declarative/IRemovedWithJoinBuilder.ts new file mode 100644 index 0000000..d8915a3 --- /dev/null +++ b/Source/Projections/declarative/IRemovedWithJoinBuilder.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines the builder for configuring the removal of a read model instance via a join event. + * @template TReadModel - The read model type. + * @template TEvent - The event type that triggers removal. + */ +export interface IRemovedWithJoinBuilder { + /** + * Sets the read model property that forms the relationship for the join removal. + * @param readModelPropertyAccessor - Accessor for the read model property to join on. + * @returns This builder for fluent chaining. + */ + on(readModelPropertyAccessor: PropertyAccessor): IRemovedWithJoinBuilder; + + /** + * Uses an event property as the key to identify the instance to remove. + * @param keyAccessor - Accessor for the event property used as key. + * @returns This builder for fluent chaining. + */ + usingKey(keyAccessor: PropertyAccessor): IRemovedWithJoinBuilder; +} diff --git a/Source/Projections/declarative/ISetBuilder.ts b/Source/Projections/declarative/ISetBuilder.ts new file mode 100644 index 0000000..37e2753 --- /dev/null +++ b/Source/Projections/declarative/ISetBuilder.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines a builder for configuring a set operation on a read model property. + * @template TEvent - The event type. + * @template TParentBuilder - The parent builder type for fluent return. + */ +export interface ISetBuilder { + /** + * Maps the read model property from the specified event property. + * @param eventPropertyAccessor - Accessor for the source property on the event. + * @returns The parent builder for fluent chaining. + */ + to(eventPropertyAccessor: PropertyAccessor): TParentBuilder; + + /** + * Sets the read model property to a constant value. + * @param value - The constant value to assign. + * @returns The parent builder for fluent chaining. + */ + toValue(value: TProperty): TParentBuilder; + + /** + * Maps the read model property from a named event context property. + * @param contextPropertyName - The name of the event context property to read from. + * @returns The parent builder for fluent chaining. + */ + toEventContextProperty(contextPropertyName: string): TParentBuilder; + + /** + * Maps the read model property from the event source identifier. + * @returns The parent builder for fluent chaining. + */ + toEventSourceId(): TParentBuilder; +} diff --git a/Source/Projections/declarative/ISubtractBuilder.ts b/Source/Projections/declarative/ISubtractBuilder.ts new file mode 100644 index 0000000..ff009c2 --- /dev/null +++ b/Source/Projections/declarative/ISubtractBuilder.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { PropertyAccessor } from '@cratis/fundamentals'; + +/** + * Defines a builder for configuring a subtract operation on a read model property. + * @template TEvent - The event type. + * @template TParentBuilder - The parent builder type for fluent return. + */ +export interface ISubtractBuilder { + /** + * Subtracts the value of the specified event property from the read model property. + * @param eventPropertyAccessor - Accessor for the source property on the event. + * @returns The parent builder for fluent chaining. + */ + with(eventPropertyAccessor: PropertyAccessor): TParentBuilder; +} diff --git a/Source/Projections/declarative/index.ts b/Source/Projections/declarative/index.ts new file mode 100644 index 0000000..fb3417b --- /dev/null +++ b/Source/Projections/declarative/index.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { projection, getProjectionMetadata, isProjection } from './projection'; +export type { ProjectionMetadata } from './projection'; +export type { IProjectionFor } from './IProjectionFor'; +export type { IProjectionBuilderFor } from './IProjectionBuilderFor'; +export type { IProjectionBuilder } from './IProjectionBuilder'; +export type { IFromBuilder } from './IFromBuilder'; +export type { IJoinBuilder } from './IJoinBuilder'; +export type { IFromEveryBuilder } from './IFromEveryBuilder'; +export type { IReadModelPropertiesBuilder } from './IReadModelPropertiesBuilder'; +export type { ICompositeKeyBuilder } from './ICompositeKeyBuilder'; +export type { ISetBuilder } from './ISetBuilder'; +export type { IAllSetBuilder } from './IAllSetBuilder'; +export type { IAddBuilder } from './IAddBuilder'; +export type { ISubtractBuilder } from './ISubtractBuilder'; +export type { IAddChildBuilder } from './IAddChildBuilder'; +export type { IChildrenBuilder } from './IChildrenBuilder'; +export type { INestedBuilder } from './INestedBuilder'; +export type { IRemovedWithBuilder } from './IRemovedWithBuilder'; +export type { IRemovedWithJoinBuilder } from './IRemovedWithJoinBuilder'; diff --git a/Source/Projections/declarative/projection.ts b/Source/Projections/declarative/projection.ts new file mode 100644 index 0000000..3d9ddb2 --- /dev/null +++ b/Source/Projections/declarative/projection.ts @@ -0,0 +1,59 @@ +// 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 { Constructor } from '@cratis/fundamentals'; +import { ProjectionId } from '../ProjectionId'; +import { DecoratorType, TypeDiscoverer } from '../../types'; + +/** Metadata key used to store declarative projection information on a class. */ +const PROJECTION_METADATA_KEY = 'chronicle:projection'; + +/** + * Metadata stored on a declarative projection class. + */ +export interface ProjectionMetadata { + /** The unique identifier for the projection. */ + readonly id: ProjectionId; + + /** The optional explicit event sequence identifier. */ + readonly eventSequenceId: string | undefined; +} + +/** + * TypeScript decorator that marks a class as a declarative projection. + * @param id - The unique identifier for the projection. Defaults to the class name if omitted. + * @param eventSequenceId - Optional explicit event sequence identifier. + * @returns A class decorator. + */ +export function projection(id: string = '', eventSequenceId?: string): ClassDecorator { + return (target: object) => { + const constructor = target as Function; + const projectionId = new ProjectionId(id || constructor.name); + const metadata: ProjectionMetadata = { id: projectionId, eventSequenceId }; + Reflect.defineMetadata(PROJECTION_METADATA_KEY, metadata, target); + TypeDiscoverer.default.register( + DecoratorType.Projection, + constructor as Constructor, + projectionId.value + ); + }; +} + +/** + * Gets the {@link ProjectionMetadata} associated with a class decorated with {@link projection}. + * @param target - The class constructor to retrieve metadata for. + * @returns The associated metadata, or undefined if not decorated. + */ +export function getProjectionMetadata(target: Function): ProjectionMetadata | undefined { + return Reflect.getMetadata(PROJECTION_METADATA_KEY, target); +} + +/** + * Checks whether a class has been decorated with {@link projection}. + * @param target - The class constructor to check. + * @returns True if the class has a projection decorator; false otherwise. + */ +export function isProjection(target: Function): boolean { + return Reflect.hasMetadata(PROJECTION_METADATA_KEY, target); +} diff --git a/Source/Projections/index.ts b/Source/Projections/index.ts new file mode 100644 index 0000000..22355fc --- /dev/null +++ b/Source/Projections/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { ProjectionId } from './ProjectionId'; +export type { IProjections } from './IProjections'; +export { Projections } from './Projections'; +export * from './declarative'; +export * from './modelBound'; diff --git a/Source/Projections/modelBound/FromEventMetadata.ts b/Source/Projections/modelBound/FromEventMetadata.ts new file mode 100644 index 0000000..98192fb --- /dev/null +++ b/Source/Projections/modelBound/FromEventMetadata.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FromEventOptions } from './FromEventOptions'; + +/** Metadata stored by the fromEvent decorator on a class. */ +export interface FromEventMetadata extends FromEventOptions { + /** The event constructor associated with this annotation. */ + readonly eventType: Function; +} diff --git a/Source/Projections/modelBound/FromEventOptions.ts b/Source/Projections/modelBound/FromEventOptions.ts new file mode 100644 index 0000000..5d0f1c9 --- /dev/null +++ b/Source/Projections/modelBound/FromEventOptions.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Options for the fromEvent class decorator. */ +export interface FromEventOptions { + /** The event property name to use as the key to identify the read model instance. */ + readonly key?: string; + /** The event property name to use as the parent key for child relationships. */ + readonly parentKey?: string; + /** A constant string value to use as the key. All events will update the same instance. */ + readonly constantKey?: string; +} diff --git a/Source/Projections/modelBound/addFrom.ts b/Source/Projections/modelBound/addFrom.ts new file mode 100644 index 0000000..068884a --- /dev/null +++ b/Source/Projections/modelBound/addFrom.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the addFrom property decorator. */ +export interface AddFromMetadata { + /** The event constructor associated with this annotation. */ + readonly eventType: Function; + /** The event property name whose value is added to the read model property. Defaults to the decorated property name. */ + readonly eventPropertyName?: string; +} + +const METADATA_KEY = 'chronicle:projection:addFrom'; + +/** + * Property decorator that adds an event property value to the decorated read model property. + * @param eventType - The event constructor. + * @param eventPropertyName - Optional event property name. Defaults to the property name. + * @returns A property decorator. + */ +export function addFrom(eventType: Function, eventPropertyName?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: AddFromMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: AddFromMetadata = { eventType, eventPropertyName }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all addFrom metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of addFrom metadata entries. + */ +export function getAddFromMetadata(target: object, propertyKey: string): AddFromMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/childrenFrom.ts b/Source/Projections/modelBound/childrenFrom.ts new file mode 100644 index 0000000..0364bea --- /dev/null +++ b/Source/Projections/modelBound/childrenFrom.ts @@ -0,0 +1,50 @@ +// 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'; + +/** Metadata stored by the childrenFrom property decorator. */ +export interface ChildrenFromMetadata { + /** The event constructor that adds children to the collection. */ + readonly eventType: Function; + /** The event property name used as the key for children. Defaults to the event source identifier. */ + readonly key?: string; + /** The child model property name used to uniquely identify instances in the collection. */ + readonly identifiedBy?: string; + /** The event property name used as the parent key. Defaults to the event source identifier. */ + readonly parentKey?: string; +} + +const METADATA_KEY = 'chronicle:projection:childrenFrom'; + +/** + * Property decorator that configures a children collection sub-projection from an event type. + * @param eventType - The event constructor. + * @param key - Optional event property name to use as the key for children. + * @param identifiedBy - Optional child model property name used to identify instances. + * @param parentKey - Optional event property name used as the parent key. + * @returns A property decorator. + */ +export function childrenFrom( + eventType: Function, + key?: string, + identifiedBy?: string, + parentKey?: string +): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const propKey = propertyKey.toString(); + const existing: ChildrenFromMetadata[] = Reflect.getMetadata(METADATA_KEY, target, propKey) ?? []; + const metadata: ChildrenFromMetadata = { eventType, key, identifiedBy, parentKey }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, propKey); + }; +} + +/** + * Retrieves all childrenFrom metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of childrenFrom metadata entries. + */ +export function getChildrenFromMetadata(target: object, propertyKey: string): ChildrenFromMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/clearWith.ts b/Source/Projections/modelBound/clearWith.ts new file mode 100644 index 0000000..d23e74c --- /dev/null +++ b/Source/Projections/modelBound/clearWith.ts @@ -0,0 +1,52 @@ +// 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'; + +/** Metadata stored by the clearWith class or property decorator. */ +export interface ClearWithMetadata { + /** The event constructor that clears the nested object or projection. */ + readonly eventType: Function; +} + +const CLASS_METADATA_KEY = 'chronicle:projection:clearWith:class'; +const PROPERTY_METADATA_KEY = 'chronicle:projection:clearWith:property'; + +/** + * Class or property decorator that specifies the event type that clears a nested object. + * When used on a class, it clears the entire nested object. + * When used on a property, it clears only that property. + * @param eventType - The event constructor. + * @returns A class and property decorator. + */ +export function clearWith(eventType: Function): ClassDecorator & PropertyDecorator { + return (target: object, propertyKey?: string | symbol) => { + if (propertyKey !== undefined) { + const key = propertyKey.toString(); + const existing: ClearWithMetadata[] = Reflect.getMetadata(PROPERTY_METADATA_KEY, target, key) ?? []; + Reflect.defineMetadata(PROPERTY_METADATA_KEY, [...existing, { eventType }], target, key); + } else { + const existing: ClearWithMetadata[] = Reflect.getMetadata(CLASS_METADATA_KEY, target) ?? []; + Reflect.defineMetadata(CLASS_METADATA_KEY, [...existing, { eventType }], target); + } + }; +} + +/** + * Retrieves clearWith metadata stored on the given class constructor. + * @param target - The class constructor. + * @returns An array of clearWith metadata entries. + */ +export function getClearWithClassMetadata(target: Function): ClearWithMetadata[] { + return Reflect.getMetadata(CLASS_METADATA_KEY, target) ?? []; +} + +/** + * Retrieves clearWith metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of clearWith metadata entries. + */ +export function getClearWithPropertyMetadata(target: object, propertyKey: string): ClearWithMetadata[] { + return Reflect.getMetadata(PROPERTY_METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/count.ts b/Source/Projections/modelBound/count.ts new file mode 100644 index 0000000..a312f98 --- /dev/null +++ b/Source/Projections/modelBound/count.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the count property decorator. */ +export interface CountMetadata { + /** The event constructor whose occurrences are counted. */ + readonly eventType: Function; + /** Optional constant key. All events will update the same read model instance. */ + readonly constantKey?: string; +} + +const METADATA_KEY = 'chronicle:projection:count'; + +/** + * Property decorator that counts the occurrences of the specified event into the decorated read model property. + * @param eventType - The event constructor. + * @param constantKey - Optional constant key value. + * @returns A property decorator. + */ +export function count(eventType: Function, constantKey?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: CountMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: CountMetadata = { eventType, constantKey }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all count metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of count metadata entries. + */ +export function getCountMetadata(target: object, propertyKey: string): CountMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/decrement.ts b/Source/Projections/modelBound/decrement.ts new file mode 100644 index 0000000..bd42c90 --- /dev/null +++ b/Source/Projections/modelBound/decrement.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the decrement property decorator. */ +export interface DecrementMetadata { + /** The event constructor that triggers the decrement. */ + readonly eventType: Function; + /** Optional constant key. All events will update the same read model instance. */ + readonly constantKey?: string; +} + +const METADATA_KEY = 'chronicle:projection:decrement'; + +/** + * Property decorator that decrements the decorated read model property each time the specified event occurs. + * @param eventType - The event constructor. + * @param constantKey - Optional constant key value. + * @returns A property decorator. + */ +export function decrement(eventType: Function, constantKey?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: DecrementMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: DecrementMetadata = { eventType, constantKey }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all decrement metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of decrement metadata entries. + */ +export function getDecrementMetadata(target: object, propertyKey: string): DecrementMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/fromEvent.ts b/Source/Projections/modelBound/fromEvent.ts new file mode 100644 index 0000000..585845f --- /dev/null +++ b/Source/Projections/modelBound/fromEvent.ts @@ -0,0 +1,40 @@ +// 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 { FromEventMetadata } from './FromEventMetadata'; +import { FromEventOptions } from './FromEventOptions'; + +const METADATA_KEY = 'chronicle:projection:fromEvent'; + +/** + * Class decorator that declares which event type populates instances of this read model. + * @param eventType - The event constructor. + * @param options - Optional key configuration. + * @returns A class decorator. + */ +export function fromEvent(eventType: Function, options?: FromEventOptions): ClassDecorator { + return (target: object) => { + const existing: FromEventMetadata[] = Reflect.getMetadata(METADATA_KEY, target) ?? []; + const metadata: FromEventMetadata = { eventType, ...options }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target); + }; +} + +/** + * Retrieves all fromEvent metadata stored on the given class constructor. + * @param target - The class constructor. + * @returns An array of fromEvent metadata entries. + */ +export function getFromEventMetadata(target: Function): FromEventMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target) ?? []; +} + +/** + * Checks whether the given class has any fromEvent metadata. + * @param target - The class constructor. + * @returns True if the class has fromEvent metadata; false otherwise. + */ +export function hasFromEventMetadata(target: Function): boolean { + return Reflect.hasMetadata(METADATA_KEY, target); +} diff --git a/Source/Projections/modelBound/fromEvery.ts b/Source/Projections/modelBound/fromEvery.ts new file mode 100644 index 0000000..eaba26d --- /dev/null +++ b/Source/Projections/modelBound/fromEvery.ts @@ -0,0 +1,37 @@ +// 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'; + +/** Metadata stored by the fromEvery property decorator. */ +export interface FromEveryMetadata { + /** The event or event context property name to read the value from. */ + readonly property?: string; + /** The event context property name to read the value from. */ + readonly contextProperty?: string; +} + +const METADATA_KEY = 'chronicle:projection:fromEvery'; + +/** + * Property decorator that sets the decorated read model property from a property present on every projected event. + * @param property - Optional event property name. If not specified, uses the model property name. + * @param contextProperty - Optional event context property name. + * @returns A property decorator. + */ +export function fromEvery(property?: string, contextProperty?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const metadata: FromEveryMetadata = { property, contextProperty }; + Reflect.defineMetadata(METADATA_KEY, metadata, target, propertyKey.toString()); + }; +} + +/** + * Retrieves fromEvery metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns The fromEvery metadata, or undefined if not decorated. + */ +export function getFromEveryMetadata(target: object, propertyKey: string): FromEveryMetadata | undefined { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey); +} diff --git a/Source/Projections/modelBound/increment.ts b/Source/Projections/modelBound/increment.ts new file mode 100644 index 0000000..00da87b --- /dev/null +++ b/Source/Projections/modelBound/increment.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the increment property decorator. */ +export interface IncrementMetadata { + /** The event constructor that triggers the increment. */ + readonly eventType: Function; + /** Optional constant key. All events will update the same read model instance. */ + readonly constantKey?: string; +} + +const METADATA_KEY = 'chronicle:projection:increment'; + +/** + * Property decorator that increments the decorated read model property each time the specified event occurs. + * @param eventType - The event constructor. + * @param constantKey - Optional constant key value. + * @returns A property decorator. + */ +export function increment(eventType: Function, constantKey?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: IncrementMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: IncrementMetadata = { eventType, constantKey }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all increment metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of increment metadata entries. + */ +export function getIncrementMetadata(target: object, propertyKey: string): IncrementMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/index.ts b/Source/Projections/modelBound/index.ts new file mode 100644 index 0000000..14b8e08 --- /dev/null +++ b/Source/Projections/modelBound/index.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { modelBound, getModelBoundMetadata, isModelBound } from './modelBound'; +export type { ModelBoundMetadata } from './modelBound'; +export { fromEvent, getFromEventMetadata, hasFromEventMetadata } from './fromEvent'; +export type { FromEventOptions } from './FromEventOptions'; +export type { FromEventMetadata } from './FromEventMetadata'; +export { setFrom, getSetFromMetadata } from './setFrom'; +export type { SetFromMetadata } from './setFrom'; +export { setFromContext, getSetFromContextMetadata } from './setFromContext'; +export type { SetFromContextMetadata } from './setFromContext'; +export { join, getJoinMetadata } from './join'; +export type { JoinMetadata } from './join'; +export { addFrom, getAddFromMetadata } from './addFrom'; +export type { AddFromMetadata } from './addFrom'; +export { subtractFrom, getSubtractFromMetadata } from './subtractFrom'; +export type { SubtractFromMetadata } from './subtractFrom'; +export { increment, getIncrementMetadata } from './increment'; +export type { IncrementMetadata } from './increment'; +export { decrement, getDecrementMetadata } from './decrement'; +export type { DecrementMetadata } from './decrement'; +export { count, getCountMetadata } from './count'; +export type { CountMetadata } from './count'; +export { childrenFrom, getChildrenFromMetadata } from './childrenFrom'; +export type { ChildrenFromMetadata } from './childrenFrom'; +export { nested, isNested } from './nested'; +export { clearWith, getClearWithClassMetadata, getClearWithPropertyMetadata } from './clearWith'; +export type { ClearWithMetadata } from './clearWith'; +export { removedWith, getRemovedWithClassMetadata, getRemovedWithPropertyMetadata } from './removedWith'; +export type { RemovedWithMetadata } from './removedWith'; +export { removedWithJoin, getRemovedWithJoinClassMetadata, getRemovedWithJoinPropertyMetadata } from './removedWithJoin'; +export type { RemovedWithJoinMetadata } from './removedWithJoin'; +export { notRewindable, isNotRewindable } from './notRewindable'; +export { setValue, getSetValueMetadata } from './setValue'; +export type { SetValueMetadata } from './setValue'; +export { fromEvery, getFromEveryMetadata } from './fromEvery'; +export type { FromEveryMetadata } from './fromEvery'; diff --git a/Source/Projections/modelBound/join.ts b/Source/Projections/modelBound/join.ts new file mode 100644 index 0000000..263c5fa --- /dev/null +++ b/Source/Projections/modelBound/join.ts @@ -0,0 +1,42 @@ +// 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'; + +/** Metadata stored by the join property decorator. */ +export interface JoinMetadata { + /** The event constructor associated with this annotation. */ + readonly eventType: Function; + /** The read model property name used to form the join relationship. */ + readonly on?: string; + /** The event property name to read the joined value from. Defaults to the decorated property name. */ + readonly eventPropertyName?: string; +} + +const METADATA_KEY = 'chronicle:projection:join'; + +/** + * Property decorator that configures a join relationship with an event type. + * @param eventType - The event constructor. + * @param on - Optional property name on the read model to join on. + * @param eventPropertyName - Optional event property name. Defaults to the property name. + * @returns A property decorator. + */ +export function join(eventType: Function, on?: string, eventPropertyName?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: JoinMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: JoinMetadata = { eventType, on, eventPropertyName }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all join metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of join metadata entries. + */ +export function getJoinMetadata(target: object, propertyKey: string): JoinMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/modelBound.ts b/Source/Projections/modelBound/modelBound.ts new file mode 100644 index 0000000..b74b3c7 --- /dev/null +++ b/Source/Projections/modelBound/modelBound.ts @@ -0,0 +1,59 @@ +// 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 { Constructor } from '@cratis/fundamentals'; +import { ProjectionId } from '../ProjectionId'; +import { DecoratorType, TypeDiscoverer } from '../../types'; + +/** Metadata key used to store model-bound projection information on a class. */ +const MODEL_BOUND_METADATA_KEY = 'chronicle:modelBound'; + +/** + * Metadata stored on a model-bound projection class. + */ +export interface ModelBoundMetadata { + /** The unique identifier for the projection. */ + readonly id: ProjectionId; + + /** The optional explicit event sequence identifier. */ + readonly eventSequenceId: string | undefined; +} + +/** + * TypeScript decorator that marks a class as a model-bound projection. + * @param id - The unique identifier for the projection. Defaults to the class name if omitted. + * @param eventSequenceId - Optional explicit event sequence identifier. + * @returns A class decorator. + */ +export function modelBound(id: string = '', eventSequenceId?: string): ClassDecorator { + return (target: object) => { + const constructor = target as Function; + const projectionId = new ProjectionId(id || constructor.name); + const metadata: ModelBoundMetadata = { id: projectionId, eventSequenceId }; + Reflect.defineMetadata(MODEL_BOUND_METADATA_KEY, metadata, target); + TypeDiscoverer.default.register( + DecoratorType.ModelBoundProjection, + constructor as Constructor, + projectionId.value + ); + }; +} + +/** + * Gets the {@link ModelBoundMetadata} associated with a class decorated with {@link modelBound}. + * @param target - The class constructor to retrieve metadata for. + * @returns The associated metadata, or undefined if not decorated. + */ +export function getModelBoundMetadata(target: Function): ModelBoundMetadata | undefined { + return Reflect.getMetadata(MODEL_BOUND_METADATA_KEY, target); +} + +/** + * Checks whether a class has been decorated with {@link modelBound}. + * @param target - The class constructor to check. + * @returns True if the class has a model-bound decorator; false otherwise. + */ +export function isModelBound(target: Function): boolean { + return Reflect.hasMetadata(MODEL_BOUND_METADATA_KEY, target); +} diff --git a/Source/Projections/modelBound/nested.ts b/Source/Projections/modelBound/nested.ts new file mode 100644 index 0000000..c4966d4 --- /dev/null +++ b/Source/Projections/modelBound/nested.ts @@ -0,0 +1,26 @@ +// 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'; + +const METADATA_KEY = 'chronicle:projection:nested'; + +/** + * Property decorator that marks a single nullable property as a nested sub-projection object. + * The nested type should carry its own fromEvent and optionally clearWith decorators. + * @param target - The class prototype. + * @param propertyKey - The property name. + */ +export function nested(target: object, propertyKey: string | symbol): void { + Reflect.defineMetadata(METADATA_KEY, true, target, propertyKey.toString()); +} + +/** + * Checks whether the given property is marked as a nested sub-projection. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns True if the property is marked as nested; false otherwise. + */ +export function isNested(target: object, propertyKey: string): boolean { + return Reflect.hasMetadata(METADATA_KEY, target, propertyKey); +} diff --git a/Source/Projections/modelBound/notRewindable.ts b/Source/Projections/modelBound/notRewindable.ts new file mode 100644 index 0000000..4ba34e0 --- /dev/null +++ b/Source/Projections/modelBound/notRewindable.ts @@ -0,0 +1,24 @@ +// 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'; + +const METADATA_KEY = 'chronicle:projection:notRewindable'; + +/** + * Class decorator that marks a model-bound projection as not rewindable. + * A not-rewindable projection only processes new events; it cannot replay history. + * @param target - The class constructor. + */ +export function notRewindable(target: Function): void { + Reflect.defineMetadata(METADATA_KEY, true, target); +} + +/** + * Checks whether the given class is marked as not rewindable. + * @param target - The class constructor. + * @returns True if the class is marked as not rewindable; false otherwise. + */ +export function isNotRewindable(target: Function): boolean { + return Reflect.hasMetadata(METADATA_KEY, target); +} diff --git a/Source/Projections/modelBound/removedWith.ts b/Source/Projections/modelBound/removedWith.ts new file mode 100644 index 0000000..ef12c6e --- /dev/null +++ b/Source/Projections/modelBound/removedWith.ts @@ -0,0 +1,58 @@ +// 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'; + +/** Metadata stored by the removedWith class or property decorator. */ +export interface RemovedWithMetadata { + /** The event constructor that triggers removal. */ + readonly eventType: Function; + /** The event property name that identifies the instance to remove. Defaults to the event source identifier. */ + readonly key?: string; + /** The event property name that identifies the parent instance (for children). Defaults to the event source identifier. */ + readonly parentKey?: string; +} + +const CLASS_METADATA_KEY = 'chronicle:projection:removedWith:class'; +const PROPERTY_METADATA_KEY = 'chronicle:projection:removedWith:property'; + +/** + * Class or property decorator that specifies the event type that removes a read model instance or child. + * When used on a class, it removes the read model instance. + * When used on a property, it removes a child from the collection. + * @param eventType - The event constructor. + * @param key - Optional event property name used as the key to identify the instance to remove. + * @param parentKey - Optional event property name used as the parent key (for children only). + * @returns A class and property decorator. + */ +export function removedWith(eventType: Function, key?: string, parentKey?: string): ClassDecorator & PropertyDecorator { + return (target: object, propertyKey?: string | symbol) => { + if (propertyKey !== undefined) { + const propKey = propertyKey.toString(); + const existing: RemovedWithMetadata[] = Reflect.getMetadata(PROPERTY_METADATA_KEY, target, propKey) ?? []; + Reflect.defineMetadata(PROPERTY_METADATA_KEY, [...existing, { eventType, key, parentKey }], target, propKey); + } else { + const existing: RemovedWithMetadata[] = Reflect.getMetadata(CLASS_METADATA_KEY, target) ?? []; + Reflect.defineMetadata(CLASS_METADATA_KEY, [...existing, { eventType, key, parentKey }], target); + } + }; +} + +/** + * Retrieves removedWith metadata stored on the given class constructor. + * @param target - The class constructor. + * @returns An array of removedWith metadata entries. + */ +export function getRemovedWithClassMetadata(target: Function): RemovedWithMetadata[] { + return Reflect.getMetadata(CLASS_METADATA_KEY, target) ?? []; +} + +/** + * Retrieves removedWith metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of removedWith metadata entries. + */ +export function getRemovedWithPropertyMetadata(target: object, propertyKey: string): RemovedWithMetadata[] { + return Reflect.getMetadata(PROPERTY_METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/removedWithJoin.ts b/Source/Projections/modelBound/removedWithJoin.ts new file mode 100644 index 0000000..94ccdcd --- /dev/null +++ b/Source/Projections/modelBound/removedWithJoin.ts @@ -0,0 +1,53 @@ +// 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'; + +/** Metadata stored by the removedWithJoin class or property decorator. */ +export interface RemovedWithJoinMetadata { + /** The event constructor that triggers removal via a join. */ + readonly eventType: Function; + /** The event property name that identifies the instance to remove. Defaults to the event source identifier. */ + readonly key?: string; +} + +const CLASS_METADATA_KEY = 'chronicle:projection:removedWithJoin:class'; +const PROPERTY_METADATA_KEY = 'chronicle:projection:removedWithJoin:property'; + +/** + * Class or property decorator that specifies the event type that removes a read model instance or child via a join. + * @param eventType - The event constructor. + * @param key - Optional event property name used as the key to identify the instance to remove. + * @returns A class and property decorator. + */ +export function removedWithJoin(eventType: Function, key?: string): ClassDecorator & PropertyDecorator { + return (target: object, propertyKey?: string | symbol) => { + if (propertyKey !== undefined) { + const propKey = propertyKey.toString(); + const existing: RemovedWithJoinMetadata[] = Reflect.getMetadata(PROPERTY_METADATA_KEY, target, propKey) ?? []; + Reflect.defineMetadata(PROPERTY_METADATA_KEY, [...existing, { eventType, key }], target, propKey); + } else { + const existing: RemovedWithJoinMetadata[] = Reflect.getMetadata(CLASS_METADATA_KEY, target) ?? []; + Reflect.defineMetadata(CLASS_METADATA_KEY, [...existing, { eventType, key }], target); + } + }; +} + +/** + * Retrieves removedWithJoin metadata stored on the given class constructor. + * @param target - The class constructor. + * @returns An array of removedWithJoin metadata entries. + */ +export function getRemovedWithJoinClassMetadata(target: Function): RemovedWithJoinMetadata[] { + return Reflect.getMetadata(CLASS_METADATA_KEY, target) ?? []; +} + +/** + * Retrieves removedWithJoin metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of removedWithJoin metadata entries. + */ +export function getRemovedWithJoinPropertyMetadata(target: object, propertyKey: string): RemovedWithJoinMetadata[] { + return Reflect.getMetadata(PROPERTY_METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/setFrom.ts b/Source/Projections/modelBound/setFrom.ts new file mode 100644 index 0000000..7b58526 --- /dev/null +++ b/Source/Projections/modelBound/setFrom.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the setFrom property decorator. */ +export interface SetFromMetadata { + /** The event constructor associated with this annotation. */ + readonly eventType: Function; + /** The event property name to read the value from. Defaults to the decorated property name. */ + readonly eventPropertyName?: string; +} + +const METADATA_KEY = 'chronicle:projection:setFrom'; + +/** + * Property decorator that maps an event property value onto the decorated read model property. + * @param eventType - The event constructor. + * @param eventPropertyName - Optional event property name. Defaults to the property name. + * @returns A property decorator. + */ +export function setFrom(eventType: Function, eventPropertyName?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: SetFromMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: SetFromMetadata = { eventType, eventPropertyName }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all setFrom metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of setFrom metadata entries. + */ +export function getSetFromMetadata(target: object, propertyKey: string): SetFromMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/setFromContext.ts b/Source/Projections/modelBound/setFromContext.ts new file mode 100644 index 0000000..51a054d --- /dev/null +++ b/Source/Projections/modelBound/setFromContext.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the setFromContext property decorator. */ +export interface SetFromContextMetadata { + /** The event constructor associated with this annotation. */ + readonly eventType: Function; + /** The event context property name to read the value from. Defaults to the decorated property name. */ + readonly contextPropertyName?: string; +} + +const METADATA_KEY = 'chronicle:projection:setFromContext'; + +/** + * Property decorator that maps a value from an event context property onto the decorated read model property. + * @param eventType - The event constructor. + * @param contextPropertyName - Optional event context property name. Defaults to the property name. + * @returns A property decorator. + */ +export function setFromContext(eventType: Function, contextPropertyName?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: SetFromContextMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: SetFromContextMetadata = { eventType, contextPropertyName }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all setFromContext metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of setFromContext metadata entries. + */ +export function getSetFromContextMetadata(target: object, propertyKey: string): SetFromContextMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/setValue.ts b/Source/Projections/modelBound/setValue.ts new file mode 100644 index 0000000..e3ea0eb --- /dev/null +++ b/Source/Projections/modelBound/setValue.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the setValue property decorator. */ +export interface SetValueMetadata { + /** The event constructor that triggers the value assignment. */ + readonly eventType: Function; + /** The constant value to assign to the property when the event occurs. */ + readonly value: unknown; +} + +const METADATA_KEY = 'chronicle:projection:setValue'; + +/** + * Property decorator that sets the decorated read model property to a constant value when the specified event occurs. + * @param eventType - The event constructor. + * @param value - The constant value to assign. + * @returns A property decorator. + */ +export function setValue(eventType: Function, value: unknown): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: SetValueMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: SetValueMetadata = { eventType, value }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all setValue metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of setValue metadata entries. + */ +export function getSetValueMetadata(target: object, propertyKey: string): SetValueMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Projections/modelBound/subtractFrom.ts b/Source/Projections/modelBound/subtractFrom.ts new file mode 100644 index 0000000..f223cfc --- /dev/null +++ b/Source/Projections/modelBound/subtractFrom.ts @@ -0,0 +1,39 @@ +// 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'; + +/** Metadata stored by the subtractFrom property decorator. */ +export interface SubtractFromMetadata { + /** The event constructor associated with this annotation. */ + readonly eventType: Function; + /** The event property name whose value is subtracted from the read model property. Defaults to the decorated property name. */ + readonly eventPropertyName?: string; +} + +const METADATA_KEY = 'chronicle:projection:subtractFrom'; + +/** + * Property decorator that subtracts an event property value from the decorated read model property. + * @param eventType - The event constructor. + * @param eventPropertyName - Optional event property name. Defaults to the property name. + * @returns A property decorator. + */ +export function subtractFrom(eventType: Function, eventPropertyName?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const existing: SubtractFromMetadata[] = Reflect.getMetadata(METADATA_KEY, target, key) ?? []; + const metadata: SubtractFromMetadata = { eventType, eventPropertyName }; + Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target, key); + }; +} + +/** + * Retrieves all subtractFrom metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns An array of subtractFrom metadata entries. + */ +export function getSubtractFromMetadata(target: object, propertyKey: string): SubtractFromMetadata[] { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey) ?? []; +} diff --git a/Source/Reactors/IReactors.ts b/Source/Reactors/IReactors.ts new file mode 100644 index 0000000..0152aa1 --- /dev/null +++ b/Source/Reactors/IReactors.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Defines a system to work with reactors, including discovery and registration with the Kernel. + */ +export interface IReactors { + /** + * Discovers all reactors from the registered client artifacts. + * @returns A promise that resolves when discovery is complete. + */ + discover(): Promise; + + /** + * Registers all discovered reactors with the Chronicle Kernel and starts observation. + * @returns A promise that resolves when registration is complete. + */ + register(): Promise; +} diff --git a/Source/Reactors/Reactors.ts b/Source/Reactors/Reactors.ts new file mode 100644 index 0000000..fedd0d8 --- /dev/null +++ b/Source/Reactors/Reactors.ts @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Constructor } from '@cratis/fundamentals'; +import { IClientArtifactsProvider } from '../artifacts'; +import { IReactors } from './IReactors'; +import { getReactorMetadata } from './reactor'; + +/** + * Implements {@link IReactors}, managing discovery and registration of reactors + * with the Chronicle Kernel. + */ +export class Reactors implements IReactors { + private readonly _reactors = new Map(); + + /** + * Creates a new {@link Reactors} instance. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor(private readonly _clientArtifacts: IClientArtifactsProvider) {} + + /** @inheritdoc */ + async discover(): Promise { + this._reactors.clear(); + for (const type of this._clientArtifacts.reactors) { + const metadata = getReactorMetadata(type); + if (metadata) { + this._reactors.set(metadata.id.value, type); + } + } + } + + /** @inheritdoc */ + async register(): Promise { + if (this._reactors.size === 0) { + await this.discover(); + } + + // Reactor registration uses a bidirectional streaming gRPC call. + // Full streaming observation will be added once the observation + // infrastructure is implemented in the TypeScript client. + } +} diff --git a/Source/Reactors/index.ts b/Source/Reactors/index.ts index 41c99f4..e1d89e2 100644 --- a/Source/Reactors/index.ts +++ b/Source/Reactors/index.ts @@ -4,3 +4,5 @@ export { ReactorId } from './ReactorId'; export { reactor, getReactorMetadata, isReactor } from './reactor'; export type { ReactorMetadata } from './reactor'; +export type { IReactors } from './IReactors'; +export { Reactors } from './Reactors'; diff --git a/Source/ReadModels/ReadModelId.ts b/Source/ReadModels/ReadModelId.ts new file mode 100644 index 0000000..4af0f4b --- /dev/null +++ b/Source/ReadModels/ReadModelId.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Unique identifier for a read model. + */ +export class ReadModelId { + constructor(readonly value: string) {} + + /** @inheritdoc */ + toString(): string { + return this.value; + } +} diff --git a/Source/ReadModels/index.ts b/Source/ReadModels/index.ts new file mode 100644 index 0000000..eafbb81 --- /dev/null +++ b/Source/ReadModels/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { ReadModelId } from './ReadModelId'; +export { readModel, getReadModelMetadata, isReadModel } from './readModel'; +export type { ReadModelMetadata } from './readModel'; diff --git a/Source/ReadModels/readModel.ts b/Source/ReadModels/readModel.ts new file mode 100644 index 0000000..8d67b86 --- /dev/null +++ b/Source/ReadModels/readModel.ts @@ -0,0 +1,67 @@ +// 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 { Constructor } from '@cratis/fundamentals'; +import { ReadModelId } from './ReadModelId'; +import { DecoratorType, TypeDiscoverer, TypeIntrospector } from '../types'; +import { JsonSchema, JsonSchemaGenerator } from '../Schemas'; + +/** Metadata key used to store read model information on a class. */ +const READ_MODEL_METADATA_KEY = 'chronicle:readModel'; + +/** + * Metadata stored on a read model class. + */ +export interface ReadModelMetadata { + /** The unique identifier for the read model. */ + readonly id: ReadModelId; + + /** The reflected members and their runtime types. */ + readonly members: ReadonlyMap; + + /** The generated JSON schema for the read model. */ + readonly schema: JsonSchema; +} + +/** + * TypeScript decorator that marks a class as a read model and captures reflection metadata. + * @param id - The unique identifier for the read model. Defaults to the class name if omitted. + * @returns A class decorator. + */ +export function readModel(id: string = ''): ClassDecorator { + return (target: object) => { + const constructor = target as Function; + const readModelId = new ReadModelId(id || constructor.name); + const members = TypeIntrospector.getMembers(constructor); + const metadata: ReadModelMetadata = { + id: readModelId, + members, + schema: JsonSchemaGenerator.generate(constructor, members) + }; + Reflect.defineMetadata(READ_MODEL_METADATA_KEY, metadata, target); + TypeDiscoverer.default.register( + DecoratorType.ReadModel, + constructor as Constructor, + readModelId.value + ); + }; +} + +/** + * Gets the {@link ReadModelMetadata} associated with a class decorated with {@link readModel}. + * @param target - The class constructor to retrieve metadata for. + * @returns The associated metadata, or undefined if not decorated. + */ +export function getReadModelMetadata(target: Function): ReadModelMetadata | undefined { + return Reflect.getMetadata(READ_MODEL_METADATA_KEY, target); +} + +/** + * Checks whether a class has been decorated with {@link readModel}. + * @param target - The class constructor to check. + * @returns True if the class has a read model decorator; false otherwise. + */ +export function isReadModel(target: Function): boolean { + return Reflect.hasMetadata(READ_MODEL_METADATA_KEY, target); +} diff --git a/Source/Reducers/IReducers.ts b/Source/Reducers/IReducers.ts new file mode 100644 index 0000000..2f47260 --- /dev/null +++ b/Source/Reducers/IReducers.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Defines a system to work with reducers, including discovery and registration with the Kernel. + */ +export interface IReducers { + /** + * Discovers all reducers from the registered client artifacts. + * @returns A promise that resolves when discovery is complete. + */ + discover(): Promise; + + /** + * Registers all discovered reducers with the Chronicle Kernel and starts observation. + * @returns A promise that resolves when registration is complete. + */ + register(): Promise; +} diff --git a/Source/Reducers/Reducers.ts b/Source/Reducers/Reducers.ts new file mode 100644 index 0000000..27429bf --- /dev/null +++ b/Source/Reducers/Reducers.ts @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Constructor } from '@cratis/fundamentals'; +import { IClientArtifactsProvider } from '../artifacts'; +import { IReducers } from './IReducers'; +import { getReducerMetadata } from './reducer'; + +/** + * Implements {@link IReducers}, managing discovery and registration of reducers + * with the Chronicle Kernel. + */ +export class Reducers implements IReducers { + private readonly _reducers = new Map(); + + /** + * Creates a new {@link Reducers} instance. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor(private readonly _clientArtifacts: IClientArtifactsProvider) {} + + /** @inheritdoc */ + async discover(): Promise { + this._reducers.clear(); + for (const type of this._clientArtifacts.reducers) { + const metadata = getReducerMetadata(type); + if (metadata) { + this._reducers.set(metadata.id.value, type); + } + } + } + + /** @inheritdoc */ + async register(): Promise { + if (this._reducers.size === 0) { + await this.discover(); + } + + // Reducer registration uses a bidirectional streaming gRPC call. + // Full streaming observation will be added once the observation + // infrastructure is implemented in the TypeScript client. + } +} diff --git a/Source/Reducers/index.ts b/Source/Reducers/index.ts index 3cb281c..b4f8c65 100644 --- a/Source/Reducers/index.ts +++ b/Source/Reducers/index.ts @@ -4,3 +4,5 @@ export { ReducerId } from './ReducerId'; export { reducer, getReducerMetadata, isReducer } from './reducer'; export type { ReducerMetadata } from './reducer'; +export type { IReducers } from './IReducers'; +export { Reducers } from './Reducers'; diff --git a/Source/Reducers/reducer.ts b/Source/Reducers/reducer.ts index 8db68ad..f5bbf60 100644 --- a/Source/Reducers/reducer.ts +++ b/Source/Reducers/reducer.ts @@ -18,6 +18,9 @@ export interface ReducerMetadata { /** The optional explicit event sequence identifier. */ readonly eventSequenceId: string | undefined; + + /** The optional read model type produced by the reducer. */ + readonly readModel: Constructor | undefined; } /** @@ -30,6 +33,7 @@ export interface ReducerMetadata { * * @param id - The unique identifier for the reducer. Defaults to the class name if omitted. * @param eventSequenceId - Optional explicit event sequence identifier. + * @param readModel - Optional read model type produced by the reducer. * @returns A class decorator. * * @example @@ -42,17 +46,25 @@ export interface ReducerMetadata { * } * ``` */ -export function reducer(id: string = '', eventSequenceId?: string): ClassDecorator { +export function reducer(id: string = '', eventSequenceId?: string, readModel?: Constructor): ClassDecorator { return (target: object) => { const constructor = target as Function; const reducerId = new ReducerId(id || constructor.name); - const metadata: ReducerMetadata = { id: reducerId, eventSequenceId }; + const metadata: ReducerMetadata = { id: reducerId, eventSequenceId, readModel }; Reflect.defineMetadata(REDUCER_METADATA_KEY, metadata, target); TypeDiscoverer.default.register( DecoratorType.Reducer, constructor as Constructor, reducerId.value ); + + if (readModel) { + TypeDiscoverer.default.register( + DecoratorType.ReadModel, + readModel, + readModel.name + ); + } }; } diff --git a/Source/Schemas/JsonSchema.ts b/Source/Schemas/JsonSchema.ts new file mode 100644 index 0000000..150ab4b --- /dev/null +++ b/Source/Schemas/JsonSchema.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents a JSON Schema object. + */ +export type JsonSchema = { + $schema?: string; + title?: string; + description?: string; + type?: 'null' | 'boolean' | 'object' | 'array' | 'number' | 'string' | 'integer'; + properties?: Record; + required?: string[]; + items?: JsonSchema; + additionalProperties?: boolean | JsonSchema; + enum?: Array; +}; diff --git a/Source/Schemas/JsonSchemaGenerator.ts b/Source/Schemas/JsonSchemaGenerator.ts new file mode 100644 index 0000000..c1c052f --- /dev/null +++ b/Source/Schemas/JsonSchemaGenerator.ts @@ -0,0 +1,71 @@ +// 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 { JsonSchema } from './JsonSchema'; +import { TypeIntrospector } from '../types'; + +/** + * Generates JSON schemas for class constructors using reflection metadata. + */ +export class JsonSchemaGenerator { + /** + * Creates an empty schema for a type name. + * @param title - The title to use for the schema. + * @returns An empty object schema. + */ + static createEmptySchema(title: string): JsonSchema { + return { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title, + type: 'object', + properties: {}, + required: [], + additionalProperties: false + }; + } + + /** + * Generates a JSON schema for a class constructor. + * @param target - The class constructor to generate schema for. + * @param members - Optional pre-introspected members for reuse. + * @returns The generated JSON schema. + */ + static generate(target: Function, members?: ReadonlyMap): JsonSchema { + const membersToUse = members ?? TypeIntrospector.getMembers(target); + const schemaProperties: Record = {}; + for (const [memberName, memberType] of membersToUse.entries()) { + schemaProperties[memberName] = this.mapRuntimeTypeToSchema(memberType); + } + + return { + ...this.createEmptySchema(target.name), + properties: schemaProperties, + required: Array.from(membersToUse.keys()), + }; + } + + private static mapRuntimeTypeToSchema(runtimeType: Function | undefined): JsonSchema { + if (runtimeType === String) { + return { type: 'string' }; + } + + if (runtimeType === Number) { + return { type: 'number' }; + } + + if (runtimeType === Boolean) { + return { type: 'boolean' }; + } + + if (runtimeType === Array) { + return { type: 'array', items: { type: 'object' } }; + } + + if (runtimeType && runtimeType !== Object) { + return this.generate(runtimeType); + } + + return { type: 'object' }; + } +} diff --git a/Source/Schemas/index.ts b/Source/Schemas/index.ts new file mode 100644 index 0000000..96d6fc0 --- /dev/null +++ b/Source/Schemas/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { JsonSchemaGenerator } from './JsonSchemaGenerator'; +export { jsonSchemaProperty, getTrackedJsonSchemaProperties } from './jsonSchemaProperty'; +export type { JsonSchema } from './JsonSchema'; diff --git a/Source/Schemas/jsonSchemaProperty.ts b/Source/Schemas/jsonSchemaProperty.ts new file mode 100644 index 0000000..e0b8cbd --- /dev/null +++ b/Source/Schemas/jsonSchemaProperty.ts @@ -0,0 +1,24 @@ +// 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 { TypeIntrospector } from '../types'; + +/** + * Decorates a class property so its runtime type metadata can be used for JSON schema generation. + * @returns A property decorator. + */ +export function jsonSchemaProperty(): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + TypeIntrospector.trackProperty(target.constructor as Function, propertyKey.toString()); + }; +} + +/** + * Gets all tracked schema properties for a type. + * @param target - The class constructor to inspect. + * @returns The tracked property names. + */ +export function getTrackedJsonSchemaProperties(target: Function): string[] { + return TypeIntrospector.getTrackedProperties(target); +} diff --git a/Source/artifacts/DefaultClientArtifactsProvider.ts b/Source/artifacts/DefaultClientArtifactsProvider.ts index fb3c058..e4c4286 100644 --- a/Source/artifacts/DefaultClientArtifactsProvider.ts +++ b/Source/artifacts/DefaultClientArtifactsProvider.ts @@ -24,6 +24,11 @@ export class DefaultClientArtifactsProvider implements IClientArtifactsProvider return this.discoverer.getTypesByDecoratorType(DecoratorType.EventType); } + /** @inheritdoc */ + get readModels(): Constructor[] { + return this.discoverer.getTypesByDecoratorType(DecoratorType.ReadModel); + } + /** @inheritdoc */ get reactors(): Constructor[] { return this.discoverer.getTypesByDecoratorType(DecoratorType.Reactor); @@ -33,4 +38,19 @@ export class DefaultClientArtifactsProvider implements IClientArtifactsProvider get reducers(): Constructor[] { return this.discoverer.getTypesByDecoratorType(DecoratorType.Reducer); } + + /** @inheritdoc */ + get constraints(): Constructor[] { + return this.discoverer.getTypesByDecoratorType(DecoratorType.Constraint); + } + + /** @inheritdoc */ + get projections(): Constructor[] { + return this.discoverer.getTypesByDecoratorType(DecoratorType.Projection); + } + + /** @inheritdoc */ + get modelBoundProjections(): Constructor[] { + return this.discoverer.getTypesByDecoratorType(DecoratorType.ModelBoundProjection); + } } diff --git a/Source/artifacts/IClientArtifactsProvider.ts b/Source/artifacts/IClientArtifactsProvider.ts index 1e0fb50..6458f46 100644 --- a/Source/artifacts/IClientArtifactsProvider.ts +++ b/Source/artifacts/IClientArtifactsProvider.ts @@ -10,9 +10,21 @@ export interface IClientArtifactsProvider { /** Gets discovered event type constructors. */ readonly eventTypes: Constructor[]; + /** Gets discovered read model constructors. */ + readonly readModels: Constructor[]; + /** Gets discovered reactor constructors. */ readonly reactors: Constructor[]; /** Gets discovered reducer constructors. */ readonly reducers: Constructor[]; + + /** Gets discovered constraint constructors. */ + readonly constraints: Constructor[]; + + /** Gets discovered declarative projection constructors. */ + readonly projections: Constructor[]; + + /** Gets discovered model-bound projection constructors. */ + readonly modelBoundProjections: Constructor[]; } diff --git a/Source/index.ts b/Source/index.ts index 3f2379d..5256ee4 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -17,6 +17,9 @@ export * from './Events'; export * from './EventSequences'; export * from './Reactors'; export * from './Reducers'; +export * from './ReadModels'; +export * from './Projections'; export * from './Observation'; +export * from './Schemas'; export * from './types'; export * from './artifacts'; diff --git a/Source/types/DecoratorType.ts b/Source/types/DecoratorType.ts index 05d22e9..6fd6926 100644 --- a/Source/types/DecoratorType.ts +++ b/Source/types/DecoratorType.ts @@ -8,9 +8,21 @@ export enum DecoratorType { /** Event type artifacts discovered through the eventType decorator. */ EventType = 'eventType', + /** Read model artifacts discovered through the readModel decorator. */ + ReadModel = 'readModel', + /** Reactor artifacts discovered through the reactor decorator. */ Reactor = 'reactor', /** Reducer artifacts discovered through the reducer decorator. */ - Reducer = 'reducer' + Reducer = 'reducer', + + /** Constraint artifacts discovered through the constraint decorator. */ + Constraint = 'constraint', + + /** Declarative projection artifacts discovered through the projection decorator. */ + Projection = 'projection', + + /** Model-bound projection artifacts discovered through the modelBound decorator. */ + ModelBoundProjection = 'modelBoundProjection' } diff --git a/Source/types/TypeIntrospector.ts b/Source/types/TypeIntrospector.ts new file mode 100644 index 0000000..f9a56ce --- /dev/null +++ b/Source/types/TypeIntrospector.ts @@ -0,0 +1,130 @@ +// 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'; + +/** Metadata key for tracked schema properties on a target type. */ +const TRACKED_PROPERTIES_METADATA_KEY = 'chronicle:typeIntrospection:properties'; + +/** + * Provides reflection-based type introspection utilities for decorated Chronicle artifacts. + */ +export class TypeIntrospector { + /** + * Tracks a property name on a type so it can be included in reflection-based introspection. + * @param target - The class constructor to track the property for. + * @param propertyName - The property name to track. + */ + static trackProperty(target: Function, propertyName: string): void { + const trackedProperties = this.getTrackedProperties(target); + if (trackedProperties.includes(propertyName)) { + return; + } + + Reflect.defineMetadata(TRACKED_PROPERTIES_METADATA_KEY, [...trackedProperties, propertyName], target); + } + + /** + * Gets all tracked properties for a type. + * @param target - The class constructor to inspect. + * @returns The tracked property names. + */ + static getTrackedProperties(target: Function): string[] { + return Reflect.getMetadata(TRACKED_PROPERTIES_METADATA_KEY, target) ?? []; + } + + /** + * Gets members and their runtime types for a class. + * Members are discovered from tracked properties and constructor parameters. + * @param target - The class constructor to inspect. + * @returns A map of member name to runtime type. + */ + static getMembers(target: Function): Map { + const members = new Map(); + + for (const property of this.getTrackedProperties(target)) { + const runtimeType = Reflect.getMetadata('design:type', target.prototype, property) as Function | undefined; + members.set(property, runtimeType); + } + + const constructorParameterNames = this.getConstructorParameterNames(target); + const constructorParameterTypes = Reflect.getMetadata('design:paramtypes', target) as Function[] | undefined ?? []; + + for (let index = 0; index < constructorParameterNames.length; index++) { + const parameterName = constructorParameterNames[index]; + if (members.has(parameterName)) { + continue; + } + + members.set(parameterName, constructorParameterTypes[index]); + } + + return members; + } + + private static getConstructorParameterNames(target: Function): string[] { + const source = target.toString(); + const constructorKeyword = 'constructor('; + const constructorStart = source.indexOf(constructorKeyword); + if (constructorStart < 0) { + return []; + } + + let index = constructorStart + constructorKeyword.length; + let depth = 1; + let parameterSegment = ''; + while (index < source.length && depth > 0) { + const character = source[index]; + if (character === '(') { + depth++; + parameterSegment += character; + } else if (character === ')') { + depth--; + if (depth > 0) { + parameterSegment += character; + } + } else { + parameterSegment += character; + } + index++; + } + + const names: string[] = []; + let current = ''; + for (const character of parameterSegment) { + if (character === ',') { + const candidate = current.trim(); + if (candidate.length > 0) { + names.push(candidate); + } + current = ''; + } else { + current += character; + } + } + + const lastCandidate = current.trim(); + if (lastCandidate.length > 0) { + names.push(lastCandidate); + } + + return names + .map(name => this.extractParameterName(name)) + .filter(name => name.length > 0); + } + + private static extractParameterName(rawParameter: string): string { + if (/[{}\[\]]/.test(rawParameter)) { + return ''; + } + + const withoutRest = rawParameter.startsWith('...') ? rawParameter.substring(3) : rawParameter; + const equalsIndex = withoutRest.indexOf('='); + const withoutDefaultValue = equalsIndex >= 0 ? withoutRest.substring(0, equalsIndex) : withoutRest; + const colonIndex = withoutDefaultValue.indexOf(':'); + const withoutTypeAnnotation = colonIndex >= 0 + ? withoutDefaultValue.substring(0, colonIndex) + : withoutDefaultValue; + return withoutTypeAnnotation.trim(); + } +} diff --git a/Source/types/index.ts b/Source/types/index.ts index a4b1a07..29ee835 100644 --- a/Source/types/index.ts +++ b/Source/types/index.ts @@ -3,3 +3,4 @@ export { DecoratorType } from './DecoratorType'; export { TypeDiscoverer } from './TypeDiscoverer'; +export { TypeIntrospector } from './TypeIntrospector'; diff --git a/TestApps/NodeJS/Constraints.ts b/TestApps/NodeJS/Constraints.ts new file mode 100644 index 0000000..a617078 --- /dev/null +++ b/TestApps/NodeJS/Constraints.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { constraint, IConstraint, IConstraintBuilder } from '@cratis/chronicle'; +import { EmployeeHired, EmployeeLeft } from './Events'; + +/** + * Constraint that enforces unique employee names across all hire events. + * Demonstrates the IConstraint pattern with a builder-based definition. + */ +@constraint('unique-employee-name') +export class UniqueEmployeeNameConstraint implements IConstraint { + /** @inheritdoc */ + define(builder: IConstraintBuilder): void { + builder + .perEventSourceType() + .unique(unique => + unique + .on(e => e.firstName, e => e.lastName) + .ignoreCasing() + .removedWith(EmployeeLeft) + .withMessage('An employee with that name already exists.') + ); + } +} diff --git a/TestApps/NodeJS/Events.ts b/TestApps/NodeJS/Events.ts new file mode 100644 index 0000000..4d054b7 --- /dev/null +++ b/TestApps/NodeJS/Events.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { eventType } from '@cratis/chronicle'; + +/** Represents an employee being hired into the organization. */ +@eventType('aa7faa25-afc1-48d1-8558-716581c0e916', 1) +export class EmployeeHired { + constructor( + readonly firstName: string, + readonly lastName: string, + readonly title: string + ) {} +} + +/** Represents an employee receiving a promotion to a new title. */ +@eventType('bb8fbb36-bfd2-49e5-b669-827692d1f027', 1) +export class EmployeePromoted { + constructor(readonly newTitle: string) {} +} + +/** Represents an employee relocating to a new city. */ +@eventType('cc9fcc47-cfe3-4af6-c77a-938703e2f138', 1) +export class EmployeeMoved { + constructor(readonly newCity: string) {} +} + +/** Represents an employee leaving the organization. */ +@eventType('dd0fdd58-dfe4-4bf7-d88b-049814f3f249', 1) +export class EmployeeLeft { + constructor(readonly reason: string) {} +} diff --git a/TestApps/NodeJS/ModelBoundProjections.ts b/TestApps/NodeJS/ModelBoundProjections.ts new file mode 100644 index 0000000..245dddb --- /dev/null +++ b/TestApps/NodeJS/ModelBoundProjections.ts @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { + modelBound, + fromEvent, + setFrom, + setFromContext, + increment, + notRewindable, + removedWith +} from '@cratis/chronicle'; +import { EmployeeHired, EmployeeLeft, EmployeeMoved, EmployeePromoted } from './Events'; + +/** + * Model-bound projection that maps employee domain events directly onto properties via decorators. + * Uses property-level decorators to keep projection logic co-located with the read model shape. + */ +@modelBound('employees-model-bound') +@notRewindable +@removedWith(EmployeeLeft) +export class EmployeesModelBoundProjection { + @setFrom(EmployeeHired) + firstName: string = ''; + + @setFrom(EmployeeHired) + lastName: string = ''; + + @setFrom(EmployeeHired) + @setFrom(EmployeePromoted, 'newTitle') + title: string = ''; + + @setFrom(EmployeeMoved, 'newCity') + city: string = ''; + + @increment(EmployeePromoted) + promotionCount: number = 0; + + @setFromContext(EmployeeHired, 'occurred') + lastUpdated: string = ''; +} + +/** + * Model-bound projection that maps employee on-call status from hire events. + * Demonstrates a second, distinct model-bound projection with its own read model shape. + */ +@fromEvent(EmployeeHired) +@modelBound('employees-on-call-model-bound') +export class EmployeesOnCallModelBoundProjection { + @setFrom(EmployeeHired) + firstName: string = ''; + + @setFrom(EmployeeHired) + lastName: string = ''; + + @setFromContext(EmployeeHired, 'occurred') + onCallSince: string = ''; +} diff --git a/TestApps/NodeJS/Projections.ts b/TestApps/NodeJS/Projections.ts new file mode 100644 index 0000000..152bf07 --- /dev/null +++ b/TestApps/NodeJS/Projections.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { projection, IProjectionFor, IProjectionBuilderFor } from '@cratis/chronicle'; +import { EmployeeReadModel } from './ReadModels'; +import { EmployeeHired, EmployeeLeft, EmployeeMoved, EmployeePromoted } from './Events'; + +/** + * Declarative projection that builds an EmployeeReadModel from employee domain events. + * Uses the fluent builder API to describe all property mappings explicitly. + */ +@projection('employees-declarative') +export class EmployeesDeclarativeProjection implements IProjectionFor { + /** @inheritdoc */ + define(builder: IProjectionBuilderFor): void { + builder + .from(fromBuilder => + fromBuilder + .set(m => m.firstName).to(e => e.firstName) + .set(m => m.lastName).to(e => e.lastName) + .set(m => m.title).to(e => e.title) + ) + .from(fromBuilder => + fromBuilder + .set(m => m.title).to(e => e.newTitle) + .increment(m => m.promotionCount) + ) + .from(fromBuilder => + fromBuilder + .set(m => m.city).to(e => e.newCity) + ) + .removedWith(); + } +} diff --git a/TestApps/NodeJS/Reactors.ts b/TestApps/NodeJS/Reactors.ts new file mode 100644 index 0000000..e2106d5 --- /dev/null +++ b/TestApps/NodeJS/Reactors.ts @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { reactor, EventContext } from '@cratis/chronicle'; +import { EmployeeHired, EmployeePromoted } from './Events'; + +/** Reacts to employee events by logging them to the console. */ +@reactor('hr-notification-reactor') +export class HrNotificationReactor { + /** + * Reacts to an employee being hired. + * @param event - The EmployeeHired event. + * @param context - The event context. + */ + async employeeHired(event: EmployeeHired, context: EventContext): Promise { + console.log(`[Reactor] ${event.firstName} ${event.lastName} was hired as ${event.title} (seq: ${context.sequenceNumber})`); + } + + /** + * Reacts to an employee being promoted. + * @param event - The EmployeePromoted event. + * @param context - The event context. + */ + async employeePromoted(event: EmployeePromoted, context: EventContext): Promise { + console.log(`[Reactor] Promotion to ${event.newTitle} (seq: ${context.sequenceNumber})`); + } +} diff --git a/TestApps/NodeJS/ReadModels.ts b/TestApps/NodeJS/ReadModels.ts new file mode 100644 index 0000000..b9367cf --- /dev/null +++ b/TestApps/NodeJS/ReadModels.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readModel } from '@cratis/chronicle'; + +/** Read model representing the current state of an employee, used by the declarative projection. */ +@readModel('employee-read-model') +export class EmployeeReadModel { + constructor( + readonly firstName: string, + readonly lastName: string, + readonly title: string, + readonly city: string, + readonly promotionCount: number + ) {} +} + +/** Read model representing the current on-call status of an employee, used by the model-bound projection. */ +@readModel('employee-on-call-read-model') +export class EmployeeOnCallReadModel { + constructor( + readonly firstName: string, + readonly lastName: string, + readonly onCallSince: string + ) {} +} diff --git a/TestApps/NodeJS/index.ts b/TestApps/NodeJS/index.ts index 89d4633..247e249 100644 --- a/TestApps/NodeJS/index.ts +++ b/TestApps/NodeJS/index.ts @@ -5,63 +5,17 @@ // initialised before any instrumented code runs. import './telemetry'; import 'reflect-metadata'; -import { - ChronicleClient, - ChronicleOptions, - eventType, - reactor, - EventContext -} from '@cratis/chronicle'; - -// --- Event type definitions --- - -/** Represents an employee being hired into the organization. */ -@eventType('aa7faa25-afc1-48d1-8558-716581c0e916', 1) -class EmployeeHired { - constructor( - readonly firstName: string, - readonly lastName: string, - readonly title: string - ) {} -} - -/** Represents an employee receiving a promotion to a new title. */ -@eventType('bb8fbb36-bfd2-49e5-b669-827692d1f027', 1) -class EmployeePromoted { - constructor(readonly newTitle: string) {} -} - -/** Represents an employee relocating to a new city. */ -@eventType('cc9fcc47-cfe3-4af6-c77a-938703e2f138', 1) -class EmployeeMoved { - constructor(readonly newCity: string) {} -} +import { ChronicleClient, ChronicleOptions, getEventTypeJsonSchemaFor } from '@cratis/chronicle'; -// --- Reactor --- - -/** Reacts to employee events by logging them to the console. */ -@reactor('hr-notification-reactor') -class HrNotificationReactor { - /** - * Reacts to an employee being hired. - * @param event - The EmployeeHired event. - * @param context - The event context. - */ - async employeeHired(event: EmployeeHired, context: EventContext): Promise { - console.log(`[Reactor] ${event.firstName} ${event.lastName} was hired as ${event.title} (seq: ${context.sequenceNumber})`); - } +// Import all artifacts so their decorators register them with the discoverer. +import './Events'; +import './ReadModels'; +import './Projections'; +import './ModelBoundProjections'; +import './Constraints'; +import './Reactors'; - /** - * Reacts to an employee being promoted. - * @param event - The EmployeePromoted event. - * @param context - The event context. - */ - async employeePromoted(event: EmployeePromoted, context: EventContext): Promise { - console.log(`[Reactor] Promotion to ${event.newTitle} (seq: ${context.sequenceNumber})`); - } -} - -// --- Main test flow --- +import { EmployeeHired, EmployeeMoved, EmployeePromoted } from './Events'; async function run(): Promise { const connectionString = process.env.CHRONICLE_CONNECTION ?? 'chronicle://localhost:35000'; @@ -70,6 +24,9 @@ async function run(): Promise { const options = ChronicleOptions.fromConnectionString(connectionString); const client = new ChronicleClient(options); + const employeeHiredSchema = getEventTypeJsonSchemaFor(EmployeeHired); + console.log(`EmployeeHired schema properties: ${Object.keys(employeeHiredSchema.properties ?? {}).join(', ')}`); + try { console.log('Getting event store...'); const store = await client.getEventStore('TestStore');