From a8cec5d2d5d7435b0fe822badf59a718d752313d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 06:16:05 +0000 Subject: [PATCH 01/13] Initial plan From ed2b5ada1c9131782d2b4c9c9652afb55647ef45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 06:21:51 +0000 Subject: [PATCH 02/13] Add decorators and artifacts for projections constraints and read models Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/4c08bd3c-9d18-463d-8c8c-4c5fec8fcbf5 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Documentation/index.md | 2 +- README.md | 2 +- Source/Constraints/ConstraintId.ts | 14 +++++ Source/Constraints/constraint.ts | 55 ++++++++++++++++ Source/Constraints/index.ts | 6 ++ Source/Events/eventTypeDecorator.ts | 10 +++ Source/Events/index.ts | 2 +- Source/Projections/ProjectionId.ts | 14 +++++ Source/Projections/index.ts | 8 +++ Source/Projections/modelBoundProjection.ts | 59 ++++++++++++++++++ Source/Projections/projection.ts | 59 ++++++++++++++++++ Source/ReadModels/ReadModelId.ts | 14 +++++ Source/ReadModels/index.ts | 6 ++ Source/ReadModels/readModel.ts | 62 +++++++++++++++++++ Source/Reducers/reducer.ts | 16 ++++- Source/Schemas/JsonSchema.ts | 17 +++++ Source/Schemas/JsonSchemaGenerator.ts | 58 +++++++++++++++++ Source/Schemas/index.ts | 6 ++ Source/Schemas/jsonSchemaProperty.ts | 32 ++++++++++ .../DefaultClientArtifactsProvider.ts | 20 ++++++ Source/artifacts/IClientArtifactsProvider.ts | 12 ++++ Source/index.ts | 4 ++ Source/types/DecoratorType.ts | 14 ++++- 23 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 Source/Constraints/ConstraintId.ts create mode 100644 Source/Constraints/constraint.ts create mode 100644 Source/Constraints/index.ts create mode 100644 Source/Projections/ProjectionId.ts create mode 100644 Source/Projections/index.ts create mode 100644 Source/Projections/modelBoundProjection.ts create mode 100644 Source/Projections/projection.ts create mode 100644 Source/ReadModels/ReadModelId.ts create mode 100644 Source/ReadModels/index.ts create mode 100644 Source/ReadModels/readModel.ts create mode 100644 Source/Schemas/JsonSchema.ts create mode 100644 Source/Schemas/JsonSchemaGenerator.ts create mode 100644 Source/Schemas/index.ts create mode 100644 Source/Schemas/jsonSchemaProperty.ts 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 9086c86..940341e 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/Constraints/ConstraintId.ts b/Source/Constraints/ConstraintId.ts new file mode 100644 index 0000000..86f38ee --- /dev/null +++ b/Source/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/Constraints/constraint.ts b/Source/Constraints/constraint.ts new file mode 100644 index 0000000..499360e --- /dev/null +++ b/Source/Constraints/constraint.ts @@ -0,0 +1,55 @@ +// 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. + * @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/Constraints/index.ts b/Source/Constraints/index.ts new file mode 100644 index 0000000..e2b9209 --- /dev/null +++ b/Source/Constraints/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 { ConstraintId } from './ConstraintId'; +export { constraint, getConstraintMetadata, isConstraint } from './constraint'; +export type { ConstraintMetadata } from './constraint'; diff --git a/Source/Events/eventTypeDecorator.ts b/Source/Events/eventTypeDecorator.ts index 9b3fa8e..d7ec7f3 100644 --- a/Source/Events/eventTypeDecorator.ts +++ b/Source/Events/eventTypeDecorator.ts @@ -7,6 +7,7 @@ import { EventType } from './EventType'; import { EventTypeId } from './EventTypeId'; import { EventTypeGeneration } from './EventTypeGeneration'; import { DecoratorType, TypeDiscoverer } 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'; @@ -59,3 +60,12 @@ export function getEventTypeFor(target: Function): EventType { export function hasEventType(target: Function): boolean { return Reflect.hasMetadata(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 { + return JsonSchemaGenerator.generate(target); +} diff --git a/Source/Events/index.ts b/Source/Events/index.ts index 3e2ca09..0ac2702 100644 --- a/Source/Events/index.ts +++ b/Source/Events/index.ts @@ -4,6 +4,6 @@ export { EventType } from './EventType'; export { EventTypeId } from './EventTypeId'; export { EventTypeGeneration } from './EventTypeGeneration'; -export { eventType, getEventTypeFor, hasEventType } from './eventTypeDecorator'; +export { eventType, getEventTypeFor, hasEventType, getEventTypeJsonSchemaFor } from './eventTypeDecorator'; export type { EventContext, CausationEntry } from './EventContext'; export type { AppendedEvent } from './AppendedEvent'; 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/index.ts b/Source/Projections/index.ts new file mode 100644 index 0000000..8d74de5 --- /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 { projection, getProjectionMetadata, isProjection } from './projection'; +export type { ProjectionMetadata } from './projection'; +export { modelBoundProjection, getModelBoundProjectionMetadata, isModelBoundProjection } from './modelBoundProjection'; +export type { ModelBoundProjectionMetadata } from './modelBoundProjection'; diff --git a/Source/Projections/modelBoundProjection.ts b/Source/Projections/modelBoundProjection.ts new file mode 100644 index 0000000..e4135a1 --- /dev/null +++ b/Source/Projections/modelBoundProjection.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_PROJECTION_METADATA_KEY = 'chronicle:modelBoundProjection'; + +/** + * Metadata stored on a model-bound projection class. + */ +export interface ModelBoundProjectionMetadata { + /** 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 modelBoundProjection(id: string = '', eventSequenceId?: string): ClassDecorator { + return (target: object) => { + const constructor = target as Function; + const projectionId = new ProjectionId(id || constructor.name); + const metadata: ModelBoundProjectionMetadata = { id: projectionId, eventSequenceId }; + Reflect.defineMetadata(MODEL_BOUND_PROJECTION_METADATA_KEY, metadata, target); + TypeDiscoverer.default.register( + DecoratorType.ModelBoundProjection, + constructor as Constructor, + projectionId.value + ); + }; +} + +/** + * Gets the {@link ModelBoundProjectionMetadata} associated with a class decorated with {@link modelBoundProjection}. + * @param target - The class constructor to retrieve metadata for. + * @returns The associated metadata, or undefined if not decorated. + */ +export function getModelBoundProjectionMetadata(target: Function): ModelBoundProjectionMetadata | undefined { + return Reflect.getMetadata(MODEL_BOUND_PROJECTION_METADATA_KEY, target); +} + +/** + * Checks whether a class has been decorated with {@link modelBoundProjection}. + * @param target - The class constructor to check. + * @returns True if the class has a model-bound projection decorator; false otherwise. + */ +export function isModelBoundProjection(target: Function): boolean { + return Reflect.hasMetadata(MODEL_BOUND_PROJECTION_METADATA_KEY, target); +} diff --git a/Source/Projections/projection.ts b/Source/Projections/projection.ts new file mode 100644 index 0000000..a3474b8 --- /dev/null +++ b/Source/Projections/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/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..45ecc2c --- /dev/null +++ b/Source/ReadModels/readModel.ts @@ -0,0 +1,62 @@ +// 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 } 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 generated JSON schema for the read model. */ + readonly schema: JsonSchema; +} + +/** + * TypeScript decorator that marks a class as a read model and captures its JSON schema. + * @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 metadata: ReadModelMetadata = { + id: readModelId, + schema: JsonSchemaGenerator.generate(constructor) + }; + 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/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..2f9860a --- /dev/null +++ b/Source/Schemas/JsonSchemaGenerator.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'; +import { JsonSchema } from './JsonSchema'; +import { getTrackedJsonSchemaProperties } from './jsonSchemaProperty'; + +/** + * Generates JSON schemas for class constructors using reflection metadata. + */ +export class JsonSchemaGenerator { + /** + * Generates a JSON schema for a class constructor. + * @param target - The class constructor to generate schema for. + * @returns The generated JSON schema. + */ + static generate(target: Function): JsonSchema { + const properties = getTrackedJsonSchemaProperties(target); + const schemaProperties: Record = {}; + for (const property of properties) { + const runtimeType = Reflect.getMetadata('design:type', target.prototype, property) as Function | undefined; + schemaProperties[property] = this.mapRuntimeTypeToSchema(runtimeType); + } + + return { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: target.name, + type: 'object', + properties: schemaProperties, + required: properties, + additionalProperties: false + }; + } + + 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..743cac4 --- /dev/null +++ b/Source/Schemas/jsonSchemaProperty.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 'reflect-metadata'; + +/** Metadata key for tracked schema properties on a target type. */ +const JSON_SCHEMA_PROPERTIES_METADATA_KEY = 'chronicle:jsonSchema:properties'; + +/** + * 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) => { + const existing = getTrackedJsonSchemaProperties(target.constructor as Function); + const property = propertyKey.toString(); + if (existing.includes(property)) { + return; + } + + Reflect.defineMetadata(JSON_SCHEMA_PROPERTIES_METADATA_KEY, [...existing, property], target.constructor); + }; +} + +/** + * 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 Reflect.getMetadata(JSON_SCHEMA_PROPERTIES_METADATA_KEY, 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 f4f093a..ad31ef3 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -15,6 +15,10 @@ export * from './Events'; export * from './EventSequences'; export * from './Reactors'; export * from './Reducers'; +export * from './ReadModels'; +export * from './Constraints'; +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..e025930 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 modelBoundProjection decorator. */ + ModelBoundProjection = 'modelBoundProjection' } From 81ebc32891c32c284001c307d35b9aed1562a37c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 06:58:35 +0000 Subject: [PATCH 03/13] Refactor event and read-model schema metadata through TypeIntrospector Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/10b983ed-1c87-432d-a182-6cecf0e571f7 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/Events/eventTypeDecorator.ts | 35 ++++++++++- Source/Events/index.ts | 3 +- Source/ReadModels/index.ts | 2 +- Source/ReadModels/readModel.ts | 17 +++++- Source/Schemas/JsonSchemaGenerator.ts | 11 ++-- Source/Schemas/jsonSchemaProperty.ts | 14 +---- Source/types/TypeIntrospector.ts | 83 +++++++++++++++++++++++++++ Source/types/index.ts | 1 + 8 files changed, 142 insertions(+), 24 deletions(-) create mode 100644 Source/types/TypeIntrospector.ts diff --git a/Source/Events/eventTypeDecorator.ts b/Source/Events/eventTypeDecorator.ts index d7ec7f3..e71d481 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'; +const EVENT_TYPE_INTROSPECTION_METADATA_KEY = 'chronicle:eventType:introspection'; + +/** + * 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 @@ -34,7 +49,14 @@ 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)); + const members = TypeIntrospector.getMembers(constructor); + const metadata: EventTypeMetadata = { + eventType: eventTypeInstance, + members, + schema: JsonSchemaGenerator.generate(constructor) + }; Reflect.defineMetadata(EVENT_TYPE_METADATA_KEY, eventTypeInstance, target); + Reflect.defineMetadata(EVENT_TYPE_INTROSPECTION_METADATA_KEY, metadata, target); TypeDiscoverer.default.register( DecoratorType.EventType, constructor as Constructor, @@ -61,11 +83,20 @@ export function hasEventType(target: Function): boolean { return Reflect.hasMetadata(EVENT_TYPE_METADATA_KEY, target); } +/** + * 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_INTROSPECTION_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 { - return JsonSchemaGenerator.generate(target); + return getEventTypeMetadata(target)?.schema ?? JsonSchemaGenerator.generate(target); } diff --git a/Source/Events/index.ts b/Source/Events/index.ts index 0ac2702..7118c96 100644 --- a/Source/Events/index.ts +++ b/Source/Events/index.ts @@ -4,6 +4,7 @@ export { EventType } from './EventType'; export { EventTypeId } from './EventTypeId'; export { EventTypeGeneration } from './EventTypeGeneration'; -export { eventType, getEventTypeFor, hasEventType, getEventTypeJsonSchemaFor } from './eventTypeDecorator'; +export { eventType, getEventTypeFor, hasEventType, getEventTypeMetadata, getEventTypeJsonSchemaFor } from './eventTypeDecorator'; +export type { EventTypeMetadata } from './eventTypeDecorator'; export type { EventContext, CausationEntry } from './EventContext'; export type { AppendedEvent } from './AppendedEvent'; diff --git a/Source/ReadModels/index.ts b/Source/ReadModels/index.ts index eafbb81..e30dafa 100644 --- a/Source/ReadModels/index.ts +++ b/Source/ReadModels/index.ts @@ -2,5 +2,5 @@ // 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 { ReadModel, readModel, getReadModelMetadata, isReadModel } from './readModel'; export type { ReadModelMetadata } from './readModel'; diff --git a/Source/ReadModels/readModel.ts b/Source/ReadModels/readModel.ts index 45ecc2c..f8c0fa6 100644 --- a/Source/ReadModels/readModel.ts +++ b/Source/ReadModels/readModel.ts @@ -4,7 +4,7 @@ import 'reflect-metadata'; import { Constructor } from '@cratis/fundamentals'; import { ReadModelId } from './ReadModelId'; -import { DecoratorType, TypeDiscoverer } from '../types'; +import { DecoratorType, TypeDiscoverer, TypeIntrospector } from '../types'; import { JsonSchema, JsonSchemaGenerator } from '../Schemas'; /** Metadata key used to store read model information on a class. */ @@ -17,21 +17,25 @@ 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 its JSON schema. + * 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 { +export function ReadModel(id: string = ''): ClassDecorator { return (target: object) => { const constructor = target as Function; const readModelId = new ReadModelId(id || constructor.name); const metadata: ReadModelMetadata = { id: readModelId, + members: TypeIntrospector.getMembers(constructor), schema: JsonSchemaGenerator.generate(constructor) }; Reflect.defineMetadata(READ_MODEL_METADATA_KEY, metadata, target); @@ -43,6 +47,13 @@ export function readModel(id: string = ''): ClassDecorator { }; } +/** + * 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 const readModel = ReadModel; + /** * Gets the {@link ReadModelMetadata} associated with a class decorated with {@link readModel}. * @param target - The class constructor to retrieve metadata for. diff --git a/Source/Schemas/JsonSchemaGenerator.ts b/Source/Schemas/JsonSchemaGenerator.ts index 2f9860a..4099795 100644 --- a/Source/Schemas/JsonSchemaGenerator.ts +++ b/Source/Schemas/JsonSchemaGenerator.ts @@ -3,7 +3,7 @@ import 'reflect-metadata'; import { JsonSchema } from './JsonSchema'; -import { getTrackedJsonSchemaProperties } from './jsonSchemaProperty'; +import { TypeIntrospector } from '../types'; /** * Generates JSON schemas for class constructors using reflection metadata. @@ -15,11 +15,10 @@ export class JsonSchemaGenerator { * @returns The generated JSON schema. */ static generate(target: Function): JsonSchema { - const properties = getTrackedJsonSchemaProperties(target); + const members = TypeIntrospector.getMembers(target); const schemaProperties: Record = {}; - for (const property of properties) { - const runtimeType = Reflect.getMetadata('design:type', target.prototype, property) as Function | undefined; - schemaProperties[property] = this.mapRuntimeTypeToSchema(runtimeType); + for (const [memberName, memberType] of members.entries()) { + schemaProperties[memberName] = this.mapRuntimeTypeToSchema(memberType); } return { @@ -27,7 +26,7 @@ export class JsonSchemaGenerator { title: target.name, type: 'object', properties: schemaProperties, - required: properties, + required: Array.from(members.keys()), additionalProperties: false }; } diff --git a/Source/Schemas/jsonSchemaProperty.ts b/Source/Schemas/jsonSchemaProperty.ts index 743cac4..e0b8cbd 100644 --- a/Source/Schemas/jsonSchemaProperty.ts +++ b/Source/Schemas/jsonSchemaProperty.ts @@ -2,9 +2,7 @@ // 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 JSON_SCHEMA_PROPERTIES_METADATA_KEY = 'chronicle:jsonSchema:properties'; +import { TypeIntrospector } from '../types'; /** * Decorates a class property so its runtime type metadata can be used for JSON schema generation. @@ -12,13 +10,7 @@ const JSON_SCHEMA_PROPERTIES_METADATA_KEY = 'chronicle:jsonSchema:properties'; */ export function jsonSchemaProperty(): PropertyDecorator { return (target: object, propertyKey: string | symbol) => { - const existing = getTrackedJsonSchemaProperties(target.constructor as Function); - const property = propertyKey.toString(); - if (existing.includes(property)) { - return; - } - - Reflect.defineMetadata(JSON_SCHEMA_PROPERTIES_METADATA_KEY, [...existing, property], target.constructor); + TypeIntrospector.trackProperty(target.constructor as Function, propertyKey.toString()); }; } @@ -28,5 +20,5 @@ export function jsonSchemaProperty(): PropertyDecorator { * @returns The tracked property names. */ export function getTrackedJsonSchemaProperties(target: Function): string[] { - return Reflect.getMetadata(JSON_SCHEMA_PROPERTIES_METADATA_KEY, target) ?? []; + return TypeIntrospector.getTrackedProperties(target); } diff --git a/Source/types/TypeIntrospector.ts b/Source/types/TypeIntrospector.ts new file mode 100644 index 0000000..8548ddb --- /dev/null +++ b/Source/types/TypeIntrospector.ts @@ -0,0 +1,83 @@ +// 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 match = source.match(/constructor\s*\(([^)]*)\)/m); + if (!match) { + return []; + } + + return match[1] + .split(',') + .map(parameter => parameter + .replace(/\/\*.*?\*\//g, '') + .replace(/\b(public|private|protected|readonly)\b/g, '') + .replace(/\?.*$/, '') + .replace(/:.*$/, '') + .replace(/=.*$/, '') + .trim()) + .filter(parameter => parameter.length > 0); + } +} 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'; From a5cb13c3a08daf2cceb82dcac205b7b4b19e2003 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 07:04:37 +0000 Subject: [PATCH 04/13] Use shared TypeIntrospector metadata for event and read model schemas Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/10b983ed-1c87-432d-a182-6cecf0e571f7 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/Events/eventTypeDecorator.ts | 19 ++++--- Source/ReadModels/readModel.ts | 15 ++---- Source/Schemas/JsonSchemaGenerator.ts | 30 ++++++++--- Source/types/TypeIntrospector.ts | 71 ++++++++++++++++++++++----- 4 files changed, 97 insertions(+), 38 deletions(-) diff --git a/Source/Events/eventTypeDecorator.ts b/Source/Events/eventTypeDecorator.ts index e71d481..ea054b3 100644 --- a/Source/Events/eventTypeDecorator.ts +++ b/Source/Events/eventTypeDecorator.ts @@ -11,7 +11,6 @@ import { JsonSchema, JsonSchemaGenerator } from '../Schemas'; /** Metadata key used to store event type information on a class. */ const EVENT_TYPE_METADATA_KEY = 'chronicle:eventType'; -const EVENT_TYPE_INTROSPECTION_METADATA_KEY = 'chronicle:eventType:introspection'; /** * Metadata stored for an event type class. @@ -53,10 +52,9 @@ export function eventType(id: string = '', generation: number = EventTypeGenerat const metadata: EventTypeMetadata = { eventType: eventTypeInstance, members, - schema: JsonSchemaGenerator.generate(constructor) + schema: JsonSchemaGenerator.generate(constructor, members) }; - Reflect.defineMetadata(EVENT_TYPE_METADATA_KEY, eventTypeInstance, target); - Reflect.defineMetadata(EVENT_TYPE_INTROSPECTION_METADATA_KEY, metadata, target); + Reflect.defineMetadata(EVENT_TYPE_METADATA_KEY, metadata, target); TypeDiscoverer.default.register( DecoratorType.EventType, constructor as Constructor, @@ -71,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; } /** @@ -80,7 +78,7 @@ 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; } /** @@ -89,7 +87,7 @@ export function hasEventType(target: Function): boolean { * @returns The associated metadata, or undefined if not decorated. */ export function getEventTypeMetadata(target: Function): EventTypeMetadata | undefined { - return Reflect.getMetadata(EVENT_TYPE_INTROSPECTION_METADATA_KEY, target); + return Reflect.getMetadata(EVENT_TYPE_METADATA_KEY, target); } /** @@ -98,5 +96,10 @@ export function getEventTypeMetadata(target: Function): EventTypeMetadata | unde * @returns The generated JSON schema. */ export function getEventTypeJsonSchemaFor(target: Function): JsonSchema { - return getEventTypeMetadata(target)?.schema ?? JsonSchemaGenerator.generate(target); + const metadata = getEventTypeMetadata(target); + if (metadata) { + return metadata.schema; + } + + return JsonSchemaGenerator.createEmptySchema(target.name); } diff --git a/Source/ReadModels/readModel.ts b/Source/ReadModels/readModel.ts index f8c0fa6..ac0243b 100644 --- a/Source/ReadModels/readModel.ts +++ b/Source/ReadModels/readModel.ts @@ -29,14 +29,15 @@ export interface ReadModelMetadata { * @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 { +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: TypeIntrospector.getMembers(constructor), - schema: JsonSchemaGenerator.generate(constructor) + members, + schema: JsonSchemaGenerator.generate(constructor, members) }; Reflect.defineMetadata(READ_MODEL_METADATA_KEY, metadata, target); TypeDiscoverer.default.register( @@ -46,13 +47,7 @@ export function ReadModel(id: string = ''): ClassDecorator { ); }; } - -/** - * 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 const readModel = ReadModel; +export const ReadModel = readModel; /** * Gets the {@link ReadModelMetadata} associated with a class decorated with {@link readModel}. diff --git a/Source/Schemas/JsonSchemaGenerator.ts b/Source/Schemas/JsonSchemaGenerator.ts index 4099795..c1c052f 100644 --- a/Source/Schemas/JsonSchemaGenerator.ts +++ b/Source/Schemas/JsonSchemaGenerator.ts @@ -9,25 +9,39 @@ 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): JsonSchema { - const members = TypeIntrospector.getMembers(target); + static generate(target: Function, members?: ReadonlyMap): JsonSchema { + const membersToUse = members ?? TypeIntrospector.getMembers(target); const schemaProperties: Record = {}; - for (const [memberName, memberType] of members.entries()) { + for (const [memberName, memberType] of membersToUse.entries()) { schemaProperties[memberName] = this.mapRuntimeTypeToSchema(memberType); } return { - $schema: 'https://json-schema.org/draft/2020-12/schema', - title: target.name, - type: 'object', + ...this.createEmptySchema(target.name), properties: schemaProperties, - required: Array.from(members.keys()), - additionalProperties: false + required: Array.from(membersToUse.keys()), }; } diff --git a/Source/types/TypeIntrospector.ts b/Source/types/TypeIntrospector.ts index 8548ddb..f9a56ce 100644 --- a/Source/types/TypeIntrospector.ts +++ b/Source/types/TypeIntrospector.ts @@ -64,20 +64,67 @@ export class TypeIntrospector { private static getConstructorParameterNames(target: Function): string[] { const source = target.toString(); - const match = source.match(/constructor\s*\(([^)]*)\)/m); - if (!match) { + const constructorKeyword = 'constructor('; + const constructorStart = source.indexOf(constructorKeyword); + if (constructorStart < 0) { return []; } - return match[1] - .split(',') - .map(parameter => parameter - .replace(/\/\*.*?\*\//g, '') - .replace(/\b(public|private|protected|readonly)\b/g, '') - .replace(/\?.*$/, '') - .replace(/:.*$/, '') - .replace(/=.*$/, '') - .trim()) - .filter(parameter => parameter.length > 0); + 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(); } } From 120cacbf8c7ff8457124ac6440ce410064c8f2d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 20:07:37 +0000 Subject: [PATCH 05/13] Remove ReadModel decorator alias Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/d0d8735e-abf6-4811-8ee7-5fa8e1764e30 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/ReadModels/index.ts | 2 +- Source/ReadModels/readModel.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Source/ReadModels/index.ts b/Source/ReadModels/index.ts index e30dafa..eafbb81 100644 --- a/Source/ReadModels/index.ts +++ b/Source/ReadModels/index.ts @@ -2,5 +2,5 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. export { ReadModelId } from './ReadModelId'; -export { ReadModel, readModel, getReadModelMetadata, isReadModel } from './readModel'; +export { readModel, getReadModelMetadata, isReadModel } from './readModel'; export type { ReadModelMetadata } from './readModel'; diff --git a/Source/ReadModels/readModel.ts b/Source/ReadModels/readModel.ts index ac0243b..8d67b86 100644 --- a/Source/ReadModels/readModel.ts +++ b/Source/ReadModels/readModel.ts @@ -47,7 +47,6 @@ export function readModel(id: string = ''): ClassDecorator { ); }; } -export const ReadModel = readModel; /** * Gets the {@link ReadModelMetadata} associated with a class decorated with {@link readModel}. From 06c955ddcb8c416c300f61dc6115a75cbb0c7d05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 20:15:32 +0000 Subject: [PATCH 06/13] Align projection structure and remove schema-property dependency Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/d34b2733-db80-4dfd-baf3-a9179b84bf56 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/Projections/declarative/index.ts | 5 ++ .../{ => declarative}/projection.ts | 4 +- Source/Projections/index.ts | 6 +- Source/Projections/modelBound/index.ts | 5 ++ .../modelBound.ts} | 28 +++---- Source/types/DecoratorType.ts | 2 +- Source/types/TypeIntrospector.ts | 80 +++++++++++++++++++ TestApps/NodeJS/index.ts | 20 +++++ 8 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 Source/Projections/declarative/index.ts rename Source/Projections/{ => declarative}/projection.ts (95%) create mode 100644 Source/Projections/modelBound/index.ts rename Source/Projections/{modelBoundProjection.ts => modelBound/modelBound.ts} (57%) diff --git a/Source/Projections/declarative/index.ts b/Source/Projections/declarative/index.ts new file mode 100644 index 0000000..8e00bbd --- /dev/null +++ b/Source/Projections/declarative/index.ts @@ -0,0 +1,5 @@ +// 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'; diff --git a/Source/Projections/projection.ts b/Source/Projections/declarative/projection.ts similarity index 95% rename from Source/Projections/projection.ts rename to Source/Projections/declarative/projection.ts index a3474b8..3d9ddb2 100644 --- a/Source/Projections/projection.ts +++ b/Source/Projections/declarative/projection.ts @@ -3,8 +3,8 @@ import 'reflect-metadata'; import { Constructor } from '@cratis/fundamentals'; -import { ProjectionId } from './ProjectionId'; -import { DecoratorType, TypeDiscoverer } from '../types'; +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'; diff --git a/Source/Projections/index.ts b/Source/Projections/index.ts index 8d74de5..1bf13d6 100644 --- a/Source/Projections/index.ts +++ b/Source/Projections/index.ts @@ -2,7 +2,5 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. export { ProjectionId } from './ProjectionId'; -export { projection, getProjectionMetadata, isProjection } from './projection'; -export type { ProjectionMetadata } from './projection'; -export { modelBoundProjection, getModelBoundProjectionMetadata, isModelBoundProjection } from './modelBoundProjection'; -export type { ModelBoundProjectionMetadata } from './modelBoundProjection'; +export * from './declarative'; +export * from './modelBound'; diff --git a/Source/Projections/modelBound/index.ts b/Source/Projections/modelBound/index.ts new file mode 100644 index 0000000..0a71223 --- /dev/null +++ b/Source/Projections/modelBound/index.ts @@ -0,0 +1,5 @@ +// 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'; diff --git a/Source/Projections/modelBoundProjection.ts b/Source/Projections/modelBound/modelBound.ts similarity index 57% rename from Source/Projections/modelBoundProjection.ts rename to Source/Projections/modelBound/modelBound.ts index e4135a1..37de580 100644 --- a/Source/Projections/modelBoundProjection.ts +++ b/Source/Projections/modelBound/modelBound.ts @@ -3,16 +3,16 @@ import 'reflect-metadata'; import { Constructor } from '@cratis/fundamentals'; -import { ProjectionId } from './ProjectionId'; -import { DecoratorType, TypeDiscoverer } from '../types'; +import { ProjectionId } from '../ProjectionId'; +import { DecoratorType, TypeDiscoverer } from '../../types'; /** Metadata key used to store model-bound projection information on a class. */ -const MODEL_BOUND_PROJECTION_METADATA_KEY = 'chronicle:modelBoundProjection'; +const MODEL_BOUND_METADATA_KEY = 'chronicle:modelBoundProjection'; /** * Metadata stored on a model-bound projection class. */ -export interface ModelBoundProjectionMetadata { +export interface ModelBoundMetadata { /** The unique identifier for the projection. */ readonly id: ProjectionId; @@ -26,12 +26,12 @@ export interface ModelBoundProjectionMetadata { * @param eventSequenceId - Optional explicit event sequence identifier. * @returns A class decorator. */ -export function modelBoundProjection(id: string = '', eventSequenceId?: string): ClassDecorator { +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: ModelBoundProjectionMetadata = { id: projectionId, eventSequenceId }; - Reflect.defineMetadata(MODEL_BOUND_PROJECTION_METADATA_KEY, metadata, target); + const metadata: ModelBoundMetadata = { id: projectionId, eventSequenceId }; + Reflect.defineMetadata(MODEL_BOUND_METADATA_KEY, metadata, target); TypeDiscoverer.default.register( DecoratorType.ModelBoundProjection, constructor as Constructor, @@ -41,19 +41,19 @@ export function modelBoundProjection(id: string = '', eventSequenceId?: string): } /** - * Gets the {@link ModelBoundProjectionMetadata} associated with a class decorated with {@link modelBoundProjection}. + * 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 getModelBoundProjectionMetadata(target: Function): ModelBoundProjectionMetadata | undefined { - return Reflect.getMetadata(MODEL_BOUND_PROJECTION_METADATA_KEY, target); +export function getModelBoundMetadata(target: Function): ModelBoundMetadata | undefined { + return Reflect.getMetadata(MODEL_BOUND_METADATA_KEY, target); } /** - * Checks whether a class has been decorated with {@link modelBoundProjection}. + * 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 projection decorator; false otherwise. + * @returns True if the class has a model-bound decorator; false otherwise. */ -export function isModelBoundProjection(target: Function): boolean { - return Reflect.hasMetadata(MODEL_BOUND_PROJECTION_METADATA_KEY, target); +export function isModelBound(target: Function): boolean { + return Reflect.hasMetadata(MODEL_BOUND_METADATA_KEY, target); } diff --git a/Source/types/DecoratorType.ts b/Source/types/DecoratorType.ts index e025930..6fd6926 100644 --- a/Source/types/DecoratorType.ts +++ b/Source/types/DecoratorType.ts @@ -23,6 +23,6 @@ export enum DecoratorType { /** Declarative projection artifacts discovered through the projection decorator. */ Projection = 'projection', - /** Model-bound projection artifacts discovered through the modelBoundProjection decorator. */ + /** Model-bound projection artifacts discovered through the modelBound decorator. */ ModelBoundProjection = 'modelBoundProjection' } diff --git a/Source/types/TypeIntrospector.ts b/Source/types/TypeIntrospector.ts index f9a56ce..1d96a82 100644 --- a/Source/types/TypeIntrospector.ts +++ b/Source/types/TypeIntrospector.ts @@ -47,6 +47,17 @@ export class TypeIntrospector { members.set(property, runtimeType); } + const discoveredFieldNames = this.getClassFieldNames(target); + const inferredRuntimeTypes = this.getRuntimeTypesFromInstance(target); + for (const fieldName of discoveredFieldNames) { + if (members.has(fieldName)) { + continue; + } + + const runtimeTypeFromMetadata = Reflect.getMetadata('design:type', target.prototype, fieldName) as Function | undefined; + members.set(fieldName, runtimeTypeFromMetadata ?? inferredRuntimeTypes.get(fieldName)); + } + const constructorParameterNames = this.getConstructorParameterNames(target); const constructorParameterTypes = Reflect.getMetadata('design:paramtypes', target) as Function[] | undefined ?? []; @@ -62,6 +73,75 @@ export class TypeIntrospector { return members; } + private static getClassFieldNames(target: Function): string[] { + const source = target.toString(); + const bodyStart = source.indexOf('{'); + const bodyEnd = source.lastIndexOf('}'); + if (bodyStart < 0 || bodyEnd <= bodyStart) { + return []; + } + + const body = source.substring(bodyStart + 1, bodyEnd); + const fieldNames: string[] = []; + for (const rawLine of body.split('\n')) { + const line = rawLine.trim(); + if (line.length === 0 || + line.startsWith('//') || + line.startsWith('*') || + line.startsWith('constructor(') || + line.startsWith('get ') || + line.startsWith('set ') || + line.startsWith('async ') || + line.startsWith('static ') || + line.includes('(')) { + continue; + } + + const match = line.match(/^([A-Za-z_$][\w$]*)\s*(=|;)/); + if (!match) { + continue; + } + + const fieldName = match[1]; + if (!fieldNames.includes(fieldName)) { + fieldNames.push(fieldName); + } + } + return fieldNames; + } + + private static getRuntimeTypesFromInstance(target: Function): Map { + const runtimeTypes = new Map(); + const instance = this.tryCreateInstance(target); + if (!instance) { + return runtimeTypes; + } + + for (const key of Object.keys(instance)) { + const value = (instance as Record)[key]; + if (value === null || value === undefined) { + runtimeTypes.set(key, undefined); + } else { + runtimeTypes.set(key, value.constructor as Function | undefined); + } + } + + return runtimeTypes; + } + + private static tryCreateInstance(target: Function): Record | undefined { + try { + const created = Reflect.construct(target, []) as unknown; + if (created && typeof created === 'object') { + return created as Record; + } + } catch { + // Intentionally ignored when the type requires constructor arguments. + } + + return undefined; + } + private static getConstructorParameterNames(target: Function): string[] { const source = target.toString(); const constructorKeyword = 'constructor('; diff --git a/TestApps/NodeJS/index.ts b/TestApps/NodeJS/index.ts index c0b0407..34e59c1 100644 --- a/TestApps/NodeJS/index.ts +++ b/TestApps/NodeJS/index.ts @@ -6,6 +6,10 @@ import { ChronicleClient, ChronicleOptions, eventType, + getEventTypeJsonSchemaFor, + readModel, + projection, + modelBound, reactor, EventContext } from '@cratis/chronicle'; @@ -34,6 +38,20 @@ class EmployeeMoved { constructor(readonly newCity: string) {} } +@readModel('employee-read-model') +class EmployeeReadModel { + firstName = ''; + lastName = ''; + title = ''; + city = ''; +} + +@projection('employees-declarative') +class EmployeesDeclarativeProjection {} + +@modelBound('employees-model-bound') +class EmployeesModelBoundProjection {} + // --- Reactor --- /** Reacts to employee events by logging them to the console. */ @@ -66,6 +84,8 @@ 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...'); From 857871516fc603ef89f60ca626ae52532d290b6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 20:19:43 +0000 Subject: [PATCH 07/13] Refine modelBound metadata and simplify introspection changes Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/d34b2733-db80-4dfd-baf3-a9179b84bf56 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/Projections/modelBound/modelBound.ts | 2 +- Source/types/TypeIntrospector.ts | 80 --------------------- TestApps/NodeJS/index.ts | 10 +-- 3 files changed, 7 insertions(+), 85 deletions(-) diff --git a/Source/Projections/modelBound/modelBound.ts b/Source/Projections/modelBound/modelBound.ts index 37de580..b74b3c7 100644 --- a/Source/Projections/modelBound/modelBound.ts +++ b/Source/Projections/modelBound/modelBound.ts @@ -7,7 +7,7 @@ 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:modelBoundProjection'; +const MODEL_BOUND_METADATA_KEY = 'chronicle:modelBound'; /** * Metadata stored on a model-bound projection class. diff --git a/Source/types/TypeIntrospector.ts b/Source/types/TypeIntrospector.ts index 1d96a82..f9a56ce 100644 --- a/Source/types/TypeIntrospector.ts +++ b/Source/types/TypeIntrospector.ts @@ -47,17 +47,6 @@ export class TypeIntrospector { members.set(property, runtimeType); } - const discoveredFieldNames = this.getClassFieldNames(target); - const inferredRuntimeTypes = this.getRuntimeTypesFromInstance(target); - for (const fieldName of discoveredFieldNames) { - if (members.has(fieldName)) { - continue; - } - - const runtimeTypeFromMetadata = Reflect.getMetadata('design:type', target.prototype, fieldName) as Function | undefined; - members.set(fieldName, runtimeTypeFromMetadata ?? inferredRuntimeTypes.get(fieldName)); - } - const constructorParameterNames = this.getConstructorParameterNames(target); const constructorParameterTypes = Reflect.getMetadata('design:paramtypes', target) as Function[] | undefined ?? []; @@ -73,75 +62,6 @@ export class TypeIntrospector { return members; } - private static getClassFieldNames(target: Function): string[] { - const source = target.toString(); - const bodyStart = source.indexOf('{'); - const bodyEnd = source.lastIndexOf('}'); - if (bodyStart < 0 || bodyEnd <= bodyStart) { - return []; - } - - const body = source.substring(bodyStart + 1, bodyEnd); - const fieldNames: string[] = []; - for (const rawLine of body.split('\n')) { - const line = rawLine.trim(); - if (line.length === 0 || - line.startsWith('//') || - line.startsWith('*') || - line.startsWith('constructor(') || - line.startsWith('get ') || - line.startsWith('set ') || - line.startsWith('async ') || - line.startsWith('static ') || - line.includes('(')) { - continue; - } - - const match = line.match(/^([A-Za-z_$][\w$]*)\s*(=|;)/); - if (!match) { - continue; - } - - const fieldName = match[1]; - if (!fieldNames.includes(fieldName)) { - fieldNames.push(fieldName); - } - } - return fieldNames; - } - - private static getRuntimeTypesFromInstance(target: Function): Map { - const runtimeTypes = new Map(); - const instance = this.tryCreateInstance(target); - if (!instance) { - return runtimeTypes; - } - - for (const key of Object.keys(instance)) { - const value = (instance as Record)[key]; - if (value === null || value === undefined) { - runtimeTypes.set(key, undefined); - } else { - runtimeTypes.set(key, value.constructor as Function | undefined); - } - } - - return runtimeTypes; - } - - private static tryCreateInstance(target: Function): Record | undefined { - try { - const created = Reflect.construct(target, []) as unknown; - if (created && typeof created === 'object') { - return created as Record; - } - } catch { - // Intentionally ignored when the type requires constructor arguments. - } - - return undefined; - } - private static getConstructorParameterNames(target: Function): string[] { const source = target.toString(); const constructorKeyword = 'constructor('; diff --git a/TestApps/NodeJS/index.ts b/TestApps/NodeJS/index.ts index 34e59c1..d168d61 100644 --- a/TestApps/NodeJS/index.ts +++ b/TestApps/NodeJS/index.ts @@ -40,10 +40,12 @@ class EmployeeMoved { @readModel('employee-read-model') class EmployeeReadModel { - firstName = ''; - lastName = ''; - title = ''; - city = ''; + constructor( + readonly firstName: string, + readonly lastName: string, + readonly title: string, + readonly city: string + ) {} } @projection('employees-declarative') From b3de589062d97003f881ff75d17f5121ee2dc616 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 21:27:44 +0000 Subject: [PATCH 08/13] Add full declarative and model-bound projection APIs Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/a357eb10-359d-41f3-8d2f-3c62c164c7d7 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/Projections/declarative/IAddBuilder.ts | 18 +++ .../declarative/IAddChildBuilder.ts | 25 ++++ .../Projections/declarative/IAllSetBuilder.ts | 31 ++++ .../declarative/IChildrenBuilder.ts | 31 ++++ .../declarative/ICompositeKeyBuilder.ts | 22 +++ .../Projections/declarative/IFromBuilder.ts | 12 ++ .../declarative/IFromEveryBuilder.ts | 24 ++++ .../Projections/declarative/IJoinBuilder.ts | 20 +++ .../Projections/declarative/INestedBuilder.ts | 19 +++ .../declarative/IProjectionBuilder.ts | 94 +++++++++++++ .../declarative/IProjectionBuilderFor.ts | 38 +++++ .../Projections/declarative/IProjectionFor.ts | 18 +++ .../IReadModelPropertiesBuilder.ts | 132 ++++++++++++++++++ .../declarative/IRemovedWithBuilder.ts | 25 ++++ .../declarative/IRemovedWithJoinBuilder.ts | 25 ++++ Source/Projections/declarative/ISetBuilder.ts | 38 +++++ .../declarative/ISubtractBuilder.ts | 18 +++ Source/Projections/declarative/index.ts | 17 +++ Source/Projections/modelBound/addFrom.ts | 39 ++++++ Source/Projections/modelBound/childrenFrom.ts | 50 +++++++ Source/Projections/modelBound/clearWith.ts | 52 +++++++ Source/Projections/modelBound/count.ts | 39 ++++++ Source/Projections/modelBound/decrement.ts | 39 ++++++ Source/Projections/modelBound/fromEvent.ts | 54 +++++++ Source/Projections/modelBound/fromEvery.ts | 37 +++++ Source/Projections/modelBound/increment.ts | 39 ++++++ Source/Projections/modelBound/index.ts | 32 +++++ Source/Projections/modelBound/join.ts | 42 ++++++ Source/Projections/modelBound/nested.ts | 26 ++++ .../Projections/modelBound/notRewindable.ts | 24 ++++ Source/Projections/modelBound/removedWith.ts | 58 ++++++++ .../Projections/modelBound/removedWithJoin.ts | 53 +++++++ Source/Projections/modelBound/setFrom.ts | 39 ++++++ .../Projections/modelBound/setFromContext.ts | 39 ++++++ Source/Projections/modelBound/setValue.ts | 39 ++++++ Source/Projections/modelBound/subtractFrom.ts | 39 ++++++ TestApps/NodeJS/index.ts | 105 +++++++++++++- TestApps/NodeJS/package-lock.json | 1 + 38 files changed, 1449 insertions(+), 4 deletions(-) create mode 100644 Source/Projections/declarative/IAddBuilder.ts create mode 100644 Source/Projections/declarative/IAddChildBuilder.ts create mode 100644 Source/Projections/declarative/IAllSetBuilder.ts create mode 100644 Source/Projections/declarative/IChildrenBuilder.ts create mode 100644 Source/Projections/declarative/ICompositeKeyBuilder.ts create mode 100644 Source/Projections/declarative/IFromBuilder.ts create mode 100644 Source/Projections/declarative/IFromEveryBuilder.ts create mode 100644 Source/Projections/declarative/IJoinBuilder.ts create mode 100644 Source/Projections/declarative/INestedBuilder.ts create mode 100644 Source/Projections/declarative/IProjectionBuilder.ts create mode 100644 Source/Projections/declarative/IProjectionBuilderFor.ts create mode 100644 Source/Projections/declarative/IProjectionFor.ts create mode 100644 Source/Projections/declarative/IReadModelPropertiesBuilder.ts create mode 100644 Source/Projections/declarative/IRemovedWithBuilder.ts create mode 100644 Source/Projections/declarative/IRemovedWithJoinBuilder.ts create mode 100644 Source/Projections/declarative/ISetBuilder.ts create mode 100644 Source/Projections/declarative/ISubtractBuilder.ts create mode 100644 Source/Projections/modelBound/addFrom.ts create mode 100644 Source/Projections/modelBound/childrenFrom.ts create mode 100644 Source/Projections/modelBound/clearWith.ts create mode 100644 Source/Projections/modelBound/count.ts create mode 100644 Source/Projections/modelBound/decrement.ts create mode 100644 Source/Projections/modelBound/fromEvent.ts create mode 100644 Source/Projections/modelBound/fromEvery.ts create mode 100644 Source/Projections/modelBound/increment.ts create mode 100644 Source/Projections/modelBound/join.ts create mode 100644 Source/Projections/modelBound/nested.ts create mode 100644 Source/Projections/modelBound/notRewindable.ts create mode 100644 Source/Projections/modelBound/removedWith.ts create mode 100644 Source/Projections/modelBound/removedWithJoin.ts create mode 100644 Source/Projections/modelBound/setFrom.ts create mode 100644 Source/Projections/modelBound/setFromContext.ts create mode 100644 Source/Projections/modelBound/setValue.ts create mode 100644 Source/Projections/modelBound/subtractFrom.ts 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 index 8e00bbd..fb3417b 100644 --- a/Source/Projections/declarative/index.ts +++ b/Source/Projections/declarative/index.ts @@ -3,3 +3,20 @@ 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/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..7ccd376 --- /dev/null +++ b/Source/Projections/modelBound/fromEvent.ts @@ -0,0 +1,54 @@ +// 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'; + +/** 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; +} + +/** Metadata stored by the fromEvent decorator on a class. */ +export interface FromEventMetadata extends FromEventOptions { + /** The event constructor associated with this annotation. */ + readonly eventType: Function; +} + +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 index 0a71223..edc6c19 100644 --- a/Source/Projections/modelBound/index.ts +++ b/Source/Projections/modelBound/index.ts @@ -3,3 +3,35 @@ export { modelBound, getModelBoundMetadata, isModelBound } from './modelBound'; export type { ModelBoundMetadata } from './modelBound'; +export { fromEvent, getFromEventMetadata, hasFromEventMetadata } from './fromEvent'; +export type { FromEventOptions, FromEventMetadata } from './fromEvent'; +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/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/TestApps/NodeJS/index.ts b/TestApps/NodeJS/index.ts index d168d61..f56abbc 100644 --- a/TestApps/NodeJS/index.ts +++ b/TestApps/NodeJS/index.ts @@ -11,7 +11,16 @@ import { projection, modelBound, reactor, - EventContext + EventContext, + IProjectionFor, + IProjectionBuilderFor, + fromEvent, + setFrom, + setFromContext, + increment, + childrenFrom, + notRewindable, + removedWith } from '@cratis/chronicle'; // --- Event type definitions --- @@ -38,21 +47,109 @@ class EmployeeMoved { constructor(readonly newCity: string) {} } +/** Represents an employee leaving the organization. */ +@eventType('dd0fdd58-dfe4-4bf7-d88b-049814f3f249', 1) +class EmployeeLeft { + constructor(readonly reason: string) {} +} + +/** Read model representing the current state of an employee. */ @readModel('employee-read-model') class EmployeeReadModel { constructor( readonly firstName: string, readonly lastName: string, readonly title: string, - readonly city: string + readonly city: string, + readonly promotionCount: number ) {} } +// --- Declarative projection --- + +/** + * Declarative projection that builds an EmployeeReadModel from employee domain events. + * Uses the fluent builder API to describe all property mappings explicitly. + */ @projection('employees-declarative') -class EmployeesDeclarativeProjection {} +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(); + } +} + +// --- Model-bound projection --- +/** + * Model-bound projection that maps employee domain events directly onto properties via decorators. + * This style keeps all projection logic co-located with the read model shape. + */ @modelBound('employees-model-bound') -class EmployeesModelBoundProjection {} +@notRewindable +@removedWith(EmployeeLeft) +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 with children --- + +/** Represents a department assignment history entry. */ +class DepartmentAssignment { + constructor( + readonly department: string, + readonly assignedOn: string + ) {} +} + +/** + * Model-bound projection that includes a child collection, demonstrating the childrenFrom decorator. + */ +@fromEvent(EmployeeHired) +@modelBound('employees-with-history') +class EmployeeWithHistoryProjection { + @setFrom(EmployeeHired) + firstName: string = ''; + + @setFrom(EmployeeHired) + lastName: string = ''; + + @childrenFrom(EmployeePromoted, 'promotionCount', 'department') + promotionHistory: DepartmentAssignment[] = []; +} // --- Reactor --- diff --git a/TestApps/NodeJS/package-lock.json b/TestApps/NodeJS/package-lock.json index e43a18e..01ab0a1 100644 --- a/TestApps/NodeJS/package-lock.json +++ b/TestApps/NodeJS/package-lock.json @@ -23,6 +23,7 @@ "license": "MIT", "dependencies": { "@cratis/chronicle.contracts": "^15.24.0", + "@cratis/fundamentals": "^7.8.2", "reflect-metadata": "^0.2.2" }, "devDependencies": { From 6a09d9d0bb466b6a0c28aa3191ad55a22e88d079 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 05:00:49 +0000 Subject: [PATCH 09/13] Move constraints to Events/Constraints, implement IConstraint API, split TestApp into files Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/69522283-d84e-48ea-950a-7908a80fbfda Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/Constraints/index.ts | 5 +- Source/Events/Constraints/ConstraintId.ts | 14 ++ Source/Events/Constraints/IConstraint.ts | 18 ++ .../Events/Constraints/IConstraintBuilder.ts | 44 +++++ .../Constraints/IUniqueConstraintBuilder.ts | 52 +++++ Source/Events/Constraints/constraint.ts | 56 ++++++ Source/Events/Constraints/index.ts | 9 + Source/Events/index.ts | 1 + TestApps/NodeJS/Constraints.ts | 25 +++ TestApps/NodeJS/Events.ts | 32 +++ TestApps/NodeJS/ModelBoundProjections.ts | 58 ++++++ TestApps/NodeJS/Projections.ts | 34 ++++ TestApps/NodeJS/Reactors.ts | 27 +++ TestApps/NodeJS/ReadModels.ts | 26 +++ TestApps/NodeJS/index.ts | 182 +----------------- 15 files changed, 408 insertions(+), 175 deletions(-) create mode 100644 Source/Events/Constraints/ConstraintId.ts create mode 100644 Source/Events/Constraints/IConstraint.ts create mode 100644 Source/Events/Constraints/IConstraintBuilder.ts create mode 100644 Source/Events/Constraints/IUniqueConstraintBuilder.ts create mode 100644 Source/Events/Constraints/constraint.ts create mode 100644 Source/Events/Constraints/index.ts create mode 100644 TestApps/NodeJS/Constraints.ts create mode 100644 TestApps/NodeJS/Events.ts create mode 100644 TestApps/NodeJS/ModelBoundProjections.ts create mode 100644 TestApps/NodeJS/Projections.ts create mode 100644 TestApps/NodeJS/Reactors.ts create mode 100644 TestApps/NodeJS/ReadModels.ts diff --git a/Source/Constraints/index.ts b/Source/Constraints/index.ts index e2b9209..2a9cd0d 100644 --- a/Source/Constraints/index.ts +++ b/Source/Constraints/index.ts @@ -1,6 +1,5 @@ // 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 { constraint, getConstraintMetadata, isConstraint } from './constraint'; -export type { ConstraintMetadata } from './constraint'; +// Constraints have moved to Events/Constraints. Re-exporting for backward compatibility. +export * from '../Events/Constraints'; 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/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/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/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..0e73735 --- /dev/null +++ b/Source/Events/Constraints/index.ts @@ -0,0 +1,9 @@ +// 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'; diff --git a/Source/Events/index.ts b/Source/Events/index.ts index 7118c96..b2a4b5f 100644 --- a/Source/Events/index.ts +++ b/Source/Events/index.ts @@ -8,3 +8,4 @@ export { eventType, getEventTypeFor, hasEventType, getEventTypeMetadata, getEven export type { EventTypeMetadata } from './eventTypeDecorator'; export type { EventContext, CausationEntry } from './EventContext'; export type { AppendedEvent } from './AppendedEvent'; +export * from './Constraints'; 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 f56abbc..0800252 100644 --- a/TestApps/NodeJS/index.ts +++ b/TestApps/NodeJS/index.ts @@ -2,180 +2,17 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import 'reflect-metadata'; -import { - ChronicleClient, - ChronicleOptions, - eventType, - getEventTypeJsonSchemaFor, - readModel, - projection, - modelBound, - reactor, - EventContext, - IProjectionFor, - IProjectionBuilderFor, - fromEvent, - setFrom, - setFromContext, - increment, - childrenFrom, - notRewindable, - removedWith -} 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) {} -} - -/** Represents an employee leaving the organization. */ -@eventType('dd0fdd58-dfe4-4bf7-d88b-049814f3f249', 1) -class EmployeeLeft { - constructor(readonly reason: string) {} -} - -/** Read model representing the current state of an employee. */ -@readModel('employee-read-model') -class EmployeeReadModel { - constructor( - readonly firstName: string, - readonly lastName: string, - readonly title: string, - readonly city: string, - readonly promotionCount: number - ) {} -} - -// --- Declarative projection --- - -/** - * Declarative projection that builds an EmployeeReadModel from employee domain events. - * Uses the fluent builder API to describe all property mappings explicitly. - */ -@projection('employees-declarative') -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(); - } -} - -// --- Model-bound projection --- - -/** - * Model-bound projection that maps employee domain events directly onto properties via decorators. - * This style keeps all projection logic co-located with the read model shape. - */ -@modelBound('employees-model-bound') -@notRewindable -@removedWith(EmployeeLeft) -class EmployeesModelBoundProjection { - @setFrom(EmployeeHired) - firstName: string = ''; - - @setFrom(EmployeeHired) - lastName: string = ''; - - @setFrom(EmployeeHired) - @setFrom(EmployeePromoted, 'newTitle') - title: string = ''; +import { ChronicleClient, ChronicleOptions, getEventTypeJsonSchemaFor } from '@cratis/chronicle'; - @setFrom(EmployeeMoved, 'newCity') - city: string = ''; +// Import all artifacts so their decorators register them with the discoverer. +import './Events'; +import './ReadModels'; +import './Projections'; +import './ModelBoundProjections'; +import './Constraints'; +import './Reactors'; - @increment(EmployeePromoted) - promotionCount: number = 0; - - @setFromContext(EmployeeHired, 'occurred') - lastUpdated: string = ''; -} - -// --- Model-bound projection with children --- - -/** Represents a department assignment history entry. */ -class DepartmentAssignment { - constructor( - readonly department: string, - readonly assignedOn: string - ) {} -} - -/** - * Model-bound projection that includes a child collection, demonstrating the childrenFrom decorator. - */ -@fromEvent(EmployeeHired) -@modelBound('employees-with-history') -class EmployeeWithHistoryProjection { - @setFrom(EmployeeHired) - firstName: string = ''; - - @setFrom(EmployeeHired) - lastName: string = ''; - - @childrenFrom(EmployeePromoted, 'promotionCount', 'department') - promotionHistory: DepartmentAssignment[] = []; -} - -// --- 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})`); - } - - /** - * 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'; @@ -183,6 +20,7 @@ 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(', ')}`); From fcc266b7b70b6c723f898d08b13bff6f2e633032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 05:53:34 +0000 Subject: [PATCH 10/13] Remove root Constraints folder, add per-module registration interfaces and implementations Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/e509efef-82e9-4593-a21c-41d503004484 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/ChronicleClient.ts | 1 + Source/Constraints/ConstraintId.ts | 14 -- Source/Constraints/constraint.ts | 55 ---- Source/Constraints/index.ts | 5 - Source/EventStore.ts | 39 +++ Source/Events/Constraints/Constraints.ts | 290 ++++++++++++++++++++++ Source/Events/Constraints/IConstraints.ts | 28 +++ Source/Events/Constraints/index.ts | 2 + Source/Events/EventTypes.ts | 101 ++++++++ Source/Events/IEventTypes.ts | 39 +++ Source/Events/index.ts | 2 + Source/IEventStore.ts | 20 ++ Source/Projections/IProjections.ts | 19 ++ Source/Projections/Projections.ts | 69 +++++ Source/Projections/index.ts | 2 + Source/Reactors/IReactors.ts | 19 ++ Source/Reactors/Reactors.ts | 64 +++++ Source/Reactors/index.ts | 2 + Source/Reducers/IReducers.ts | 19 ++ Source/Reducers/Reducers.ts | 64 +++++ Source/Reducers/index.ts | 2 + Source/index.ts | 1 - 22 files changed, 782 insertions(+), 75 deletions(-) delete mode 100644 Source/Constraints/ConstraintId.ts delete mode 100644 Source/Constraints/constraint.ts delete mode 100644 Source/Constraints/index.ts create mode 100644 Source/Events/Constraints/Constraints.ts create mode 100644 Source/Events/Constraints/IConstraints.ts create mode 100644 Source/Events/EventTypes.ts create mode 100644 Source/Events/IEventTypes.ts create mode 100644 Source/Projections/IProjections.ts create mode 100644 Source/Projections/Projections.ts create mode 100644 Source/Reactors/IReactors.ts create mode 100644 Source/Reactors/Reactors.ts create mode 100644 Source/Reducers/IReducers.ts create mode 100644 Source/Reducers/Reducers.ts diff --git a/Source/ChronicleClient.ts b/Source/ChronicleClient.ts index 2e6fda9..2fe06fc 100644 --- a/Source/ChronicleClient.ts +++ b/Source/ChronicleClient.ts @@ -60,6 +60,7 @@ export class ChronicleClient implements IChronicleClient { ); const store = new EventStore(storeName, namespaceName, this._connection); + await store.registerArtifacts(); this._stores.set(key, store); return store; } diff --git a/Source/Constraints/ConstraintId.ts b/Source/Constraints/ConstraintId.ts deleted file mode 100644 index 86f38ee..0000000 --- a/Source/Constraints/ConstraintId.ts +++ /dev/null @@ -1,14 +0,0 @@ -// 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/Constraints/constraint.ts b/Source/Constraints/constraint.ts deleted file mode 100644 index 499360e..0000000 --- a/Source/Constraints/constraint.ts +++ /dev/null @@ -1,55 +0,0 @@ -// 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. - * @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/Constraints/index.ts b/Source/Constraints/index.ts deleted file mode 100644 index 2a9cd0d..0000000 --- a/Source/Constraints/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -// Constraints have moved to Events/Constraints. Re-exporting for backward compatibility. -export * from '../Events/Constraints'; diff --git a/Source/EventStore.ts b/Source/EventStore.ts index 68d785e..0e77516 100644 --- a/Source/EventStore.ts +++ b/Source/EventStore.ts @@ -11,6 +11,17 @@ import { Grpc } from './Grpc'; import { EventStoreName } from './EventStoreName'; import { EventStoreNamespaceName } from './EventStoreNamespaceName'; import { IEventStore } from './IEventStore'; +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 @@ -18,6 +29,12 @@ import { IEventStore } from './IEventStore'; */ 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( @@ -27,6 +44,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(name.value, _connection, artifacts); + this.reactors = new Reactors(name.value, namespace.value, _connection, artifacts); + this.reducers = new Reducers(name.value, namespace.value, _connection, 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/Constraints/Constraints.ts b/Source/Events/Constraints/Constraints.ts new file mode 100644 index 0000000..00d4bb8 --- /dev/null +++ b/Source/Events/Constraints/Constraints.ts @@ -0,0 +1,290 @@ +// 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 { ChronicleConnection, ConstraintType } from '@cratis/chronicle.contracts'; +import { IClientArtifactsProvider } from '../../artifacts'; +import { getEventTypeFor } from '../eventTypeDecorator'; +import { Grpc } from '../../Grpc'; +import { ConstraintId } from './ConstraintId'; +import { IConstraint } from './IConstraint'; +import { IConstraintBuilder } from './IConstraintBuilder'; +import { IConstraints } from './IConstraints'; +import { IUniqueConstraintBuilder } from './IUniqueConstraintBuilder'; +import { getConstraintMetadata } from './constraint'; + +/** 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. */ +interface UniqueConstraintEventEntry { + eventTypeId: string; + properties: string[]; +} + +/** Captured definition of a unique constraint. */ +interface UniqueConstraintCapture { + name?: string; + eventDefinitions: UniqueConstraintEventEntry[]; + ignoreCasing: boolean; + removedWithEventTypeId?: string; + message?: string; +} + +/** Represents the captured definition of a unique event type constraint. */ +interface UniqueEventTypeCapture { + eventTypeId: string; + message?: string; + name?: string; +} + +/** Represents the captured scope for a constraint. */ +interface ConstraintScopeCapture { + perEventSourceType: boolean; + perEventStreamType: boolean; + perEventStreamId: boolean; +} + +/** Represents the full captured definition of a constraint. */ +interface ConstraintCapture { + name: string; + scope: ConstraintScopeCapture; + uniqueConstraint?: UniqueConstraintCapture; + uniqueEventType?: UniqueEventTypeCapture; +} + +/** Implementation of {@link IUniqueConstraintBuilder} that captures the unique constraint definition. */ +class UniqueConstraintBuilderImpl 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): UniqueConstraintBuilderImpl { + this._currentEventTypeId = eventTypeId; + return this; + } +} + +/** Implementation of {@link IConstraintBuilder} that captures the constraint definition. */ +class ConstraintBuilderImpl implements IConstraintBuilder { + readonly capture: ConstraintCapture; + private _uniqueBuilder?: UniqueConstraintBuilderImpl; + + 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 UniqueConstraintBuilderImpl(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; + } +} + +/** + * Implements {@link IConstraints}, managing 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 ConstraintBuilderImpl(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/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/index.ts b/Source/Events/Constraints/index.ts index 0e73735..3c0a843 100644 --- a/Source/Events/Constraints/index.ts +++ b/Source/Events/Constraints/index.ts @@ -7,3 +7,5 @@ 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'; 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/index.ts b/Source/Events/index.ts index b2a4b5f..1b582e0 100644 --- a/Source/Events/index.ts +++ b/Source/Events/index.ts @@ -8,4 +8,6 @@ export { eventType, getEventTypeFor, hasEventType, getEventTypeMetadata, getEven export type { EventTypeMetadata } from './eventTypeDecorator'; export type { EventContext, CausationEntry } from './EventContext'; 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/Projections.ts b/Source/Projections/Projections.ts new file mode 100644 index 0000000..b70b8dc --- /dev/null +++ b/Source/Projections/Projections.ts @@ -0,0 +1,69 @@ +// 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 { 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(); + + // Reserved for gRPC registration once projection definition serialization is implemented. + private readonly _eventStore: string; + private readonly _connection: ChronicleConnection; + + /** + * Creates a new {@link Projections} instance. + * @param eventStore - The name of the event store these projections belong to. + * @param connection - The connection used to communicate with the Kernel. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor( + eventStore: string, + connection: ChronicleConnection, + private readonly _clientArtifacts: IClientArtifactsProvider + ) { + this._eventStore = eventStore; + this._connection = connection; + } + + /** @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 + // implemented as the projection engine support matures. + void this._eventStore; + void this._connection; + } +} diff --git a/Source/Projections/index.ts b/Source/Projections/index.ts index 1bf13d6..22355fc 100644 --- a/Source/Projections/index.ts +++ b/Source/Projections/index.ts @@ -2,5 +2,7 @@ // 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/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..2b565b1 --- /dev/null +++ b/Source/Reactors/Reactors.ts @@ -0,0 +1,64 @@ +// 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 { 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(); + + // Reserved for streaming gRPC registration once observation infrastructure is implemented. + private readonly _eventStore: string; + private readonly _namespace: string; + private readonly _connection: ChronicleConnection; + + /** + * Creates a new {@link Reactors} instance. + * @param eventStore - The name of the event store these reactors belong to. + * @param namespace - The namespace within the event store. + * @param connection - The connection used to communicate with the Kernel. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor( + eventStore: string, + namespace: string, + connection: ChronicleConnection, + private readonly _clientArtifacts: IClientArtifactsProvider + ) { + this._eventStore = eventStore; + this._namespace = namespace; + this._connection = connection; + } + + /** @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 registration will be implemented as the observation + // infrastructure matures in the TypeScript client. + void this._eventStore; + void this._namespace; + void this._connection; + } +} 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/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..d241760 --- /dev/null +++ b/Source/Reducers/Reducers.ts @@ -0,0 +1,64 @@ +// 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 { 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(); + + // Reserved for streaming gRPC registration once observation infrastructure is implemented. + private readonly _eventStore: string; + private readonly _namespace: string; + private readonly _connection: ChronicleConnection; + + /** + * Creates a new {@link Reducers} instance. + * @param eventStore - The name of the event store these reducers belong to. + * @param namespace - The namespace within the event store. + * @param connection - The connection used to communicate with the Kernel. + * @param _clientArtifacts - Provider for discovered client artifact types. + */ + constructor( + eventStore: string, + namespace: string, + connection: ChronicleConnection, + private readonly _clientArtifacts: IClientArtifactsProvider + ) { + this._eventStore = eventStore; + this._namespace = namespace; + this._connection = connection; + } + + /** @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 registration will be implemented as the observation + // infrastructure matures in the TypeScript client. + void this._eventStore; + void this._namespace; + void this._connection; + } +} 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/index.ts b/Source/index.ts index ad31ef3..4e87562 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -16,7 +16,6 @@ export * from './EventSequences'; export * from './Reactors'; export * from './Reducers'; export * from './ReadModels'; -export * from './Constraints'; export * from './Projections'; export * from './Observation'; export * from './Schemas'; From e6125ff4da31852256f0a2671592658e21686197 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 05:56:27 +0000 Subject: [PATCH 11/13] Clean up stub registration classes - remove unused fields and simplify constructors Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/e509efef-82e9-4593-a21c-41d503004484 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/EventStore.ts | 6 +++--- Source/Projections/Projections.ts | 20 ++------------------ Source/Reactors/Reactors.ts | 27 +++------------------------ Source/Reducers/Reducers.ts | 27 +++------------------------ 4 files changed, 11 insertions(+), 69 deletions(-) diff --git a/Source/EventStore.ts b/Source/EventStore.ts index 0e77516..da114a7 100644 --- a/Source/EventStore.ts +++ b/Source/EventStore.ts @@ -48,9 +48,9 @@ export class EventStore implements IEventStore { const artifacts = DefaultClientArtifactsProvider.default; this.eventTypes = new EventTypes(name.value, _connection, artifacts); this.constraints = new Constraints(name.value, _connection, artifacts); - this.projections = new Projections(name.value, _connection, artifacts); - this.reactors = new Reactors(name.value, namespace.value, _connection, artifacts); - this.reducers = new Reducers(name.value, namespace.value, _connection, artifacts); + this.projections = new Projections(artifacts); + this.reactors = new Reactors(artifacts); + this.reducers = new Reducers(artifacts); } /** diff --git a/Source/Projections/Projections.ts b/Source/Projections/Projections.ts index b70b8dc..d1ddfee 100644 --- a/Source/Projections/Projections.ts +++ b/Source/Projections/Projections.ts @@ -2,7 +2,6 @@ // 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 { IProjections } from './IProjections'; import { getProjectionMetadata } from './declarative/projection'; @@ -16,24 +15,11 @@ export class Projections implements IProjections { private readonly _declarative = new Map(); private readonly _modelBound = new Map(); - // Reserved for gRPC registration once projection definition serialization is implemented. - private readonly _eventStore: string; - private readonly _connection: ChronicleConnection; - /** * Creates a new {@link Projections} instance. - * @param eventStore - The name of the event store these projections belong to. - * @param connection - The connection used to communicate with the Kernel. * @param _clientArtifacts - Provider for discovered client artifact types. */ - constructor( - eventStore: string, - connection: ChronicleConnection, - private readonly _clientArtifacts: IClientArtifactsProvider - ) { - this._eventStore = eventStore; - this._connection = connection; - } + constructor(private readonly _clientArtifacts: IClientArtifactsProvider) {} /** @inheritdoc */ async discover(): Promise { @@ -62,8 +48,6 @@ export class Projections implements IProjections { } // Full projection definition serialization and gRPC registration will be - // implemented as the projection engine support matures. - void this._eventStore; - void this._connection; + // added once the projection engine contract supports TypeScript clients. } } diff --git a/Source/Reactors/Reactors.ts b/Source/Reactors/Reactors.ts index 2b565b1..fedd0d8 100644 --- a/Source/Reactors/Reactors.ts +++ b/Source/Reactors/Reactors.ts @@ -2,7 +2,6 @@ // 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 { IReactors } from './IReactors'; import { getReactorMetadata } from './reactor'; @@ -14,28 +13,11 @@ import { getReactorMetadata } from './reactor'; export class Reactors implements IReactors { private readonly _reactors = new Map(); - // Reserved for streaming gRPC registration once observation infrastructure is implemented. - private readonly _eventStore: string; - private readonly _namespace: string; - private readonly _connection: ChronicleConnection; - /** * Creates a new {@link Reactors} instance. - * @param eventStore - The name of the event store these reactors belong to. - * @param namespace - The namespace within the event store. - * @param connection - The connection used to communicate with the Kernel. * @param _clientArtifacts - Provider for discovered client artifact types. */ - constructor( - eventStore: string, - namespace: string, - connection: ChronicleConnection, - private readonly _clientArtifacts: IClientArtifactsProvider - ) { - this._eventStore = eventStore; - this._namespace = namespace; - this._connection = connection; - } + constructor(private readonly _clientArtifacts: IClientArtifactsProvider) {} /** @inheritdoc */ async discover(): Promise { @@ -55,10 +37,7 @@ export class Reactors implements IReactors { } // Reactor registration uses a bidirectional streaming gRPC call. - // Full streaming registration will be implemented as the observation - // infrastructure matures in the TypeScript client. - void this._eventStore; - void this._namespace; - void this._connection; + // Full streaming observation will be added once the observation + // infrastructure is implemented in the TypeScript client. } } diff --git a/Source/Reducers/Reducers.ts b/Source/Reducers/Reducers.ts index d241760..27429bf 100644 --- a/Source/Reducers/Reducers.ts +++ b/Source/Reducers/Reducers.ts @@ -2,7 +2,6 @@ // 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 { IReducers } from './IReducers'; import { getReducerMetadata } from './reducer'; @@ -14,28 +13,11 @@ import { getReducerMetadata } from './reducer'; export class Reducers implements IReducers { private readonly _reducers = new Map(); - // Reserved for streaming gRPC registration once observation infrastructure is implemented. - private readonly _eventStore: string; - private readonly _namespace: string; - private readonly _connection: ChronicleConnection; - /** * Creates a new {@link Reducers} instance. - * @param eventStore - The name of the event store these reducers belong to. - * @param namespace - The namespace within the event store. - * @param connection - The connection used to communicate with the Kernel. * @param _clientArtifacts - Provider for discovered client artifact types. */ - constructor( - eventStore: string, - namespace: string, - connection: ChronicleConnection, - private readonly _clientArtifacts: IClientArtifactsProvider - ) { - this._eventStore = eventStore; - this._namespace = namespace; - this._connection = connection; - } + constructor(private readonly _clientArtifacts: IClientArtifactsProvider) {} /** @inheritdoc */ async discover(): Promise { @@ -55,10 +37,7 @@ export class Reducers implements IReducers { } // Reducer registration uses a bidirectional streaming gRPC call. - // Full streaming registration will be implemented as the observation - // infrastructure matures in the TypeScript client. - void this._eventStore; - void this._namespace; - void this._connection; + // Full streaming observation will be added once the observation + // infrastructure is implemented in the TypeScript client. } } From f4ca3a722f505f175574f6054fb7276b0ea84b2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 06:17:14 +0000 Subject: [PATCH 12/13] Split multi-type files and extract builder classes from Constraints.ts Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/e60ed382-ede1-4b77-9f2e-2889116ef533 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/EventSequences/AppendError.ts | 10 + Source/EventSequences/AppendOptions.ts | 15 ++ Source/EventSequences/AppendResult.ts | 25 +-- Source/EventSequences/ConstraintViolation.ts | 16 ++ Source/EventSequences/EventSequence.ts | 6 +- Source/EventSequences/IEventSequence.ts | 13 +- Source/EventSequences/index.ts | 7 +- Source/Events/CausationEntry.ts | 13 ++ .../Events/Constraints/ConstraintBuilder.ts | 86 +++++++++ Source/Events/Constraints/Constraints.ts | 174 +----------------- .../Constraints/UniqueConstraintBuilder.ts | 97 ++++++++++ Source/Events/Constraints/index.ts | 4 + Source/Events/EventContext.ts | 14 +- .../modelBound/FromEventMetadata.ts | 10 + .../modelBound/FromEventOptions.ts | 12 ++ Source/Projections/modelBound/fromEvent.ts | 19 +- Source/Projections/modelBound/index.ts | 3 +- 17 files changed, 290 insertions(+), 234 deletions(-) create mode 100644 Source/EventSequences/AppendError.ts create mode 100644 Source/EventSequences/AppendOptions.ts create mode 100644 Source/EventSequences/ConstraintViolation.ts create mode 100644 Source/Events/CausationEntry.ts create mode 100644 Source/Events/Constraints/ConstraintBuilder.ts create mode 100644 Source/Events/Constraints/UniqueConstraintBuilder.ts create mode 100644 Source/Projections/modelBound/FromEventMetadata.ts create mode 100644 Source/Projections/modelBound/FromEventOptions.ts 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..bd53c46 100644 --- a/Source/EventSequences/AppendResult.ts +++ b/Source/EventSequences/AppendResult.ts @@ -1,29 +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 { 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>; -} +export type { AppendError } from './AppendError'; +export type { ConstraintViolation } from './ConstraintViolation'; /** * 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 7839693..5033f9e 100644 --- a/Source/EventSequences/EventSequence.ts +++ b/Source/EventSequences/EventSequence.ts @@ -5,8 +5,10 @@ import { ChronicleConnection, AppendResponse, AppendManyResponse, GetTailSequenc 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'; diff --git a/Source/EventSequences/IEventSequence.ts b/Source/EventSequences/IEventSequence.ts index 7f47bd6..d074888 100644 --- a/Source/EventSequences/IEventSequence.ts +++ b/Source/EventSequences/IEventSequence.ts @@ -1,21 +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 { 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; -} +export type { AppendOptions } from './AppendOptions'; /** * 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/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/Constraints.ts b/Source/Events/Constraints/Constraints.ts index 00d4bb8..b67e70f 100644 --- a/Source/Events/Constraints/Constraints.ts +++ b/Source/Events/Constraints/Constraints.ts @@ -1,185 +1,17 @@ // 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 { ChronicleConnection, ConstraintType } from '@cratis/chronicle.contracts'; import { IClientArtifactsProvider } from '../../artifacts'; -import { getEventTypeFor } from '../eventTypeDecorator'; import { Grpc } from '../../Grpc'; import { ConstraintId } from './ConstraintId'; import { IConstraint } from './IConstraint'; -import { IConstraintBuilder } from './IConstraintBuilder'; import { IConstraints } from './IConstraints'; -import { IUniqueConstraintBuilder } from './IUniqueConstraintBuilder'; +import { ConstraintBuilder, ConstraintCapture } from './ConstraintBuilder'; import { getConstraintMetadata } from './constraint'; -/** 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. */ -interface UniqueConstraintEventEntry { - eventTypeId: string; - properties: string[]; -} - -/** Captured definition of a unique constraint. */ -interface UniqueConstraintCapture { - name?: string; - eventDefinitions: UniqueConstraintEventEntry[]; - ignoreCasing: boolean; - removedWithEventTypeId?: string; - message?: string; -} - -/** Represents the captured definition of a unique event type constraint. */ -interface UniqueEventTypeCapture { - eventTypeId: string; - message?: string; - name?: string; -} - -/** Represents the captured scope for a constraint. */ -interface ConstraintScopeCapture { - perEventSourceType: boolean; - perEventStreamType: boolean; - perEventStreamId: boolean; -} - -/** Represents the full captured definition of a constraint. */ -interface ConstraintCapture { - name: string; - scope: ConstraintScopeCapture; - uniqueConstraint?: UniqueConstraintCapture; - uniqueEventType?: UniqueEventTypeCapture; -} - -/** Implementation of {@link IUniqueConstraintBuilder} that captures the unique constraint definition. */ -class UniqueConstraintBuilderImpl 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): UniqueConstraintBuilderImpl { - this._currentEventTypeId = eventTypeId; - return this; - } -} - -/** Implementation of {@link IConstraintBuilder} that captures the constraint definition. */ -class ConstraintBuilderImpl implements IConstraintBuilder { - readonly capture: ConstraintCapture; - private _uniqueBuilder?: UniqueConstraintBuilderImpl; - - 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 UniqueConstraintBuilderImpl(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; - } -} - /** - * Implements {@link IConstraints}, managing discovery and registration of constraints - * with the Chronicle Kernel. + * Manages discovery and registration of constraints with the Chronicle Kernel. */ export class Constraints implements IConstraints { private readonly _captures = new Map(); @@ -203,7 +35,7 @@ export class Constraints implements IConstraints { const metadata = getConstraintMetadata(type); if (!metadata) continue; - const builder = new ConstraintBuilderImpl(metadata.id.value); + 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); 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/index.ts b/Source/Events/Constraints/index.ts index 3c0a843..e8e3689 100644 --- a/Source/Events/Constraints/index.ts +++ b/Source/Events/Constraints/index.ts @@ -9,3 +9,7 @@ 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..7c66e69 100644 --- a/Source/Events/EventContext.ts +++ b/Source/Events/EventContext.ts @@ -2,6 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { EventType } from './EventType'; +import { CausationEntry } from './CausationEntry'; + +export type { CausationEntry } from './CausationEntry'; /** * Represents contextual information about an appended event. @@ -25,14 +28,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/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/fromEvent.ts b/Source/Projections/modelBound/fromEvent.ts index 7ccd376..b07629f 100644 --- a/Source/Projections/modelBound/fromEvent.ts +++ b/Source/Projections/modelBound/fromEvent.ts @@ -2,22 +2,11 @@ // 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'; -/** 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; -} - -/** Metadata stored by the fromEvent decorator on a class. */ -export interface FromEventMetadata extends FromEventOptions { - /** The event constructor associated with this annotation. */ - readonly eventType: Function; -} +export type { FromEventOptions } from './FromEventOptions'; +export type { FromEventMetadata } from './FromEventMetadata'; const METADATA_KEY = 'chronicle:projection:fromEvent'; diff --git a/Source/Projections/modelBound/index.ts b/Source/Projections/modelBound/index.ts index edc6c19..14b8e08 100644 --- a/Source/Projections/modelBound/index.ts +++ b/Source/Projections/modelBound/index.ts @@ -4,7 +4,8 @@ export { modelBound, getModelBoundMetadata, isModelBound } from './modelBound'; export type { ModelBoundMetadata } from './modelBound'; export { fromEvent, getFromEventMetadata, hasFromEventMetadata } from './fromEvent'; -export type { FromEventOptions, FromEventMetadata } 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'; From 2ad29c0e9709855fbf4ea5afc225da485bc8e757 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 06:18:37 +0000 Subject: [PATCH 13/13] Remove redundant re-exports from split files Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/e60ed382-ede1-4b77-9f2e-2889116ef533 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/EventSequences/AppendResult.ts | 3 --- Source/EventSequences/IEventSequence.ts | 2 -- Source/Events/EventContext.ts | 2 -- Source/Events/index.ts | 3 ++- Source/Projections/modelBound/fromEvent.ts | 3 --- 5 files changed, 2 insertions(+), 11 deletions(-) diff --git a/Source/EventSequences/AppendResult.ts b/Source/EventSequences/AppendResult.ts index bd53c46..b71c439 100644 --- a/Source/EventSequences/AppendResult.ts +++ b/Source/EventSequences/AppendResult.ts @@ -5,9 +5,6 @@ import { AppendError } from './AppendError'; import { ConstraintViolation } from './ConstraintViolation'; import { EventSequenceNumber } from './EventSequenceNumber'; -export type { AppendError } from './AppendError'; -export type { ConstraintViolation } from './ConstraintViolation'; - /** * Represents the result of appending a single event to an event sequence. */ diff --git a/Source/EventSequences/IEventSequence.ts b/Source/EventSequences/IEventSequence.ts index d074888..3266967 100644 --- a/Source/EventSequences/IEventSequence.ts +++ b/Source/EventSequences/IEventSequence.ts @@ -6,8 +6,6 @@ import { AppendResult } from './AppendResult'; import { EventSequenceId } from './EventSequenceId'; import { EventSequenceNumber } from './EventSequenceNumber'; -export type { AppendOptions } from './AppendOptions'; - /** * Defines the API surface for an event sequence. */ diff --git a/Source/Events/EventContext.ts b/Source/Events/EventContext.ts index 7c66e69..c33746c 100644 --- a/Source/Events/EventContext.ts +++ b/Source/Events/EventContext.ts @@ -4,8 +4,6 @@ import { EventType } from './EventType'; import { CausationEntry } from './CausationEntry'; -export type { CausationEntry } from './CausationEntry'; - /** * Represents contextual information about an appended event. */ diff --git a/Source/Events/index.ts b/Source/Events/index.ts index 1b582e0..f003321 100644 --- a/Source/Events/index.ts +++ b/Source/Events/index.ts @@ -6,7 +6,8 @@ export { EventTypeId } from './EventTypeId'; export { EventTypeGeneration } from './EventTypeGeneration'; export { eventType, getEventTypeFor, hasEventType, getEventTypeMetadata, getEventTypeJsonSchemaFor } from './eventTypeDecorator'; export type { EventTypeMetadata } from './eventTypeDecorator'; -export type { EventContext, CausationEntry } from './EventContext'; +export type { EventContext } from './EventContext'; +export type { CausationEntry } from './CausationEntry'; export type { AppendedEvent } from './AppendedEvent'; export type { IEventTypes } from './IEventTypes'; export { EventTypes } from './EventTypes'; diff --git a/Source/Projections/modelBound/fromEvent.ts b/Source/Projections/modelBound/fromEvent.ts index b07629f..585845f 100644 --- a/Source/Projections/modelBound/fromEvent.ts +++ b/Source/Projections/modelBound/fromEvent.ts @@ -5,9 +5,6 @@ import 'reflect-metadata'; import { FromEventMetadata } from './FromEventMetadata'; import { FromEventOptions } from './FromEventOptions'; -export type { FromEventOptions } from './FromEventOptions'; -export type { FromEventMetadata } from './FromEventMetadata'; - const METADATA_KEY = 'chronicle:projection:fromEvent'; /**