// Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ConstraintType } from '@cratis/chronicle.contracts'; import type { ConstraintViolation } from '../../eventSequences/ConstraintViolation.js'; import type { IClientArtifactsProvider } from '../../artifacts/index.js'; import type { ChronicleConnection } from '../../connection/index.js'; import { ConstraintId } from './ConstraintId.js'; import { IConstraint } from './IConstraint.js'; import { IConstraints } from './IConstraints.js'; import { ConstraintBuilder, ConstraintCapture, ConstraintScopeCapture } from './ConstraintBuilder.js'; import { UniqueConstraintBuilder } from './UniqueConstraintBuilder.js'; import { getConstraintMetadata } from './constraint.js'; import { getUniqueEventMetadata, getUniquePropertyMetadata } from './unique.js'; import { getRemovedConstraintNames } from './removeConstraint.js'; import { TypeIntrospector } from '../../types/TypeIntrospector.js'; import { getEventTypeFor } from '../eventTypeDecorator.js'; import { combineConstraintEventSequences } from './ConstraintEventSequences.js'; /** Resolves the name registered with the Chronicle Kernel. */ function wireNameOf(capture: ConstraintCapture): string { return capture.uniqueEventType?.name ?? capture.name; } /** Combine another declaration's event sequences into a capture; an empty result means every event sequence. */ function combineEventSequencesInto(capture: ConstraintCapture, declared: readonly string[] | undefined): void { const combined = combineConstraintEventSequences([capture.eventSequences, declared]); if (combined.length > 0) capture.eventSequences = combined; else delete capture.eventSequences; } const decoratorScope: ConstraintScopeCapture = { perEventSourceType: false, perEventStreamType: false, perEventStreamId: false }; function assertMatchingScope(name: string, left: ConstraintScopeCapture, right: ConstraintScopeCapture): void { if (left.perEventSourceType !== right.perEventSourceType || left.perEventStreamType !== right.perEventStreamType || left.perEventStreamId !== right.perEventStreamId) { throw new Error(`Conflicting scopes for constraint '${name}'.`); } } /** Compile decorator and fluent constraints using the production registration rules. */ export function compileConstraints(provider: Pick): Map { const captures = new Map(); const fluentIds = new Set(); for (const definition of provider.constraints) { const metadata = getConstraintMetadata(definition); if (!metadata) continue; const id = metadata.id.value; if (fluentIds.has(id)) throw new Error(`Duplicate constraint id '${id}'.`); fluentIds.add(id); const builder = new ConstraintBuilder(id); const instance = new (definition as new () => IConstraint)(); instance.define(builder); const capture = builder.capture; const name = wireNameOf(capture); const existing = captures.get(name); if (existing?.uniqueEventType && capture.uniqueEventType) { assertMatchingScope(name, existing.scope, capture.scope); const merged = existing.uniqueEventType; if (capture.uniqueEventType.message && !merged.message) merged.message = capture.uniqueEventType.message; const ids = merged.eventTypeIds ??= [merged.eventTypeId]; for (const id of capture.uniqueEventType.eventTypeIds ?? [capture.uniqueEventType.eventTypeId]) { if (!ids.includes(id)) ids.push(id); } const removedWith = merged.removedWithEventTypeIds ??= []; for (const id of capture.uniqueEventType.removedWithEventTypeIds ?? []) { if (!removedWith.includes(id)) removedWith.push(id); } combineEventSequencesInto(existing, capture.eventSequences); } else if (existing) { throw new Error(`Duplicate constraint name '${name}'.`); } else { captures.set(name, capture); } } const removalEvents = new Map(); for (const eventType of provider.eventTypes) { for (const name of getRemovedConstraintNames(eventType)) { const events = removalEvents.get(name) ?? []; events.push(eventType); removalEvents.set(name, events); } } for (const eventType of provider.eventTypes) { const eventMetadata = getUniqueEventMetadata(eventType); if (eventMetadata) { const name = eventMetadata.name ?? eventType.name; let capture = captures.get(name); if (!capture) { const builder = new ConstraintBuilder(name); builder.uniqueFor(eventType, eventMetadata.message, name); builder.forEventSequences(...(eventMetadata.eventSequences ?? [])); capture = builder.capture; captures.set(name, capture); } else if (!capture.uniqueEventType) { throw new Error(`Constraint '${name}' is not a unique event type constraint.`); } else { assertMatchingScope(name, capture.scope, decoratorScope); combineEventSequencesInto(capture, eventMetadata.eventSequences); if (eventMetadata.message && !capture.uniqueEventType.message) capture.uniqueEventType.message = eventMetadata.message; capture.uniqueEventType.eventTypeIds ??= [capture.uniqueEventType.eventTypeId]; const id = getEventTypeFor(eventType).id.value; if (!capture.uniqueEventType.eventTypeIds.includes(id)) capture.uniqueEventType.eventTypeIds.push(id); } } for (const property of TypeIntrospector.getTrackedProperties(eventType)) { const metadata = getUniquePropertyMetadata(eventType, property); if (!metadata) continue; const name = metadata.name ?? property; let capture = captures.get(name); if (!capture) { const builder = new ConstraintBuilder(name); builder.unique(() => {}); builder.forEventSequences(...(metadata.eventSequences ?? [])); capture = builder.capture; captures.set(name, capture); } else { combineEventSequencesInto(capture, metadata.eventSequences); } if (!capture.uniqueConstraint) throw new Error(`Constraint '${name}' is not a unique property constraint.`); assertMatchingScope(name, capture.scope, decoratorScope); if (capture.uniqueConstraint.ignoreCasing) throw new Error(`Conflicting ignoreCasing for unique property constraint '${name}'.`); const unique = new UniqueConstraintBuilder(capture.uniqueConstraint); const id = getEventTypeFor(eventType).id.value; const existing = capture.uniqueConstraint.eventDefinitions.find(definition => definition.eventTypeId === id); if (existing && !existing.properties.includes(property)) { throw new Error(`Event type '${id}' already added to unique constraint '${name}' with properties '${existing.properties.join(', ')}'.`); } if (!existing) unique.on(eventType, event => (event as Record)[property]); if (metadata.message && !capture.uniqueConstraint.message) unique.withMessage(metadata.message); } } for (const [name, eventTypes] of removalEvents) { const capture = captures.get(name); if (capture) { if (capture.uniqueConstraint) { const unique = new UniqueConstraintBuilder(capture.uniqueConstraint); eventTypes.forEach(eventType => unique.removedWith(eventType)); } else if (capture.uniqueEventType) { const removedWith = capture.uniqueEventType.removedWithEventTypeIds ??= []; for (const eventType of eventTypes) { const id = getEventTypeFor(eventType).id.value; if (!removedWith.includes(id)) removedWith.push(id); } } } } return captures; } /** Resolve the registered message and detail substitutions without a connection. */ export function resolveConstraintMessage(captures: ReadonlyMap, violation: ConstraintViolation): ConstraintViolation { const capture = captures.get(violation.constraintId); const message = capture?.uniqueConstraint?.message ?? capture?.uniqueEventType?.message; if (!message) return violation; let resolved = message; for (const [key, value] of Object.entries(violation.details)) { resolved = resolved.replaceAll(`{${key}}`, () => value); } return { ...violation, message: resolved }; } /** Manages discovery and registration of constraints with the Chronicle Kernel. */ export class Constraints implements IConstraints { private _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 = compileConstraints(this._clientArtifacts); } /** @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 ? '*' : '' }; // Empty means every event sequence, which is what the Kernel assumes when the field is omitted. const eventSequences = [...new Set(capture.eventSequences ?? [])]; if (capture.uniqueConstraint) { const uc = capture.uniqueConstraint; const removalEventTypeIds = uc.removedWithEventTypeIds ?? []; const legacyEventTypeId = uc.removedWithEventTypeId; return { Name: capture.name, Type: ConstraintType.Unique, RemovedWith: [...new Set([ ...(legacyEventTypeId && !removalEventTypeIds.includes(legacyEventTypeId) ? [legacyEventTypeId] : []), ...removalEventTypeIds ])], Definition: { Value0: { EventDefinitions: uc.eventDefinitions.map(ed => ({ EventTypeId: ed.eventTypeId, Properties: ed.properties })), IgnoreCasing: uc.ignoreCasing }, Value1: undefined }, Scope: scope, EventSequences: eventSequences }; } if (capture.uniqueEventType) { const uet = capture.uniqueEventType; return { Name: uet.name ?? capture.name, Type: ConstraintType.UniqueEventType, RemovedWith: uet.removedWithEventTypeIds ?? [], Definition: { Value0: undefined, Value1: { EventTypeIds: uet.eventTypeIds ?? [uet.eventTypeId] } }, Scope: scope, EventSequences: eventSequences }; } return { Name: capture.name, Type: ConstraintType.Unknown, RemovedWith: [], Definition: undefined, Scope: scope, EventSequences: eventSequences }; }); if (constraints.length === 0) { return; } await this._connection.constraints.register({ EventStore: this._eventStore, Constraints: constraints }); } /** @inheritdoc */ hasFor(id: ConstraintId): boolean { return this._captures.has(id.value); } /** * Resolves a configured violation message, preserving the Kernel message when none was supplied. * @param violation - Violation returned by the Kernel. * @returns The violation with its configured message and substituted details, if available. */ resolveMessageFor(violation: ConstraintViolation): ConstraintViolation { return resolveConstraintMessage(this._captures, violation); } }