From 9e002b42f87e6b8575a17a28b3e93cceab1e522c Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:06:38 +0100 Subject: [PATCH 1/7] Characterize projection value conversion boundaries --- ...rting_structured_and_scalar_values.spec.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 Source/testing/projections/for_ProjectionValueConverter/when_converting_structured_and_scalar_values.spec.ts diff --git a/Source/testing/projections/for_ProjectionValueConverter/when_converting_structured_and_scalar_values.spec.ts b/Source/testing/projections/for_ProjectionValueConverter/when_converting_structured_and_scalar_values.spec.ts new file mode 100644 index 00000000..34c04959 --- /dev/null +++ b/Source/testing/projections/for_ProjectionValueConverter/when_converting_structured_and_scalar_values.spec.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 { chai, describe, it } from 'vitest'; +import { ProjectionValueConverter } from '../ProjectionValueConverter.js'; +import type { JsonSchema } from '../../../schemas/JsonSchema.js'; + +chai.should(); + +describe('when converting structured and scalar values', () => { + it('should unwrap concepts only at expression boundaries', () => { + (ProjectionValueConverter.convert({ value: { value: '12' } }, { type: 'integer' }) as number).should.equal(12); + (() => ProjectionValueConverter.convert({ value: 12 }, { type: 'integer' }, true, false)) + .should.throw(RangeError, "Projection event numeric value '[object Object]' must be a JSON number."); + (() => ProjectionValueConverter.convert([{ value: 12 }], { type: 'array', items: { type: 'integer' } })) + .should.throw(RangeError, "Projection numeric value '[object Object]' is not supported by integer/double."); + }); + + it('should apply defaults to missing object members and omit null defaults', () => { + const schema: JsonSchema = { type: 'object', properties: { + amount: { type: 'integer' }, flag: { type: 'boolean' }, text: { type: 'string' }, + nullable: { type: ['string', 'null'] }, identifier: { type: 'string', format: 'guid' }, + date: { type: 'string', format: 'date-time' } + } }; + const expected = { amount: 0, flag: false, identifier: '00000000-0000-0000-0000-000000000000', date: '0001-01-01T00:00:00' }; + ({ ...ProjectionValueConverter.convert({ amount: null }, schema) as object }).should.deep.equal(expected); + ({ ...ProjectionValueConverter.eventContent([], schema) }).should.deep.equal(expected); + (ProjectionValueConverter.convert(undefined, schema) === null).should.equal(true); + }); + + it('should clone open objects and untyped array items without coercing them', () => { + const input = { nested: { value: '12' } }; + const result = ProjectionValueConverter.convert(input, { type: 'object', additionalProperties: true }) as typeof input; + result.should.deep.equal(input); + result.nested.should.not.equal(input.nested); + (ProjectionValueConverter.convert([input], { type: 'array' }) as object[]).should.deep.equal([input]); + (ProjectionValueConverter.convert(['12'], { type: 'array', items: { type: 'integer' } }) as number[]).should.deep.equal([12]); + }); + + it('should distinguish boolean expression text from event JSON', () => { + (ProjectionValueConverter.convert('TrUe', { type: 'boolean' }) as boolean).should.equal(true); + (ProjectionValueConverter.convert(false, { type: 'boolean' }, true) as boolean).should.equal(false); + (() => ProjectionValueConverter.convert('false', { type: 'boolean' }, true)) + .should.throw(RangeError, "Projection boolean value 'false' requires a kernel-backed test (ChronicleKernelScenario / live kernel)."); + }); + + for (const [value, schema] of [ + [Infinity, { type: 'number' }], [1.5, { type: 'integer' }], + [2147483648, { type: 'integer', format: 'int32' }], [-1, { type: 'integer', format: 'uint32' }], + [4294967296, { type: 'integer', format: 'uint32' }], [9007199254740992, { type: 'integer' }] + ] satisfies [number, JsonSchema][]) { + it(`should reject ${value} outside ${schema.type}/${schema.format ?? 'double'}`, () => { + (() => ProjectionValueConverter.convert(value, schema)).should.throw(RangeError, + `Projection numeric value '${value}' is outside the supported ${schema.type}/${schema.format ?? 'double'} range.`); + }); + } + + it('should preserve the precedence of formats and scalar types', () => { + (ProjectionValueConverter.convert('0001-01-01T00:00:00', { type: 'string', format: 'date-time' }) as string) + .should.equal('0001-01-01T00:00:00'); + (ProjectionValueConverter.convert('12', { type: 'number', format: 'guid' }) as number).should.equal(12); + (ProjectionValueConverter.convert(5, { type: ['string', 'null'] }) as number).should.equal(5); + }); +}); From ad26690f899d2ed53870e0de4bd42e3376250e10 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:09:04 +0100 Subject: [PATCH 2/7] Separate projection scalar and structured value conversion --- .../projections/ProjectionScalarConverter.ts | 98 +++++++++++++++ .../projections/ProjectionValueConverter.ts | 113 ++++++------------ 2 files changed, 135 insertions(+), 76 deletions(-) create mode 100644 Source/testing/projections/ProjectionScalarConverter.ts diff --git a/Source/testing/projections/ProjectionScalarConverter.ts b/Source/testing/projections/ProjectionScalarConverter.ts new file mode 100644 index 00000000..4ed135d5 --- /dev/null +++ b/Source/testing/projections/ProjectionScalarConverter.ts @@ -0,0 +1,98 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { JsonSchema } from '../../schemas/JsonSchema.js'; + +/** Scalar conversions shared by schema-bound input and expression evaluation. */ +export class ProjectionScalarConverter { + static convert(value: unknown, schema: JsonSchema, eventContent: boolean): unknown { + if (schema.type === 'boolean') return this.boolean(value, eventContent); + if (schema.type === 'number' || schema.type === 'integer') return this.number(value, schema, eventContent); + if (schema.format === 'date-time') return this.dateTime(value); + return this.schemaString(value, schema, eventContent); + } + + static checkNumber(number: number, schema: JsonSchema): void { + if (!Number.isFinite(number) || this.invalidInteger(number, schema)) { + throw new RangeError(`Projection numeric value '${number}' is outside the supported ${schema.type}/${schema.format ?? 'double'} range.`); + } + } + + private static invalidInteger(number: number, schema: JsonSchema): boolean { + if (schema.type !== 'integer') return false; + if (!Number.isSafeInteger(number)) return true; + if (schema.format === 'int32') return number < -2147483648 || number > 2147483647; + if (schema.format === 'uint32') return number < 0 || number > 4294967295; + return false; + } + + private static boolean(value: unknown, eventContent: boolean): boolean { + if (typeof value === 'boolean') return value; + if (!eventContent && this.booleanText(value)) return value.toLowerCase() === 'true'; + throw new RangeError(`Projection boolean value '${String(value)}' requires a kernel-backed test (ChronicleKernelScenario / live kernel).`); + } + + private static booleanText(value: unknown): value is string { + return typeof value === 'string' && /^(true|false)$/i.test(value); + } + + private static number(value: unknown, schema: JsonSchema, eventContent: boolean): number { + if (eventContent && typeof value !== 'number') { + throw new RangeError(`Projection event numeric value '${String(value)}' must be a JSON number.`); + } + if (typeof value !== 'number' && !this.numericText(value, schema)) { + throw new RangeError(`Projection numeric value '${String(value)}' is not supported by ${schema.type}/${schema.format ?? 'double'}.`); + } + const number = typeof value === 'number' ? value : Number(value); + this.checkNumber(number, schema); + return number; + } + + private static numericText(value: unknown, schema: JsonSchema): boolean { + if (typeof value !== 'string') return false; + return schema.type === 'integer' ? /^[+-]?\d+$/.test(value) : /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value); + } + + private static dateTime(value: unknown): unknown { + if (value === '0001-01-01T00:00:00') return value; // Kernel default for a missing DateTime member. + if (!this.utcDateTime(value)) { + throw new RangeError(`Projection date-time value '${String(value)}' is outside the supported DateTime range or UTC ISO format.`); + } + return value; + } + + private static utcDateTime(value: unknown): boolean { + // Date.parse accepts years beyond DateTime.MaxValue and normalizes invalid calendar dates. + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) return false; + if (+value.slice(0, 4) < 1 || !Number.isFinite(Date.parse(value))) return false; + return new Date(value).toISOString().slice(0, 19) === value.slice(0, 19); + } + + private static stringSchema(schema: JsonSchema): boolean { + return schema.type === 'string' || (Array.isArray(schema.type) && schema.type[0] === 'string'); + } + + private static schemaString(value: unknown, schema: JsonSchema, eventContent: boolean): unknown { + if (eventContent && this.stringSchema(schema)) this.checkEventString(value); + return this.string(value, schema); + } + + private static checkEventString(value: unknown): void { + if (typeof value !== 'string') { + throw new RangeError(`Projection event string value '${String(value)}' must be a JSON string.`); + } + } + + private static string(value: unknown, schema: JsonSchema): unknown { + if (schema.format === 'guid' && typeof value === 'string') return this.guid(value); + if (schema.type === 'string' && typeof value !== 'string') return String(value); + return value; + } + + private static guid(value: string): string { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { + throw new RangeError(`Projection GUID value '${value}' requires a kernel-backed test (ChronicleKernelScenario / live kernel).`); + } + return value.toLowerCase(); + } +} diff --git a/Source/testing/projections/ProjectionValueConverter.ts b/Source/testing/projections/ProjectionValueConverter.ts index 0e0fe459..100a28d1 100644 --- a/Source/testing/projections/ProjectionValueConverter.ts +++ b/Source/testing/projections/ProjectionValueConverter.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { JsonSchema } from '../../schemas/JsonSchema.js'; +import { ProjectionScalarConverter } from './ProjectionScalarConverter.js'; /** Converts values only in the numeric and scalar schema subset admitted by ProjectionCapabilities. */ export class ProjectionValueConverter { @@ -23,91 +24,51 @@ export class ProjectionValueConverter { /** The kernel deserializes event JSON against its registered schema before mapping. */ static eventContent(content: unknown, schema: JsonSchema): Record { - const serialized = content !== null && typeof content === 'object' && !Array.isArray(content) - ? content as Record : {}; - const result: Record = Object.create(null); - for (const [name, property] of Object.entries(schema.properties ?? {})) { - const source = this.sourceKey(serialized, name); - const value = source === undefined ? null : serialized[source]; - if (value !== null && value !== undefined) result[name] = this.convert(value, property, true, false); - else { - const fallback = this.defaultValue(property); - if (fallback !== null && fallback !== undefined) result[name] = fallback; - } - } - return result; + const serialized = this.isObject(content) ? content : {}; + return this.properties(serialized, schema, true); } static convert(value: unknown, schema: JsonSchema, eventContent = false, expression = true): unknown { if (value == null) return null; // Concept conversion belongs to expression evaluation, never schema-bound input deserialization. - if (expression && schema.type !== 'object' && schema.type !== 'array' && - typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && 'value' in value) { - return this.convert((value as Record).value, schema, eventContent, expression); - } - if (schema.type === 'object' && typeof value === 'object' && !Array.isArray(value)) { - const input = value as Record; - if (schema.additionalProperties || !schema.properties) return structuredClone(input); - const result: Record = Object.create(null); - for (const [name, property] of Object.entries(schema.properties)) { - const source = this.sourceKey(input, name); - if (source !== undefined && input[source] != null) result[name] = this.convert(input[source], property, eventContent, false); - else { - const fallback = this.defaultValue(property); - if (fallback !== null && fallback !== undefined) result[name] = fallback; - } - } - return result; - } - if (schema.type === 'array' && Array.isArray(value)) { - return value.map(item => schema.items ? this.convert(item, schema.items, eventContent, false) : structuredClone(item)); + if (expression && this.isConcept(value, schema)) { + return this.convert(value.value, schema, eventContent, expression); } - if (schema.type === 'boolean') { - if (typeof value === 'boolean') return value; - if (!eventContent && typeof value === 'string' && /^(true|false)$/i.test(value)) return value.toLowerCase() === 'true'; - throw new RangeError(`Projection boolean value '${String(value)}' requires a kernel-backed test (ChronicleKernelScenario / live kernel).`); - } - if (schema.type === 'number' || schema.type === 'integer') { - if (eventContent && typeof value !== 'number') { - throw new RangeError(`Projection event numeric value '${String(value)}' must be a JSON number.`); - } - if (typeof value !== 'number' && (typeof value !== 'string' || - !(schema.type === 'integer' ? /^[+-]?\d+$/.test(value) : /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)))) { - throw new RangeError(`Projection numeric value '${String(value)}' is not supported by ${schema.type}/${schema.format ?? 'double'}.`); - } - const number = typeof value === 'number' ? value : Number(value); - this.checkNumber(number, schema); - return number; - } - if (schema.format === 'date-time') { - if (value === '0001-01-01T00:00:00') return value; // Kernel default for a missing DateTime member. - // Bound the accepted subset to UTC ISO text with a four-digit year. Date.parse accepts - // years beyond DateTime.MaxValue, and silently normalizes some invalid calendar dates. - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) || - +value.slice(0, 4) < 1 || !Number.isFinite(Date.parse(value)) || - new Date(value).toISOString().slice(0, 19) !== value.slice(0, 19)) { - throw new RangeError(`Projection date-time value '${String(value)}' is outside the supported DateTime range or UTC ISO format.`); - } - return value; - } - if (eventContent && (schema.type === 'string' || (Array.isArray(schema.type) && schema.type[0] === 'string')) && typeof value !== 'string') { - throw new RangeError(`Projection event string value '${String(value)}' must be a JSON string.`); - } - if (schema.format === 'guid' && typeof value === 'string') { - if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { - throw new RangeError(`Projection GUID value '${value}' requires a kernel-backed test (ChronicleKernelScenario / live kernel).`); - } - return value.toLowerCase(); - } - if (schema.type === 'string' && typeof value !== 'string') return String(value); - return value; + if (schema.type === 'object' && this.isObject(value)) return this.object(value, schema, eventContent); + if (schema.type === 'array' && Array.isArray(value)) return this.array(value, schema, eventContent); + return ProjectionScalarConverter.convert(value, schema, eventContent); } static checkNumber(number: number, schema: JsonSchema): void { - if (!Number.isFinite(number) || (schema.type === 'integer' && (!Number.isSafeInteger(number) || - (schema.format === 'int32' && (number < -2147483648 || number > 2147483647)) || - (schema.format === 'uint32' && (number < 0 || number > 4294967295))))) { - throw new RangeError(`Projection numeric value '${number}' is outside the supported ${schema.type}/${schema.format ?? 'double'} range.`); + ProjectionScalarConverter.checkNumber(number, schema); + } + + private static isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); + } + + private static isConcept(value: unknown, schema: JsonSchema): value is Record { + if (schema.type === 'object' || schema.type === 'array') return false; + return this.isObject(value) && Object.keys(value).length === 1 && 'value' in value; + } + + private static object(input: Record, schema: JsonSchema, eventContent: boolean): Record { + if (schema.additionalProperties || !schema.properties) return structuredClone(input); + return this.properties(input, schema, eventContent); + } + + private static array(input: unknown[], schema: JsonSchema, eventContent: boolean): unknown[] { + return input.map(item => schema.items ? this.convert(item, schema.items, eventContent, false) : structuredClone(item)); + } + + private static properties(input: Record, schema: JsonSchema, eventContent: boolean): Record { + const result: Record = Object.create(null); + for (const [name, property] of Object.entries(schema.properties ?? {})) { + const source = this.sourceKey(input, name); + const value = source === undefined ? null : input[source]; + const converted = value == null ? this.defaultValue(property) : this.convert(value, property, eventContent, false); + if (converted != null) result[name] = converted; } + return result; } } From 383ee90e72d5a7ec45a9d1e977ad522b8948def4 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:10:12 +0100 Subject: [PATCH 3/7] Characterize projection capability rejection ordering --- ...ng_the_first_unsupported_operation.spec.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 Source/testing/projections/for_ProjectionCapabilities/when_reporting_the_first_unsupported_operation.spec.ts diff --git a/Source/testing/projections/for_ProjectionCapabilities/when_reporting_the_first_unsupported_operation.spec.ts b/Source/testing/projections/for_ProjectionCapabilities/when_reporting_the_first_unsupported_operation.spec.ts new file mode 100644 index 00000000..7e93ade9 --- /dev/null +++ b/Source/testing/projections/for_ProjectionCapabilities/when_reporting_the_first_unsupported_operation.spec.ts @@ -0,0 +1,74 @@ +// 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 { chai, describe, it } from 'vitest'; +import { ProjectionCapabilities } from '../ProjectionCapabilities.js'; +import { UnsupportedProjectionOperation } from '../UnsupportedProjectionOperation.js'; +import { Changed } from './given/Changed.js'; +import { compileDeclarative } from './given/compile.js'; + +chai.should(); + +describe('when reporting the first unsupported operation', () => { + const compile = () => compileDeclarative(builder => builder.from(Changed)); + + it('should reject definition membership before missing evidence', () => { + const { compiled, definition } = compile(); + (() => ProjectionCapabilities.validate({ ...compiled, definitions: [], provenance: new Map(), eventSchemas: new Map() }, definition)) + .should.throw(UnsupportedProjectionOperation, 'Definition (contract): definition is not part of this compile'); + }); + + it('should reject missing provenance before a missing event catalog', () => { + const { compiled, definition } = compile(); + (() => ProjectionCapabilities.validate({ ...compiled, provenance: new Map(), eventSchemas: new Map() }, definition)) + .should.throw(UnsupportedProjectionOperation, 'Definition (contract): capability provenance is unavailable'); + }); + + it('should reject a missing catalog before passive projections', () => { + const { compiled, definition } = compile(); + definition.IsActive = false; + (() => ProjectionCapabilities.validate({ ...compiled, eventSchemas: new Map() }, definition)) + .should.throw(UnsupportedProjectionOperation, 'Definition (contract): event-schema catalog is unavailable'); + }); + + it('should reject passive projections before malformed read-model schemas', () => { + const { compiled, definition } = compile(); + definition.IsActive = false; + compiled.readModels[0].Schema = '{'; + (() => ProjectionCapabilities.validate(compiled, definition)) + .should.throw(UnsupportedProjectionOperation, 'IsActive (contract): passive projections require a kernel-backed test'); + }); + + it('should report malformed model schemas with their source declaration', () => { + const { compiled, definition } = compile(); + compiled.readModels[0].Schema = '{'; + (() => ProjectionCapabilities.validate(compiled, definition)) + .should.throw(UnsupportedProjectionOperation, 'ReadModel.Schema (.from): read-model schema is not valid JSON'); + }); + + it('should reject malformed initial JSON before invalid subscription keys', () => { + const { compiled, definition } = compile(); + definition.InitialModelState = '['; + definition.From[0].Value.Key = 'unsupported'; + (() => ProjectionCapabilities.validate(compiled, definition)) + .should.throw(UnsupportedProjectionOperation, 'InitialModelState (.withInitialValues): initial values must be a JSON object'); + }); + + it('should report the first initial value in JSON property order', () => { + const { compiled, definition } = compile(); + definition.InitialModelState = '{"unknownFirst":1,"unknownSecond":2}'; + (() => ProjectionCapabilities.validate(compiled, definition)) + .should.throw(UnsupportedProjectionOperation, 'InitialModelState.unknownFirst (.withInitialValues): initial value has no read-model schema'); + }); + + it('should validate a destination schema before interpreting its expression', () => { + const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.state).to(event => event.name))); + definition.From[0].Value.Properties.state = '$setOnce(name)'; + const schema = JSON.parse(compiled.readModels[0].Schema); + schema.properties.state = { type: 'string', format: 'unsupported' }; + compiled.readModels[0].Schema = JSON.stringify(schema); + (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation, + "expression '$setOnce(name)': schema type/format 'string/unsupported' requires a kernel-backed test"); + }); +}); From 7875f4146c4927c461ffa43164714d9d13a19da9 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:19:55 +0100 Subject: [PATCH 4/7] Separate projection capability validation responsibilities --- .../projections/ProjectionCapabilities.ts | 302 ++---------------- .../projections/ProjectionCapabilityReject.ts | 5 + .../ProjectionCapabilityReporter.ts | 31 ++ .../ProjectionDefinitionCapabilities.ts | 45 +++ .../ProjectionEventSchemaResolver.ts | 23 ++ .../ProjectionExpressionCapabilities.ts | 101 ++++++ .../ProjectionInitialValueCapabilities.ts | 69 ++++ .../ProjectionMappingCapabilities.ts | 85 +++++ .../ProjectionMappingValidation.ts | 12 + .../ProjectionReadModelCapabilities.ts | 42 +++ .../projections/ProjectionRootCapabilities.ts | 75 +++++ .../ProjectionSchemaCapabilities.ts | 112 +++++++ 12 files changed, 623 insertions(+), 279 deletions(-) create mode 100644 Source/testing/projections/ProjectionCapabilityReject.ts create mode 100644 Source/testing/projections/ProjectionCapabilityReporter.ts create mode 100644 Source/testing/projections/ProjectionDefinitionCapabilities.ts create mode 100644 Source/testing/projections/ProjectionEventSchemaResolver.ts create mode 100644 Source/testing/projections/ProjectionExpressionCapabilities.ts create mode 100644 Source/testing/projections/ProjectionInitialValueCapabilities.ts create mode 100644 Source/testing/projections/ProjectionMappingCapabilities.ts create mode 100644 Source/testing/projections/ProjectionMappingValidation.ts create mode 100644 Source/testing/projections/ProjectionReadModelCapabilities.ts create mode 100644 Source/testing/projections/ProjectionRootCapabilities.ts create mode 100644 Source/testing/projections/ProjectionSchemaCapabilities.ts diff --git a/Source/testing/projections/ProjectionCapabilities.ts b/Source/testing/projections/ProjectionCapabilities.ts index 3aeecf79..0fa35277 100644 --- a/Source/testing/projections/ProjectionCapabilities.ts +++ b/Source/testing/projections/ProjectionCapabilities.ts @@ -1,17 +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 { AutoMap, type ProjectionDefinition } from '@cratis/chronicle.contracts'; -import { EventSequenceId } from '../../eventSequences/EventSequenceId.js'; +import { type AutoMap, type ProjectionDefinition } from '@cratis/chronicle.contracts'; import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js'; -import { eventContractPath } from '../../projections/eventContractPath.js'; -import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js'; -import { getEventTypeMapKey, type ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js'; -import type { JsonSchema } from '../../schemas/JsonSchema.js'; -import { ProjectionArithmetic } from './ProjectionArithmetic.js'; +import type { ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js'; +import { ProjectionCapabilityReporter } from './ProjectionCapabilityReporter.js'; import { ProjectionChildrenCapabilities } from './ProjectionChildrenCapabilities.js'; +import { ProjectionDefinitionCapabilities } from './ProjectionDefinitionCapabilities.js'; +import { ProjectionEventSchemaResolver } from './ProjectionEventSchemaResolver.js'; +import { ProjectionInitialValueCapabilities } from './ProjectionInitialValueCapabilities.js'; import { ProjectionJoinsCapabilities } from './ProjectionJoinsCapabilities.js'; -import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js'; +import { ProjectionMappingCapabilities } from './ProjectionMappingCapabilities.js'; +import { ProjectionReadModelCapabilities } from './ProjectionReadModelCapabilities.js'; +import { ProjectionRootCapabilities } from './ProjectionRootCapabilities.js'; +import { ProjectionSchemaCapabilities } from './ProjectionSchemaCapabilities.js'; /** Validates *all* subscribed operations against the bounded scenario subset, before replay. */ export class ProjectionCapabilities { @@ -23,286 +25,28 @@ export class ProjectionCapabilities { static validate(compiled: CompiledProjectionDefinitions, definition: ProjectionDefinition): void { const model = String(definition.ReadModel ?? definition.Identifier ?? ''); const wire = definition as unknown as Record; - const reject = (path: string, reason: string, fallback?: string) : never => { - const source = compiled.provenance.get(definition)?.find(entry => entry.contractPath.startsWith('From['))?.declaration ?? '.from'; - const declaration = compiled.provenance.get(definition)?.find(entry => entry.contractPath === path)?.declaration - ?? compiled.provenance.get(definition)?.find(entry => entry.contractPath.startsWith('Join[') && path.startsWith(`${entry.contractPath}.`))?.declaration - ?? fallback ?? (path.includes('.AutoMap.') ? `${source} (AutoMap)` - : path.startsWith('InitialModelState') ? '.withInitialValues' - : path.startsWith('ReadModel') ? source : 'contract'); - throw new UnsupportedProjectionOperation(model, path, declaration, reason); - }; + const reject = new ProjectionCapabilityReporter(compiled, definition).reject; if (!compiled.definitions.includes(definition)) reject('Definition', 'definition is not part of this compile'); const provenance = compiled.provenance.get(definition) ?? reject('Definition', 'capability provenance is unavailable'); const catalog = compiled.eventSchemas.get(definition) ?? reject('Definition', 'event-schema catalog is unavailable'); - const variant = provenance.find(entry => entry.contractPath === 'Variant'); - if (variant) reject('Variant', 'variants require a kernel-backed test'); - if (wire.IsActive === false) reject('IsActive', 'passive projections require a kernel-backed test'); - if (wire.SubscribesToAllEvents === true) reject('SubscribesToAllEvents', 'subscribe-to-all projections require a kernel-backed test'); - if (wire.EventSequenceId !== EventSequenceId.eventLog.value) reject('EventSequenceId', 'non-default event sequences require a kernel-backed test'); - for (const section of ['Nested', 'RemovedWithJoin'] as const) { - const value = wire[section]; - const paths = Array.isArray(value) ? value.map((entry: { Key: ContractEventType }) => eventContractPath(section, entry.Key)) - : Object.keys(value as Record ?? {}).map(property => `${section}.${property}`); - if (paths.length) reject(paths[0], `${section === 'Nested' ? 'nested projections' : 'removedWithJoin'} require a kernel-backed test`); - } - if ((wire.FromEvery as unknown[] | undefined)?.length) reject('FromEvery', 'derivative projections require a kernel-backed test'); - if (wire.FromEventProperty) reject('FromEventProperty', 'event-property subscriptions require a kernel-backed test'); - const all = wire.All as { Properties?: Record; IncludeChildren?: boolean; AutoMap?: AutoMap } | undefined; - if (provenance.some(entry => entry.contractPath === 'All') || all?.IncludeChildren || - Object.keys(all?.Properties ?? {}).length || all?.AutoMap === AutoMap.Enabled) { - reject('All', 'fromEvery/all subscriptions require a kernel-backed test'); - } - - const readModel = compiled.readModels.find(candidate => candidate.Type.Identifier === model) - ?? reject('ReadModel', 'a single registered read-model schema is required'); - let schema: JsonSchema; - try { - schema = JSON.parse(readModel.Schema) as JsonSchema; - } catch { - reject('ReadModel.Schema', 'read-model schema is not valid JSON'); - } - this.checkProtection(schema!, 'ReadModel.Schema', reject); - const identifier = schema!.properties?.id; - if (!identifier || !((identifier.type === 'string' && (!identifier.format || identifier.format === 'guid')) || - (identifier.type === 'number' && identifier.format === 'double'))) { - reject('ReadModel.Schema.id', 'only a lowercase id with a fixture-backed string, GUID, or number/double schema is supported; other identifiers require a kernel-backed test (ChronicleKernelScenario / live kernel)'); - } - const names = Object.keys(schema!.properties ?? {}); - for (const [index, name] of names.entries()) { - if (names.slice(index + 1).some(other => other.toLowerCase() === name.toLowerCase())) { - reject(`ReadModel.Schema.${name}`, 'case-insensitively colliding read-model properties require a kernel-backed test (ChronicleKernelScenario / live kernel)'); - } - } - let initial: unknown; - try { initial = JSON.parse(String(wire.InitialModelState ?? '{}')); } catch { reject('InitialModelState', 'initial values must be a JSON object'); } - if (initial === null || typeof initial !== 'object' || Array.isArray(initial)) reject('InitialModelState', 'initial values must be a JSON object'); - for (const [property, value] of Object.entries(initial as Record)) { - const target = schema!.properties?.[property] - ?? reject(`InitialModelState.${property}`, 'initial value has no read-model schema'); - this.checkSchema(target, `InitialModelState.${property}`, reject); - this.checkInitialValue(value, target, `InitialModelState.${property}`, reject); - } - const generations = new Map(); - const requireEventSchema = (eventType: ContractEventType, path: string): JsonSchema => { - const previous = generations.get(eventType.Id); - if (previous !== undefined && previous !== eventType.Generation) reject(path, 'multiple generations of the same event-type id require a kernel-backed test'); - generations.set(eventType.Id, eventType.Generation); - const event = catalog.get(getEventTypeMapKey(eventType)) ?? reject(path, 'participating event schema is unavailable'); - return event.schema; - }; + ProjectionDefinitionCapabilities.validate(wire, provenance, reject); + const schema = ProjectionReadModelCapabilities.schema(compiled, model, reject); + const initial = ProjectionInitialValueCapabilities.validate(String(wire.InitialModelState ?? '{}'), schema, reject); + const resolver = new ProjectionEventSchemaResolver(catalog, reject); ProjectionJoinsCapabilities.validate(definition, { - schema: schema!, requireEventSchema, reject, - checkProtection: (eventSchema, path) => this.checkProtection(eventSchema, path, reject), + schema, requireEventSchema: resolver.require, reject, + checkProtection: (eventSchema, path) => ProjectionSchemaCapabilities.checkProtection(eventSchema, path, reject), checkMapping: (modelSchema, eventSchema, destination, expression, path) => - this.checkMapping(modelSchema, eventSchema, destination, expression, path, reject) + new ProjectionMappingCapabilities(modelSchema, reject).check({ eventSchema, destination, expression, path }) }); - const from = wire.From as FromRecord[] ?? []; - const removedWith = wire.RemovedWith as RemovedWithRecord[] ?? []; - for (const entry of from) { - if (removedWith.some(removal => removal.Key.Id === entry.Key.Id)) { - reject(eventContractPath('From', entry.Key), 'events subscribed through both From and RemovedWith require a kernel-backed test (ChronicleKernelScenario / live kernel)'); - } - } - const accumulators = new Set(from.flatMap(entry => Object.entries(entry.Value.Properties ?? {}) - .filter(([, expression]) => ProjectionArithmetic.isArithmetic(expression)).map(([property]) => property))); - if (accumulators.size && Object.keys(wire.Children as object ?? {}).length) { - reject('Children', 'root arithmetic combined with children requires a kernel-backed test'); - } - for (const entry of from) { - for (const [property, expression] of Object.entries(entry.Value.Properties ?? {})) { - if (expression === '$null' && accumulators.has(property)) { - reject(`${eventContractPath('From', entry.Key)}.Properties.${property}`, - 'clearing an arithmetic accumulator requires a kernel-backed test; the kernel rejects arithmetic on a present null'); - } - } - } - for (const entry of [...from, ...removedWith]) { - const section = from.includes(entry as FromRecord) ? 'From' : 'RemovedWith'; - const path = eventContractPath(section, entry.Key); - const eventSchema = requireEventSchema(entry.Key, path); - if (entry.Value.Key !== '$eventSourceId') reject(`${path}.Key`, 'only $eventSourceId keys are supported'); - if (entry.Value.ParentKey) reject(`${path}.ParentKey`, 'parent keys require a kernel-backed test'); - if (section === 'RemovedWith') continue; - const properties = (entry as FromRecord).Value.Properties ?? {}; - for (const [property, expression] of Object.entries(properties)) { - const mappingPath = `${path}.Properties.${property}`; - this.checkMapping(schema!, eventSchema, property, expression, mappingPath, reject, accumulators); - } - if (wire.AutoMap !== AutoMap.Disabled && !ProjectionArithmetic.suppressesAutoMap(properties)) { - this.checkAutoMap(schema!, eventSchema, properties, wire.NoAutoMapProperties as string[] ?? [], path, reject, accumulators); - } - } + const roots = new ProjectionRootCapabilities(schema, wire, reject); + roots.validate(resolver); ProjectionChildrenCapabilities.validate(wire.Children as Record ?? {}, { - schema: schema!, rootAutoMap: wire.AutoMap as AutoMap, hasInitialModelState: Object.keys(initial as object).length > 0, from, removedWith, requireEventSchema, reject, + schema, rootAutoMap: wire.AutoMap as AutoMap, hasInitialModelState: Object.keys(initial).length > 0, + from: roots.from, removedWith: roots.removedWith, requireEventSchema: resolver.require, reject, declarationFor: path => provenance.find(entry => entry.contractPath === path)?.declaration, checkMapping: (modelSchema, eventSchema, destination, expression, path) => - this.checkMapping(modelSchema, eventSchema, destination, expression, path, reject) + new ProjectionMappingCapabilities(modelSchema, reject).check({ eventSchema, destination, expression, path }) }); } - - private static checkAutoMap( - modelSchema: JsonSchema, eventSchema: JsonSchema, explicit: Record, exclusions: string[], - path: string, reject: (path: string, reason: string) => never, accumulators: ReadonlySet - ): void { - for (const [destination] of Object.entries(modelSchema.properties ?? {})) { - if (Object.keys(explicit).some(name => name.toLowerCase() === destination.toLowerCase()) || - exclusions.some(name => name.toLowerCase() === destination.toLowerCase())) continue; - const candidates = Object.keys(eventSchema.properties ?? {}).filter(source => source.toLowerCase() === destination.toLowerCase()); - if (candidates.length > 1) reject(`${path}.AutoMap.${destination}`, `inferred AutoMap source is ambiguous: ${candidates.join(', ')}`); - if (candidates.length) this.checkMapping(modelSchema, eventSchema, destination, candidates[0], `${path}.AutoMap.${destination}`, reject, accumulators); - } - } - - private static checkMapping( - modelSchema: JsonSchema, eventSchema: JsonSchema, destination: string, expression: string, - path: string, reject: (path: string, reason: string) => never, accumulators?: ReadonlySet - ): void { - const fail = (reason: string): never => reject(path, `expression '${expression}': ${reason}`); - const target = modelSchema.properties?.[destination] - ?? fail('dynamic or unknown destination paths require a kernel-backed test'); - if (destination.includes('.') || destination.includes('$')) fail('dynamic or unknown destination paths require a kernel-backed test'); - if (destination.toLowerCase() === 'id' || Object.keys(modelSchema.properties ?? {}).some(name => - name !== destination && name.toLowerCase() === destination.toLowerCase())) { - fail('identifier or case-insensitively colliding target mappings require a kernel-backed test (ChronicleKernelScenario / live kernel)'); - } - if (target.type === 'object' || target.type === 'array') fail('object/array target mappings require a kernel-backed test (ChronicleKernelScenario / live kernel)'); - this.checkSchema(target, path, (_path, reason) => fail(reason)); - if (expression === '$eventSourceId') { - if (target.type !== 'string' || (target.format && target.format !== 'guid')) { - fail('$eventSourceId requires a string or GUID target; other targets require a kernel-backed test (ChronicleKernelScenario / live kernel)'); - } - return; - } - if (expression === '$null') return; - if (expression.startsWith('$setOnce(')) fail('set once requires a kernel-backed test'); - if (expression.startsWith('$eventContext(')) { - if (expression === '$eventContext(Occurred)') fail('raw Occurred is converted inconsistently by the kernel; only its Year, Month and Day paths are supported'); - if (/^\$eventContext\(.+\(\)\)$/.test(expression)) fail('derived event-context functions require a kernel-backed test'); - const path = /^\$eventContext\(([^()]*)\)$/.exec(expression)?.[1]; - const strings = ['EventSourceId', 'EventStore', 'Namespace', 'EventSourceType', 'EventStreamType', - 'EventStreamId', 'Subject', 'Subject.Value', 'Hash', 'CorrelationId', 'CorrelationId.Value', - 'CausedBy.Subject', 'CausedBy.Name', 'CausedBy.UserName', 'EventType.Id.Value', 'SequenceNumber', 'SequenceNumber.Value']; - const integers = ['Occurred.Year', 'Occurred.Month', 'Occurred.Day', 'EventType.Generation.Value']; - if (path && ((strings.includes(path) && target.type === 'string' && !target.format) || - (integers.includes(path) && target.type === 'integer' && target.format === 'int32'))) return; - fail('event-context path and target schema require a kernel-backed test'); - } - if (/^\$value\([\p{L}\p{Mn}\p{Nd}\p{Pc} ._/:*+-]*\)$/u.test(expression)) { - const text = expression.slice(7, -1); - if (target.format === 'date-time') fail('$value date-time literals require a kernel-backed test'); - if (this.numeric(target)) { - if (!(target.type === 'integer' ? /^[+-]?\d+$/.test(text) : /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(text)) || !Number.isFinite(Number(text)) || - (target.type === 'integer' && (!Number.isSafeInteger(Number(text)) || - (target.format === 'int32' && (Number(text) < -2147483648 || Number(text) > 2147483647)) || - (target.format === 'uint32' && (Number(text) < 0 || Number(text) > 4294967295))))) { - fail('$value numeric literal is outside the supported finite/integer range'); - } - } else if (target.type === 'boolean' && !/^(?:true|false)$/i.test(text)) { - fail('$value boolean literal must be true or false'); - } else if (target.format === 'guid' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) { - fail('$value GUID literal is not canonical'); - } else if (target.type === 'object' || target.type === 'array') { - fail('$value object/array literals require a kernel-backed test'); - } - return; - } - if (ProjectionArithmetic.isArithmetic(expression)) { - ProjectionArithmetic.validate(expression, target, eventSchema, fail); - return; - } - if (/^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*$/.test(expression)) { - if (['true', 'True', 'false', 'False'].includes(expression)) { - fail('kernel resolves this expression as a boolean literal before event content'); - } - if (expression.includes('.')) fail('nested event property paths require a kernel-backed test'); - const source = this.propertyAt(eventSchema, expression); - if (!source) return fail(`event property '${expression}' is absent from the participating event schema`); - this.checkSchema(source, path, (_path, reason) => fail(reason)); - if (accumulators?.has(destination) && !this.numeric(source)) { - fail('assigning non-numeric event properties to an arithmetic accumulator requires a kernel-backed test; missing values can clear it'); - } - if (!this.compatible(source, target)) { - fail(`source '${expression}' (${source.type}${source.format ? `/${source.format}` : ''}) to target '${destination}' (${target.type}${target.format ? `/${target.format}` : ''}) requires a kernel-backed test`); - } - return; - } - if (expression.startsWith('$context.')) { - return fail('is a legacy expression the kernel does not resolve; event-context mappings use $eventContext(...)'); - } - fail('requires a kernel-backed test'); - } - - private static compatible(source: JsonSchema, target: JsonSchema): boolean { - if (JSON.stringify(source.type) === JSON.stringify(target.type) && source.format === target.format) { - return source.type !== 'object' && source.type !== 'array'; - } - if (source.type === 'string' && !source.format && Array.isArray(target.type) && - target.type[0] === 'string' && target.type[1] === 'null') return true; - if (source.type === 'string' && !source.format && - ((target.type === 'number' && (!target.format || target.format === 'double')) || - (target.type === 'integer' && ['int32', 'uint32'].includes(target.format ?? '')))) return true; - if (source.type === 'string' && source.format === 'guid' && target.type === 'string' && !target.format) return true; - return source.type === 'integer' && ['int32', 'uint32'].includes(source.format ?? '') && - target.type === 'number' && (!target.format || target.format === 'double'); - } - - private static checkInitialValue(value: unknown, target: JsonSchema, path: string, reject: (path: string, reason: string) => never): void { - if (value === null && Array.isArray(target.type) && target.type.includes('null')) return; - if (target.type === 'object' || target.type === 'array' || target.format === 'date-time') { - reject(path, 'initial object, array, or date-time values require a kernel-backed test'); - } - if (target.type === 'integer' || target.type === 'number') { - if (typeof value !== 'number') reject(path, 'initial numeric value must be a JSON number'); - const outsideIntegerRange = !Number.isSafeInteger(value) || - (target.format === 'int32' && (value < -2147483648 || value > 2147483647)) || - (target.format === 'uint32' && (value < 0 || value > 4294967295)); - if (!Number.isFinite(value) || (target.type === 'integer' && outsideIntegerRange)) { - reject(path, 'initial numeric value is outside the supported finite/integer range'); - } - } else if (target.type === 'boolean') { - if (typeof value !== 'boolean') reject(path, 'initial boolean value must be a JSON boolean'); - } else if (target.type === 'string' || Array.isArray(target.type)) { - if (typeof value !== 'string') reject(path, 'initial string value must be a JSON string'); - if (target.format === 'guid' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) { - reject(path, 'initial GUID value must be canonical lowercase text'); - } - } else reject(path, 'initial value requires a kernel-backed test'); - } - - private static propertyAt(schema: JsonSchema, path: string): JsonSchema | undefined { - return path.split('.').reduce((current, segment) => current?.properties?.[segment], schema); - } - - private static numeric(schema: JsonSchema): boolean { - return (schema.type === 'number' && (!schema.format || schema.format === 'double')) || - (schema.type === 'integer' && ['int32', 'uint32'].includes(schema.format ?? '')); - } - - private static checkProtection(schema: JsonSchema, path: string, reject: (path: string, reason: string) => never): void { - if (schema.compliance?.length || schema.security?.length) reject(path, 'protected fields require a kernel-backed test'); - for (const [name, property] of Object.entries(schema.properties ?? {})) this.checkProtection(property, `${path}.${name}`, reject); - if (schema.items) this.checkProtection(schema.items, `${path}.items`, reject); - if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { - this.checkProtection(schema.additionalProperties, `${path}.additionalProperties`, reject); - } - } - - private static checkSchema(schema: JsonSchema, path: string, reject: (path: string, reason: string) => never): void { - if (schema.compliance?.length || schema.security?.length) reject(path, 'protected fields require a kernel-backed test'); - if (!schema.type || schema.type === 'null' || (Array.isArray(schema.type) && - (schema.type.length !== 2 || schema.type[0] !== 'string' || schema.type[1] !== 'null')) || - (schema.format && !['guid', 'date-time', 'double', 'int32', 'uint32'].includes(schema.format)) || - (schema.format === 'date-time' && schema.type !== 'string') || - (schema.format === 'guid' && schema.type !== 'string') || - (schema.format === 'double' && schema.type !== 'number') || - (['int32', 'uint32'].includes(schema.format ?? '') && schema.type !== 'integer')) { - reject(path, `schema type/format '${schema.type ?? 'unknown'}${schema.format ? `/${schema.format}` : ''}' requires a kernel-backed test`); - } - if (schema.type === 'integer' && !this.numeric(schema)) reject(path, 'integer width requires a kernel-backed test'); - if (schema.type === 'array' && schema.items) this.checkSchema(schema.items, path, reject); - if (schema.type === 'object') { - for (const property of Object.values(schema.properties ?? {})) this.checkSchema(property, path, reject); - } - } } diff --git a/Source/testing/projections/ProjectionCapabilityReject.ts b/Source/testing/projections/ProjectionCapabilityReject.ts new file mode 100644 index 00000000..3aa82229 --- /dev/null +++ b/Source/testing/projections/ProjectionCapabilityReject.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. + +/** Reports the first unsupported operation at its original contract path. */ +export type ProjectionCapabilityReject = (path: string, reason: string, fallback?: string) => never; diff --git a/Source/testing/projections/ProjectionCapabilityReporter.ts b/Source/testing/projections/ProjectionCapabilityReporter.ts new file mode 100644 index 00000000..72e4c60c --- /dev/null +++ b/Source/testing/projections/ProjectionCapabilityReporter.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 type { ProjectionDefinition } from '@cratis/chronicle.contracts'; +import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js'; +import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js'; + +/** Resolves diagnostics back to their original declaration, including inferred AutoMap. */ +export class ProjectionCapabilityReporter { + constructor(private readonly compiled: CompiledProjectionDefinitions, private readonly definition: ProjectionDefinition) {} + + readonly reject = (path: string, reason: string, fallback?: string): never => { + const model = String(this.definition.ReadModel ?? this.definition.Identifier ?? ''); + throw new UnsupportedProjectionOperation(model, path, this.declaration(path, fallback), reason); + }; + + private declaration(path: string, fallback?: string): string { + const provenance = this.compiled.provenance.get(this.definition); + const exact = provenance?.find(entry => entry.contractPath === path)?.declaration; + const join = provenance?.find(entry => entry.contractPath.startsWith('Join[') && path.startsWith(`${entry.contractPath}.`))?.declaration; + return exact ?? join ?? fallback ?? this.inferred(path); + } + + private inferred(path: string): string { + const source = this.compiled.provenance.get(this.definition)?.find(entry => entry.contractPath.startsWith('From['))?.declaration ?? '.from'; + if (path.includes('.AutoMap.')) return `${source} (AutoMap)`; + if (path.startsWith('InitialModelState')) return '.withInitialValues'; + if (path.startsWith('ReadModel')) return source; + return 'contract'; + } +} diff --git a/Source/testing/projections/ProjectionDefinitionCapabilities.ts b/Source/testing/projections/ProjectionDefinitionCapabilities.ts new file mode 100644 index 00000000..4ff0c49b --- /dev/null +++ b/Source/testing/projections/ProjectionDefinitionCapabilities.ts @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { AutoMap } from '@cratis/chronicle.contracts'; +import { EventSequenceId } from '../../eventSequences/EventSequenceId.js'; +import type { ContractEventType } from '../../projections/declarative/ProjectionBuilderCore.js'; +import { eventContractPath } from '../../projections/eventContractPath.js'; +import type { ProjectionCapabilityProvenance } from '../../projections/ProjectionCapabilityProvenance.js'; +import type { ProjectionCapabilityReject } from './ProjectionCapabilityReject.js'; + +/** Subscription shapes that must be rejected before inspecting any model schema. */ +export class ProjectionDefinitionCapabilities { + static validate(wire: Record, provenance: readonly ProjectionCapabilityProvenance[], reject: ProjectionCapabilityReject): void { + if (provenance.find(entry => entry.contractPath === 'Variant')) reject('Variant', 'variants require a kernel-backed test'); + this.subscription(wire, reject); + for (const section of ['Nested', 'RemovedWithJoin']) this.unsupportedSection(wire, section, reject); + if ((wire.FromEvery as unknown[] | undefined)?.length) reject('FromEvery', 'derivative projections require a kernel-backed test'); + if (wire.FromEventProperty) reject('FromEventProperty', 'event-property subscriptions require a kernel-backed test'); + this.all(wire, provenance, reject); + } + + private static subscription(wire: Record, reject: ProjectionCapabilityReject): void { + if (wire.IsActive === false) reject('IsActive', 'passive projections require a kernel-backed test'); + if (wire.SubscribesToAllEvents === true) reject('SubscribesToAllEvents', 'subscribe-to-all projections require a kernel-backed test'); + if (wire.EventSequenceId !== EventSequenceId.eventLog.value) reject('EventSequenceId', 'non-default event sequences require a kernel-backed test'); + } + + private static unsupportedSection(wire: Record, section: string, reject: ProjectionCapabilityReject): void { + const value = wire[section]; + const paths = Array.isArray(value) ? value.map((entry: { Key: ContractEventType }) => eventContractPath(section, entry.Key)) + : Object.keys(value as Record ?? {}).map(property => `${section}.${property}`); + if (paths.length) reject(paths[0], `${section === 'Nested' ? 'nested projections' : 'removedWithJoin'} require a kernel-backed test`); + } + + private static all(wire: Record, provenance: readonly ProjectionCapabilityProvenance[], reject: ProjectionCapabilityReject): void { + const all = wire.All as { Properties?: Record; IncludeChildren?: boolean; AutoMap?: AutoMap } | undefined; + if (provenance.some(entry => entry.contractPath === 'All') || this.hasAllMappings(all)) { + reject('All', 'fromEvery/all subscriptions require a kernel-backed test'); + } + } + + private static hasAllMappings(all: { Properties?: Record; IncludeChildren?: boolean; AutoMap?: AutoMap } | undefined): boolean { + return !!(all?.IncludeChildren || Object.keys(all?.Properties ?? {}).length || all?.AutoMap === AutoMap.Enabled); + } +} diff --git a/Source/testing/projections/ProjectionEventSchemaResolver.ts b/Source/testing/projections/ProjectionEventSchemaResolver.ts new file mode 100644 index 00000000..98176bce --- /dev/null +++ b/Source/testing/projections/ProjectionEventSchemaResolver.ts @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ContractEventType } from '../../projections/declarative/ProjectionBuilderCore.js'; +import { getEventTypeMapKey } from '../../projections/modelBound/childrenAndNestedBuilder.js'; +import type { ProjectionEventSchema } from '../../projections/ProjectionEventSchema.js'; +import type { JsonSchema } from '../../schemas/JsonSchema.js'; +import type { ProjectionCapabilityReject } from './ProjectionCapabilityReject.js'; + +/** Tracks generations across joins, roots, removals, and children in validation order. */ +export class ProjectionEventSchemaResolver { + private readonly generations = new Map(); + + constructor(private readonly catalog: ReadonlyMap, private readonly reject: ProjectionCapabilityReject) {} + + readonly require = (eventType: ContractEventType, path: string): JsonSchema => { + const previous = this.generations.get(eventType.Id); + if (previous !== undefined && previous !== eventType.Generation) this.reject(path, 'multiple generations of the same event-type id require a kernel-backed test'); + this.generations.set(eventType.Id, eventType.Generation); + const event = this.catalog.get(getEventTypeMapKey(eventType)) ?? this.reject(path, 'participating event schema is unavailable'); + return event.schema; + }; +} diff --git a/Source/testing/projections/ProjectionExpressionCapabilities.ts b/Source/testing/projections/ProjectionExpressionCapabilities.ts new file mode 100644 index 00000000..8e7fedeb --- /dev/null +++ b/Source/testing/projections/ProjectionExpressionCapabilities.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 type { JsonSchema } from '../../schemas/JsonSchema.js'; +import { ProjectionArithmetic } from './ProjectionArithmetic.js'; +import { ProjectionSchemaCapabilities } from './ProjectionSchemaCapabilities.js'; + +/** Fixture-backed expression families, in kernel validation order. */ +export class ProjectionExpressionCapabilities { + static check(expression: string, target: JsonSchema, eventSchema: JsonSchema, fail: (reason: string) => never): boolean { + if (this.context(expression, target, fail)) return true; + if (/^\$value\([\p{L}\p{Mn}\p{Nd}\p{Pc} ._/:*+-]*\)$/u.test(expression)) { + this.literal(expression.slice(7, -1), target, fail); + return true; + } + if (ProjectionArithmetic.isArithmetic(expression)) { + ProjectionArithmetic.validate(expression, target, eventSchema, fail); + return true; + } + return false; + } + + private static context(expression: string, target: JsonSchema, fail: (reason: string) => never): boolean { + if (expression === '$eventSourceId') { + this.eventSourceId(target, fail); + return true; + } + if (expression === '$null') return true; + if (expression.startsWith('$setOnce(')) fail('set once requires a kernel-backed test'); + if (!expression.startsWith('$eventContext(')) return false; + this.eventContext(expression, target, fail); + return true; + } + + private static eventSourceId(target: JsonSchema, fail: (reason: string) => never): void { + if (target.type !== 'string' || !this.identifierFormat(target)) { + fail('$eventSourceId requires a string or GUID target; other targets require a kernel-backed test (ChronicleKernelScenario / live kernel)'); + } + } + + private static identifierFormat(target: JsonSchema): boolean { + return !target.format || target.format === 'guid'; + } + + private static eventContext(expression: string, target: JsonSchema, fail: (reason: string) => never): void { + if (expression === '$eventContext(Occurred)') fail('raw Occurred is converted inconsistently by the kernel; only its Year, Month and Day paths are supported'); + if (/^\$eventContext\(.+\(\)\)$/.test(expression)) fail('derived event-context functions require a kernel-backed test'); + const path = /^\$eventContext\(([^()]*)\)$/.exec(expression)?.[1]; + if (path && this.contextTarget(path, target)) return; + fail('event-context path and target schema require a kernel-backed test'); + } + + private static contextTarget(path: string, target: JsonSchema): boolean { + const strings = ['EventSourceId', 'EventStore', 'Namespace', 'EventSourceType', 'EventStreamType', + 'EventStreamId', 'Subject', 'Subject.Value', 'Hash', 'CorrelationId', 'CorrelationId.Value', + 'CausedBy.Subject', 'CausedBy.Name', 'CausedBy.UserName', 'EventType.Id.Value', 'SequenceNumber', 'SequenceNumber.Value']; + const integers = ['Occurred.Year', 'Occurred.Month', 'Occurred.Day', 'EventType.Generation.Value']; + if (strings.includes(path)) return this.plainString(target); + return integers.includes(path) && this.int32(target); + } + + private static plainString(target: JsonSchema): boolean { + return target.type === 'string' && !target.format; + } + + private static int32(target: JsonSchema): boolean { + return target.type === 'integer' && target.format === 'int32'; + } + + private static literal(text: string, target: JsonSchema, fail: (reason: string) => never): void { + if (target.format === 'date-time') fail('$value date-time literals require a kernel-backed test'); + if (ProjectionSchemaCapabilities.numeric(target)) this.numericLiteral(text, target, fail); + else this.scalarLiteral(text, target, fail); + } + + private static numericLiteral(text: string, target: JsonSchema, fail: (reason: string) => never): void { + const spelling = target.type === 'integer' ? /^[+-]?\d+$/ : /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/; + if (!spelling.test(text) || !this.inRange(Number(text), target)) { + fail('$value numeric literal is outside the supported finite/integer range'); + } + } + + private static inRange(number: number, target: JsonSchema): boolean { + if (!Number.isFinite(number)) return false; + return target.type !== 'integer' || !ProjectionSchemaCapabilities.outsideIntegerRange(number, target); + } + + private static scalarLiteral(text: string, target: JsonSchema, fail: (reason: string) => never): void { + if (target.type === 'boolean') this.booleanLiteral(text, fail); + else if (target.format === 'guid') this.guidLiteral(text, fail); + else if (target.type === 'object' || target.type === 'array') fail('$value object/array literals require a kernel-backed test'); + } + + private static booleanLiteral(text: string, fail: (reason: string) => never): void { + if (!/^(?:true|false)$/i.test(text)) fail('$value boolean literal must be true or false'); + } + + private static guidLiteral(text: string, fail: (reason: string) => never): void { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) fail('$value GUID literal is not canonical'); + } +} diff --git a/Source/testing/projections/ProjectionInitialValueCapabilities.ts b/Source/testing/projections/ProjectionInitialValueCapabilities.ts new file mode 100644 index 00000000..fd23e149 --- /dev/null +++ b/Source/testing/projections/ProjectionInitialValueCapabilities.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 type { JsonSchema } from '../../schemas/JsonSchema.js'; +import type { ProjectionCapabilityReject } from './ProjectionCapabilityReject.js'; +import { ProjectionSchemaCapabilities } from './ProjectionSchemaCapabilities.js'; + +/** Validates initial state without changing its JSON order or defaulting semantics. */ +export class ProjectionInitialValueCapabilities { + static validate(initialState: string, schema: JsonSchema, reject: ProjectionCapabilityReject): Record { + let initial: unknown; + try { initial = JSON.parse(initialState); } + catch { reject('InitialModelState', 'initial values must be a JSON object'); } + if (initial === null || typeof initial !== 'object' || Array.isArray(initial)) { + reject('InitialModelState', 'initial values must be a JSON object'); + } + for (const [property, value] of Object.entries(initial as Record)) { + const target = schema.properties?.[property] ?? reject(`InitialModelState.${property}`, 'initial value has no read-model schema'); + ProjectionSchemaCapabilities.check(target, `InitialModelState.${property}`, reject); + this.check(value, target, `InitialModelState.${property}`, reject); + } + return initial as Record; + } + + private static check(value: unknown, target: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (value === null && this.nullable(target)) return; + if (this.structured(target) || target.format === 'date-time') { + reject(path, 'initial object, array, or date-time values require a kernel-backed test'); + } + this.scalar(value, target, path, reject); + } + + private static scalar(value: unknown, target: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (target.type === 'integer' || target.type === 'number') return this.number(value, target, path, reject); + if (target.type === 'boolean') return this.boolean(value, path, reject); + if (target.type === 'string' || Array.isArray(target.type)) return this.string(value, target, path, reject); + reject(path, 'initial value requires a kernel-backed test'); + } + + private static nullable(schema: JsonSchema): boolean { + return Array.isArray(schema.type) && schema.type.includes('null'); + } + + private static structured(schema: JsonSchema): boolean { + return schema.type === 'object' || schema.type === 'array'; + } + + private static number(value: unknown, target: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (typeof value !== 'number') reject(path, 'initial numeric value must be a JSON number'); + if (!Number.isFinite(value) || this.invalidInteger(value as number, target)) { + reject(path, 'initial numeric value is outside the supported finite/integer range'); + } + } + + private static invalidInteger(value: number, target: JsonSchema): boolean { + return target.type === 'integer' && ProjectionSchemaCapabilities.outsideIntegerRange(value, target); + } + + private static boolean(value: unknown, path: string, reject: ProjectionCapabilityReject): void { + if (typeof value !== 'boolean') reject(path, 'initial boolean value must be a JSON boolean'); + } + + private static string(value: unknown, target: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (typeof value !== 'string') reject(path, 'initial string value must be a JSON string'); + if (target.format === 'guid' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value as string)) { + reject(path, 'initial GUID value must be canonical lowercase text'); + } + } +} diff --git a/Source/testing/projections/ProjectionMappingCapabilities.ts b/Source/testing/projections/ProjectionMappingCapabilities.ts new file mode 100644 index 00000000..6b92830d --- /dev/null +++ b/Source/testing/projections/ProjectionMappingCapabilities.ts @@ -0,0 +1,85 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { JsonSchema } from '../../schemas/JsonSchema.js'; +import type { ProjectionCapabilityReject } from './ProjectionCapabilityReject.js'; +import { ProjectionExpressionCapabilities } from './ProjectionExpressionCapabilities.js'; +import { ProjectionSchemaCapabilities } from './ProjectionSchemaCapabilities.js'; +import type { ProjectionMappingValidation } from './ProjectionMappingValidation.js'; + +/** Checks explicit mappings and inferred AutoMap through the same schema boundary. */ +export class ProjectionMappingCapabilities { + constructor(private readonly schema: JsonSchema, private readonly reject: ProjectionCapabilityReject, private readonly accumulators = new Set()) {} + + check(mapping: ProjectionMappingValidation): void { + const { eventSchema, destination, expression, path } = mapping; + const fail = (reason: string): never => this.reject(path, `expression '${expression}': ${reason}`); + const target = this.schema.properties?.[destination] ?? fail('dynamic or unknown destination paths require a kernel-backed test'); + this.checkDestination(mapping, fail); + this.checkTarget(target, mapping, fail); + if (ProjectionExpressionCapabilities.check(expression, target, eventSchema, fail)) return; + if (/^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*$/.test(expression)) { + return this.property(mapping, fail); + } + if (expression.startsWith('$context.')) fail('is a legacy expression the kernel does not resolve; event-context mappings use $eventContext(...)'); + fail('requires a kernel-backed test'); + } + + autoMap(eventSchema: JsonSchema, explicit: Record, exclusions: string[], path: string): void { + for (const property of Object.keys(this.schema.properties ?? {})) { + if (this.includes(Object.keys(explicit), property) || this.includes(exclusions, property)) continue; + this.autoMapProperty(eventSchema, property, path); + } + } + + private checkTarget(target: JsonSchema, { path }: ProjectionMappingValidation, fail: (reason: string) => never): void { + if (target.type === 'object' || target.type === 'array') fail('object/array target mappings require a kernel-backed test (ChronicleKernelScenario / live kernel)'); + ProjectionSchemaCapabilities.check(target, path, (_path, reason) => fail(reason)); + } + + private autoMapProperty(eventSchema: JsonSchema, destination: string, path: string): void { + const matches = Object.keys(eventSchema.properties ?? {}).filter(name => name.toLowerCase() === destination.toLowerCase()); + if (matches.length > 1) this.reject(`${path}.AutoMap.${destination}`, `inferred AutoMap source is ambiguous: ${matches.join(', ')}`); + if (matches.length) this.check({ eventSchema, destination, expression: matches[0], path: `${path}.AutoMap.${destination}` }); + } + + private includes(names: string[], property: string): boolean { + return names.some(name => name.toLowerCase() === property.toLowerCase()); + } + + private checkDestination({ destination }: ProjectionMappingValidation, fail: (reason: string) => never): void { + if (destination.includes('.') || destination.includes('$')) fail('dynamic or unknown destination paths require a kernel-backed test'); + const collision = Object.keys(this.schema.properties ?? {}).some(name => name !== destination && name.toLowerCase() === destination.toLowerCase()); + if (destination.toLowerCase() === 'id' || collision) { + fail('identifier or case-insensitively colliding target mappings require a kernel-backed test (ChronicleKernelScenario / live kernel)'); + } + } + + private property(mapping: ProjectionMappingValidation, fail: (reason: string) => never): void { + const { eventSchema, destination, expression, path } = mapping; + this.checkSourcePath(mapping, fail); + const source = eventSchema.properties?.[expression] ?? fail(`event property '${expression}' is absent from the participating event schema`); + ProjectionSchemaCapabilities.check(source, path, (_path, reason) => fail(reason)); + this.checkAccumulator(source, destination, fail); + this.compatibility(source, mapping, fail); + } + + private checkSourcePath({ expression }: ProjectionMappingValidation, fail: (reason: string) => never): void { + if (['true', 'True', 'false', 'False'].includes(expression)) fail('kernel resolves this expression as a boolean literal before event content'); + if (expression.includes('.')) fail('nested event property paths require a kernel-backed test'); + } + + private compatibility(source: JsonSchema, { destination, expression }: ProjectionMappingValidation, fail: (reason: string) => never): void { + const target = this.schema.properties![destination]; + if (!ProjectionSchemaCapabilities.compatible(source, target)) { + fail(`source '${expression}' (${source.type}${source.format ? `/${source.format}` : ''}) to target '${destination}' (${target.type}${target.format ? `/${target.format}` : ''}) requires a kernel-backed test`); + } + } + + private checkAccumulator(source: JsonSchema, destination: string, fail: (reason: string) => never): void { + if (this.accumulators.has(destination) && !ProjectionSchemaCapabilities.numeric(source)) { + fail('assigning non-numeric event properties to an arithmetic accumulator requires a kernel-backed test; missing values can clear it'); + } + } + +} diff --git a/Source/testing/projections/ProjectionMappingValidation.ts b/Source/testing/projections/ProjectionMappingValidation.ts new file mode 100644 index 00000000..6ad74b34 --- /dev/null +++ b/Source/testing/projections/ProjectionMappingValidation.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 type { JsonSchema } from '../../schemas/JsonSchema.js'; + +/** One compiled mapping and the event schema it reads. */ +export interface ProjectionMappingValidation { + readonly eventSchema: JsonSchema; + readonly destination: string; + readonly expression: string; + readonly path: string; +} diff --git a/Source/testing/projections/ProjectionReadModelCapabilities.ts b/Source/testing/projections/ProjectionReadModelCapabilities.ts new file mode 100644 index 00000000..fa326392 --- /dev/null +++ b/Source/testing/projections/ProjectionReadModelCapabilities.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 type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js'; +import type { JsonSchema } from '../../schemas/JsonSchema.js'; +import type { ProjectionCapabilityReject } from './ProjectionCapabilityReject.js'; +import { ProjectionSchemaCapabilities } from './ProjectionSchemaCapabilities.js'; + +/** Registered read-model evidence and sink-managed identifier requirements. */ +export class ProjectionReadModelCapabilities { + static schema(compiled: CompiledProjectionDefinitions, model: string, reject: ProjectionCapabilityReject): JsonSchema { + const readModel = compiled.readModels.find(candidate => candidate.Type.Identifier === model) + ?? reject('ReadModel', 'a single registered read-model schema is required'); + let schema: JsonSchema; + try { schema = JSON.parse(readModel.Schema) as JsonSchema; } + catch { reject('ReadModel.Schema', 'read-model schema is not valid JSON'); } + ProjectionSchemaCapabilities.checkProtection(schema!, 'ReadModel.Schema', reject); + this.identifier(schema!.properties?.id, reject); + this.collisions(schema!, reject); + return schema!; + } + + private static identifier(identifier: JsonSchema | undefined, reject: ProjectionCapabilityReject): void { + if (!identifier || !this.supportedIdentifier(identifier)) { + reject('ReadModel.Schema.id', 'only a lowercase id with a fixture-backed string, GUID, or number/double schema is supported; other identifiers require a kernel-backed test (ChronicleKernelScenario / live kernel)'); + } + } + + private static supportedIdentifier(identifier: JsonSchema): boolean { + if (identifier.type === 'string') return !identifier.format || identifier.format === 'guid'; + return identifier.type === 'number' && identifier.format === 'double'; + } + + private static collisions(schema: JsonSchema, reject: ProjectionCapabilityReject): void { + const names = Object.keys(schema.properties ?? {}); + for (const [index, name] of names.entries()) { + if (names.slice(index + 1).some(other => other.toLowerCase() === name.toLowerCase())) { + reject(`ReadModel.Schema.${name}`, 'case-insensitively colliding read-model properties require a kernel-backed test (ChronicleKernelScenario / live kernel)'); + } + } + } +} diff --git a/Source/testing/projections/ProjectionRootCapabilities.ts b/Source/testing/projections/ProjectionRootCapabilities.ts new file mode 100644 index 00000000..567b5e7c --- /dev/null +++ b/Source/testing/projections/ProjectionRootCapabilities.ts @@ -0,0 +1,75 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { AutoMap } from '@cratis/chronicle.contracts'; +import type { FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js'; +import { eventContractPath } from '../../projections/eventContractPath.js'; +import type { JsonSchema } from '../../schemas/JsonSchema.js'; +import { ProjectionArithmetic } from './ProjectionArithmetic.js'; +import type { ProjectionCapabilityReject } from './ProjectionCapabilityReject.js'; +import type { ProjectionEventSchemaResolver } from './ProjectionEventSchemaResolver.js'; +import { ProjectionMappingCapabilities } from './ProjectionMappingCapabilities.js'; + +/** Validates root subscriptions and accumulator interactions before child traversal. */ +export class ProjectionRootCapabilities { + readonly from: FromRecord[]; + readonly removedWith: RemovedWithRecord[]; + + constructor(private readonly schema: JsonSchema, private readonly wire: Record, private readonly reject: ProjectionCapabilityReject) { + this.from = wire.From as FromRecord[] ?? []; + this.removedWith = wire.RemovedWith as RemovedWithRecord[] ?? []; + } + + validate(resolver: ProjectionEventSchemaResolver): void { + this.checkOverlaps(); + const accumulators = new Set(this.from.flatMap(entry => Object.entries(entry.Value.Properties ?? {}) + .filter(([, expression]) => ProjectionArithmetic.isArithmetic(expression)).map(([property]) => property))); + this.checkAccumulatorChildren(accumulators); + for (const entry of this.from) this.checkClearedAccumulators(entry, accumulators); + const mappings = new ProjectionMappingCapabilities(this.schema, this.reject, accumulators); + for (const entry of [...this.from, ...this.removedWith]) this.subscription(entry, resolver, mappings); + } + + private checkOverlaps(): void { + for (const entry of this.from) { + if (this.removedWith.some(removal => removal.Key.Id === entry.Key.Id)) { + this.reject(eventContractPath('From', entry.Key), 'events subscribed through both From and RemovedWith require a kernel-backed test (ChronicleKernelScenario / live kernel)'); + } + } + } + + private checkAccumulatorChildren(accumulators: Set): void { + if (accumulators.size && Object.keys(this.wire.Children as object ?? {}).length) { + this.reject('Children', 'root arithmetic combined with children requires a kernel-backed test'); + } + } + + private checkClearedAccumulators(entry: FromRecord, accumulators: Set): void { + for (const [property, expression] of Object.entries(entry.Value.Properties ?? {})) { + if (expression === '$null' && accumulators.has(property)) { + this.reject(`${eventContractPath('From', entry.Key)}.Properties.${property}`, + 'clearing an arithmetic accumulator requires a kernel-backed test; the kernel rejects arithmetic on a present null'); + } + } + } + + private subscription(entry: FromRecord | RemovedWithRecord, resolver: ProjectionEventSchemaResolver, mappings: ProjectionMappingCapabilities): void { + const section = this.from.includes(entry as FromRecord) ? 'From' : 'RemovedWith'; + const path = eventContractPath(section, entry.Key); + const eventSchema = resolver.require(entry.Key, path); + if (entry.Value.Key !== '$eventSourceId') this.reject(`${path}.Key`, 'only $eventSourceId keys are supported'); + if (entry.Value.ParentKey) this.reject(`${path}.ParentKey`, 'parent keys require a kernel-backed test'); + if (section === 'RemovedWith') return; + this.properties(entry as FromRecord, eventSchema, path, mappings); + } + + private properties(entry: FromRecord, eventSchema: JsonSchema, path: string, mappings: ProjectionMappingCapabilities): void { + const properties = entry.Value.Properties ?? {}; + for (const [property, expression] of Object.entries(properties)) { + mappings.check({ eventSchema, destination: property, expression, path: `${path}.Properties.${property}` }); + } + if (this.wire.AutoMap !== AutoMap.Disabled && !ProjectionArithmetic.suppressesAutoMap(properties)) { + mappings.autoMap(eventSchema, properties, this.wire.NoAutoMapProperties as string[] ?? [], path); + } + } +} diff --git a/Source/testing/projections/ProjectionSchemaCapabilities.ts b/Source/testing/projections/ProjectionSchemaCapabilities.ts new file mode 100644 index 00000000..6872f2a0 --- /dev/null +++ b/Source/testing/projections/ProjectionSchemaCapabilities.ts @@ -0,0 +1,112 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { JsonSchema } from '../../schemas/JsonSchema.js'; +import type { ProjectionCapabilityReject } from './ProjectionCapabilityReject.js'; + +/** Schema boundaries shared by root, join, and child mappings. */ +export class ProjectionSchemaCapabilities { + static numeric(schema: JsonSchema): boolean { + if (schema.type === 'number') return this.doubleFormat(schema); + return this.integer(schema); + } + + static compatible(source: JsonSchema, target: JsonSchema): boolean { + if (this.sameSchema(source, target)) return this.scalar(source); + if (this.plainString(source)) return this.textTarget(target); + if (source.type === 'string' && source.format === 'guid') return this.plainString(target); + return this.integerToNumber(source, target); + } + + static check(schema: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + this.checkProtected(schema, path, reject); + if (!this.supportedType(schema.type) || !this.supportedFormat(schema)) { + reject(path, `schema type/format '${schema.type ?? 'unknown'}${schema.format ? `/${schema.format}` : ''}' requires a kernel-backed test`); + } + this.checkIntegerWidth(schema, path, reject); + this.checkItems(schema, path, reject); + if (schema.type === 'object') this.checkProperties(schema, path, reject); + } + + static checkProtection(schema: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + this.checkProtected(schema, path, reject); + for (const [name, property] of Object.entries(schema.properties ?? {})) this.checkProtection(property, `${path}.${name}`, reject); + if (schema.items) this.checkProtection(schema.items, `${path}.items`, reject); + this.checkAdditionalProtection(schema, path, reject); + } + + static outsideIntegerRange(value: number, schema: JsonSchema): boolean { + if (!Number.isSafeInteger(value)) return true; + if (schema.format === 'int32') return value < -2147483648 || value > 2147483647; + if (schema.format === 'uint32') return value < 0 || value > 4294967295; + return false; + } + + private static doubleFormat(schema: JsonSchema): boolean { + return !schema.format || schema.format === 'double'; + } + + private static integer(schema: JsonSchema): boolean { + return schema.type === 'integer' && ['int32', 'uint32'].includes(schema.format ?? ''); + } + + private static sameSchema(source: JsonSchema, target: JsonSchema): boolean { + return JSON.stringify(source.type) === JSON.stringify(target.type) && source.format === target.format; + } + + private static scalar(schema: JsonSchema): boolean { + return schema.type !== 'object' && schema.type !== 'array'; + } + + private static nullableString(type: JsonSchema['type']): boolean { + return Array.isArray(type) && type[0] === 'string' && type[1] === 'null'; + } + + private static checkIntegerWidth(schema: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (schema.type === 'integer' && !this.numeric(schema)) reject(path, 'integer width requires a kernel-backed test'); + } + + private static checkItems(schema: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (schema.type === 'array' && schema.items) this.check(schema.items, path, reject); + } + + private static plainString(schema: JsonSchema): boolean { + return schema.type === 'string' && !schema.format; + } + + private static textTarget(target: JsonSchema): boolean { + if (this.nullableString(target.type)) return true; + return this.numeric(target); + } + + private static integerToNumber(source: JsonSchema, target: JsonSchema): boolean { + if (!this.integer(source)) return false; + return target.type === 'number' && this.numeric(target); + } + + private static supportedType(type: JsonSchema['type']): boolean { + if (!type || type === 'null') return false; + if (!Array.isArray(type)) return true; + return type.length === 2 && this.nullableString(type); + } + + private static supportedFormat(schema: JsonSchema): boolean { + if (!schema.format) return true; + const types: Record = { guid: 'string', 'date-time': 'string', double: 'number', int32: 'integer', uint32: 'integer' }; + return Object.hasOwn(types, schema.format) && schema.type === types[schema.format]; + } + + private static checkProtected(schema: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (schema.compliance?.length || schema.security?.length) reject(path, 'protected fields require a kernel-backed test'); + } + + private static checkProperties(schema: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + for (const property of Object.values(schema.properties ?? {})) this.check(property, path, reject); + } + + private static checkAdditionalProtection(schema: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { + if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { + this.checkProtection(schema.additionalProperties, `${path}.additionalProperties`, reject); + } + } +} From faa3f79cd00a0f8af02d4d02f1a8880b2c6b3b8b Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:22:19 +0100 Subject: [PATCH 5/7] Characterize in-process append validation and commit ordering --- ...erving_validation_and_commit_order.spec.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 Source/testing/for_InProcessEventSequence/when_preserving_validation_and_commit_order.spec.ts diff --git a/Source/testing/for_InProcessEventSequence/when_preserving_validation_and_commit_order.spec.ts b/Source/testing/for_InProcessEventSequence/when_preserving_validation_and_commit_order.spec.ts new file mode 100644 index 00000000..5debfa89 --- /dev/null +++ b/Source/testing/for_InProcessEventSequence/when_preserving_validation_and_commit_order.spec.ts @@ -0,0 +1,95 @@ +// 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 { field } from '@cratis/fundamentals'; +import { chai, describe, it } from 'vitest'; +import { eventType } from '../../events/eventTypeDecorator.js'; +import type { AppendOptions } from '../../eventSequences/AppendOptions.js'; +import { EventSequenceNumber } from '../../eventSequences/EventSequenceNumber.js'; +import { InProcessEventSequence, observeUnsupportedOperations } from '../InProcessEventSequence.js'; +import { UnsupportedEventSequenceOperation } from '../UnsupportedEventSequenceOperation.js'; + +chai.should(); + +@eventType('ValidationOrderRecorded') +class Recorded { + @field(String) name = 'recorded'; +} + +const create = () => new InProcessEventSequence({ constraints: 'disabled' }, [Recorded]); + +async function rejects(pending: Promise, operation: string): Promise { + await pending.then(() => { throw new Error('Expected rejection'); }, error => { + (error instanceof UnsupportedEventSequenceOperation).should.equal(true); + (error as Error).message.should.include(operation); + }); +} + +describe('when preserving validation and commit order', () => { + it('should report an invalid event before an invalid source', async () => { + const sequence = create(); + await rejects(sequence.append('invalid source', null as unknown as object), 'append.event'); + sequence.results.length.should.equal(0); + sequence.appendedEvents.length.should.equal(0); + }); + + it('should latch failures immediately and retain the async append guard until settlement', async () => { + const sequence = create(); + const observed: UnsupportedEventSequenceOperation[] = []; + observeUnsupportedOperations(sequence, error => observed.push(error)); + const first = sequence.append('invalid source', new Recorded()); + observed.length.should.equal(1); + const overlapping = sequence.append('A', new Recorded()); + observed.length.should.equal(2); + await rejects(first, 'append.source'); + await rejects(overlapping, 'Overlapping calls do not have proven ordering.'); + (await sequence.append('A', new Recorded())).sequenceNumber.value.should.equal(0n); + }); + + it('should reject shared batch options before inspecting entries', async () => { + const sequence = create(); + await rejects(sequence.appendMany([null as never], { subject: 42 } as unknown as AppendOptions), 'appendMany.subject'); + sequence.results.length.should.equal(0); + (await sequence.getNextSequenceNumber()).value.should.equal(0n); + }); + + it('should reject malformed entry metadata before invoking the correlation hook', async () => { + let calls = 0; + const sequence = new InProcessEventSequence({ constraints: 'disabled', correlationId: () => { calls++; throw new Error('hook'); } }, [Recorded]); + await rejects(sequence.appendMany([{ eventSourceId: 'A', event: new Recorded(), unexpected: true } as never]), 'appendMany.entry'); + calls.should.equal(0); + }); + + it('should release a failed batch guard and leave its sequence numbers unallocated', async () => { + const sequence = create(); + await rejects(sequence.appendMany('A', [new Recorded(), Object.assign(new Recorded(), { name: null })]), 'appendMany.content'); + sequence.results.length.should.equal(0); + sequence.appendedEvents.length.should.equal(0); + (await sequence.appendMany('B', [new Recorded()]))[0].sequenceNumber.value.should.equal(0n); + }); + + it('should discard staged seeds and release setup ownership after a later invalid event', async () => { + const sequence = create(); + await rejects(sequence.seed('A', [new Recorded(), Object.assign(new Recorded(), { name: null })]), 'append.content'); + sequence.results.length.should.equal(0); + sequence.appendedEvents.length.should.equal(0); + await sequence.seed('B', [new Recorded()]); + (await sequence.append('C', new Recorded())).sequenceNumber.value.should.equal(1n); + sequence.results.length.should.equal(1); + }); + + it('should preserve the kernel whitespace distinction in stored subjects', async () => { + const sequence = create(); + await sequence.append('A', new Recorded(), { subject: '\u0085' }); + await sequence.appendMany('B', [new Recorded()], { subject: '\ufeff' }); + sequence.appendedEvents.map(event => event.context.subject).should.deep.equal(['A', '\ufeff']); + }); + + it('should validate read arguments before route and catalog filters', async () => { + const sequence = create(); + await rejects(sequence.getTailSequenceNumber(' A ', 'bad route', undefined, undefined, [class Missing {}]), 'getTailSequenceNumber.source'); + await rejects(sequence.getForEventSourceIdAndEventTypes(' A ', [], 'bad route'), 'getForEventSourceIdAndEventTypes.filters'); + await rejects(sequence.getFromSequenceNumber(new EventSequenceNumber(-1n), ' A '), 'getFromSequenceNumber.sequenceNumber'); + }); +}); From d9b5de2717afca27f45fda96c3367bacbbbdd567 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:34:33 +0100 Subject: [PATCH 6/7] Separate in-process append staging and history filtering --- Source/testing/InProcessAppendMetadata.ts | 119 +++++++ Source/testing/InProcessBatchAppend.ts | 138 ++++++++ Source/testing/InProcessEventPayload.ts | 100 ++++++ Source/testing/InProcessEventReadCriteria.ts | 11 + Source/testing/InProcessEventReadFilter.ts | 37 ++ Source/testing/InProcessEventSequence.ts | 320 ++++-------------- .../InProcessEventSequenceEnvironment.ts | 16 + Source/testing/InProcessSingleAppend.ts | 99 ++++++ 8 files changed, 583 insertions(+), 257 deletions(-) create mode 100644 Source/testing/InProcessAppendMetadata.ts create mode 100644 Source/testing/InProcessBatchAppend.ts create mode 100644 Source/testing/InProcessEventPayload.ts create mode 100644 Source/testing/InProcessEventReadCriteria.ts create mode 100644 Source/testing/InProcessEventReadFilter.ts create mode 100644 Source/testing/InProcessEventSequenceEnvironment.ts create mode 100644 Source/testing/InProcessSingleAppend.ts diff --git a/Source/testing/InProcessAppendMetadata.ts b/Source/testing/InProcessAppendMetadata.ts new file mode 100644 index 00000000..f3decb8b --- /dev/null +++ b/Source/testing/InProcessAppendMetadata.ts @@ -0,0 +1,119 @@ +// 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'; +import { toContractsGuid } from '../connection/Guid.js'; +import { Identity } from '../identity/Identity.js'; +import type { prepareSingleAppend } from '../eventSequences/prepareSingleAppend.js'; +import type { AppendOptions } from '../eventSequences/AppendOptions.js'; +import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js'; +import { hasNamedTags } from './singleAppendRoute.js'; +import type { InProcessEventSequenceEnvironment } from './InProcessEventSequenceEnvironment.js'; + +/** Prepares only the metadata domain proven by the in-process append fixtures. */ +export class InProcessAppendMetadata { + constructor(private readonly environment: InProcessEventSequenceEnvironment) {} + + batch(options?: AppendOptions): void { + if (options === undefined) return; + if (!this.optionsObject(options)) { + throw this.environment.unsupported('appendMany.options', this.environment.sequenceId, 'Append options must be an object.'); + } + if (hasNamedTags(options.namedTags)) throw this.environment.unsupported('appendMany.namedTags', this.environment.sequenceId, 'Named tags are not fixture-backed.'); + this.batchKeys(options); + this.subject(options); + this.tags(options); + } + + entry(entry: EventForEventSourceId): void { + if (!this.entryObject(entry) || this.unknown(entry, + ['eventSourceId', 'event', 'eventSourceType', 'eventStreamType', 'eventStreamId', 'subject', 'occurred', 'tags', 'namedTags'])) { + throw this.environment.unsupported('appendMany.entry', this.environment.sequenceId, 'Unrecognized per-entry metadata.'); + } + if (hasNamedTags(entry.namedTags)) throw this.environment.unsupported('appendMany.namedTags', this.environment.sequenceId, 'Named tags are not fixture-backed.'); + if (entry.occurred !== undefined) this.checkedDate(entry.occurred, 'appendMany.occurred'); + this.subject(entry); + this.tags(entry); + } + + guid(value: string | Guid, operation: string): void { + try { + toContractsGuid(Guid.as(value)); + if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value.toString())) throw new Error('Invalid GUID.'); + } catch { throw this.environment.unsupported(operation, this.environment.sequenceId, 'Correlation ID must be a valid GUID.'); } + } + + checkedDate(value: Date, operation: string): Date { + if (!(value instanceof Date) || !this.validTime(value)) { + throw this.environment.unsupported(operation, this.environment.sequenceId, 'Occurrence must be within UTC years 1–9999 at millisecond precision.'); + } + return new Date(value); + } + + source(source: string, operation: string): void { + if (typeof source !== 'string' || !/^[A-Za-z0-9_-]+$/.test(source)) { + throw this.environment.unsupported(operation, String(source), 'Only simple source identifiers are fixture-backed.'); + } + } + + private subject(metadata: Pick): void { + if (!this.validSubject(metadata)) { + throw this.environment.unsupported('appendMany.subject', this.environment.sequenceId, 'Subject must be a string.'); + } + } + + private validSubject(metadata: Pick): boolean { + return metadata.subject === undefined || metadata.subject === null || typeof metadata.subject === 'string'; + } + + private tags(metadata: Pick): void { + if (metadata.tags && !this.stringTags(metadata)) { + throw this.environment.unsupported('appendMany.tags', this.environment.sequenceId, 'Only plain string tags are fixture-backed.'); + } + } + + unsupportedOptions(options?: AppendOptions): boolean { + if (options?.concurrencyScope !== undefined || options?.concurrencyScopes !== undefined) return true; + return options?.eventSourceId !== undefined; + } + + storedSubject(subject: string | undefined, eventSourceId: string): string { + // Match the kernel's string.IsNullOrWhiteSpace, including U+0085 but not U+FEFF. + return subject && !/^\p{White_Space}*$/u.test(subject) ? subject : eventSourceId; + } + + private batchKeys(options: AppendOptions): void { + const allowed = ['correlationId', 'sourceType', 'streamType', 'streamId', 'subject', 'occurred', 'tags', 'namedTags']; + if (this.unknown(options, allowed) || this.unsupportedOptions(options)) { + throw this.environment.unsupported('appendMany.options', this.environment.sequenceId, 'Concurrency and unrecognized metadata are not fixture-backed.'); + } + } + + private unknown(value: object, allowed: string[]): boolean { + return Reflect.ownKeys(value).some(key => !allowed.includes(String(key))); + } + + private validTime(value: Date): boolean { + return !Number.isNaN(value.getTime()) && value.getUTCFullYear() >= 1 && value.getUTCFullYear() <= 9999; + } + + private stringTags(metadata: Pick): boolean { + return Array.isArray(metadata.tags) && !metadata.tags.some(tag => typeof tag !== 'string'); + } + + private entryObject(value: unknown): boolean { + return !!value && typeof value === 'object'; + } + + private optionsObject(value: unknown): boolean { + return this.entryObject(value) && !Array.isArray(value); + } + + ambientContext(identity: Identity, chain: ReturnType['causationChain']): boolean { + return identity === Identity.system && this.emptyRoot(chain); + } + + private emptyRoot(chain: ReturnType['causationChain']): boolean { + return chain.length === 2 && Object.keys(chain[0].properties).length === 0; + } +} diff --git a/Source/testing/InProcessBatchAppend.ts b/Source/testing/InProcessBatchAppend.ts new file mode 100644 index 00000000..db21e314 --- /dev/null +++ b/Source/testing/InProcessBatchAppend.ts @@ -0,0 +1,138 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { AppendedEvent } from '../events/AppendedEvent.js'; +import { EventObservationState } from '@cratis/chronicle.contracts'; +import { getEventTypeFor } from '../events/eventTypeDecorator.js'; +import { Tag } from '../events/Tag.js'; +import type { AppendOptions } from '../eventSequences/AppendOptions.js'; +import type { AppendResult } from '../eventSequences/AppendResult.js'; +import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js'; +import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js'; +import { prepareBatchAppend } from '../eventSequences/prepareBatchAppend.js'; +import { InProcessAppendMetadata } from './InProcessAppendMetadata.js'; +import { InProcessEventPayload } from './InProcessEventPayload.js'; +import type { InProcessEventSequenceEnvironment } from './InProcessEventSequenceEnvironment.js'; +import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js'; + +/** Stages a complete batch; the sequence alone decides whether to commit it. */ +export class InProcessBatchAppend { + private readonly metadata: InProcessAppendMetadata; + + constructor(private readonly environment: InProcessEventSequenceEnvironment, private readonly payload: InProcessEventPayload, + private readonly success: (sequence: EventSequenceNumber, artifact: string) => AppendResult) { + this.metadata = new InProcessAppendMetadata(environment); + } + + prepare(sourceOrEvents: string | EventForEventSourceId[], eventsOrOptions: object[] | AppendOptions | undefined, options: AppendOptions | undefined, position: number) { + const entries = typeof sourceOrEvents === 'string' ? eventsOrOptions as object[] : sourceOrEvents; + const shared = typeof sourceOrEvents === 'string' ? options : eventsOrOptions as AppendOptions | undefined; + this.validateEntries(entries, shared, typeof sourceOrEvents === 'string'); + const prepared = this.serialize(sourceOrEvents, entries, shared); + this.metadata.guid(prepared.correlationId, 'appendMany.correlationId'); + this.context(prepared, entries.length); + const staged = prepared.eventsForEventSourceIds.map((_, index) => this.stage(prepared, index, position)); + return { prepared, staged }; + } + + private validateEntries(entries: object[], shared: AppendOptions | undefined, sameSource: boolean): void { + this.array(entries); + this.metadata.batch(shared); + if (shared?.occurred !== undefined) this.metadata.checkedDate(shared.occurred, 'appendMany.occurred'); + for (const entry of entries) { + if (sameSource) break; + this.metadata.entry(entry as EventForEventSourceId); + } + } + + private array(entries: object[]): void { + if (!Array.isArray(entries)) throw this.environment.unsupported('appendMany.arguments', this.environment.sequenceId, 'Events must be an array.'); + if (!entries.length) throw this.environment.unsupported('appendMany.empty', this.environment.sequenceId, 'The kernel rejects an empty batch.'); + } + + private serialize(sourceOrEvents: string | EventForEventSourceId[], entries: object[], shared?: AppendOptions) { + try { + const batchOptions = this.options(shared); + if (batchOptions?.correlationId !== undefined) this.metadata.guid(batchOptions.correlationId, 'appendMany.correlationId'); + return typeof sourceOrEvents === 'string' + ? prepareBatchAppend(sourceOrEvents, entries, batchOptions) + : prepareBatchAppend(sourceOrEvents, batchOptions); + } catch (error) { + if (error instanceof UnsupportedEventSequenceOperation) throw error; + throw this.environment.unsupported('appendMany.serialization', this.environment.sequenceId, `Batch preparation failed: ${String(error)}.`); + } + } + + private options(shared?: AppendOptions): AppendOptions | undefined { + return shared?.correlationId === undefined && this.environment.correlationId ? this.withCorrelation(shared) : shared; + } + + private withCorrelation(shared?: AppendOptions): AppendOptions { + const metadata = shared ?? {}; + return { correlationId: this.environment.correlationId!(), sourceType: metadata.sourceType, streamType: metadata.streamType, + streamId: metadata.streamId, subject: metadata.subject, occurred: metadata.occurred, tags: metadata.tags, + namedTags: metadata.namedTags }; + } + + private context({ identity, batchCausationChain }: ReturnType, count: number): void { + if (!this.metadata.ambientContext(identity, batchCausationChain) || !this.batchContext(batchCausationChain, count)) { + throw this.environment.unsupported('appendMany.context', this.environment.sequenceId, 'Ambient identity or causation metadata is not fixture-backed.'); + } + } + + private batchContext(chain: ReturnType['batchCausationChain'], count: number): boolean { + return chain[1].type.name === 'TypeScriptClient.AppendMany' && chain[1].properties.count === String(count); + } + + private stage(prepared: ReturnType, index: number, position: number) { + const { eventSourceId, event } = prepared.eventsForEventSourceIds[index]; + this.eventInstance(event); + this.metadata.source(eventSourceId, 'appendMany.source'); + const wire = prepared.eventsToAppend[index]; + this.route(wire); + this.tags(wire, event); + const occurred = wire.Occurred ? this.metadata.checkedDate(new Date(wire.Occurred.Value), 'appendMany.occurred') + : this.metadata.checkedDate(this.environment.clock(), 'appendMany.clock'); + const content = this.payload.checkedContent(event, wire.Content, 'appendMany.content'); + const hash = this.payload.hash(wire.EventType.Id, eventSourceId, content); + const sequenceNumber = new EventSequenceNumber(BigInt(position + index)); + const eventType = getEventTypeFor(event.constructor); + const stored: AppendedEvent = { eventType, content, context: { + eventStore: this.environment.store, namespace: this.environment.namespace, sequenceNumber: sequenceNumber.value, eventSourceId, + eventSourceType: wire.EventSourceType || 'Default', eventStreamType: wire.EventStreamType || 'All', + eventStreamId: wire.EventStreamId || 'Default', subject: this.metadata.storedSubject(wire.Subject, eventSourceId), hash, causedBy: prepared.identity, + observationState: EventObservationState.Initial, eventType, occurred, correlationId: prepared.correlationId.toString(), + causation: prepared.batchCausationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })), + tags: wire.Tags.map(tag => new Tag(tag)), namedTags: [] + } }; + return { stored, result: this.success(sequenceNumber, event.constructor.name) }; + } + + private eventInstance(event: object): void { + if (!this.registeredEvent(event)) { + throw this.environment.unsupported('appendMany.event', event?.constructor?.name ?? this.environment.sequenceId, 'Event is not in the selected catalog.'); + } + } + + private registeredEvent(event: object): boolean { + return !!event && typeof event === 'object' && this.environment.catalog.has(event.constructor); + } + + private route(wire: ReturnType['eventsToAppend'][number]): void { + for (const [name, value] of Object.entries({ sourceType: wire.EventSourceType, streamType: wire.EventStreamType, streamId: wire.EventStreamId })) { + if (value !== undefined && !this.routeIdentifier(value)) { + throw this.environment.unsupported(`appendMany.${name}`, String(value), 'Only simple metadata identifiers are fixture-backed.'); + } + } + } + + private routeIdentifier(value: unknown): boolean { + return typeof value === 'string' && /^[A-Za-z0-9_-]*$/.test(value); + } + + private tags(wire: ReturnType['eventsToAppend'][number], event: object): void { + if (wire.Tags.some(tag => !/^[A-Za-z0-9_-]+$/.test(tag))) { + throw this.environment.unsupported('appendMany.tags', event.constructor.name, 'Only simple string tags are fixture-backed.'); + } + } +} diff --git a/Source/testing/InProcessEventPayload.ts b/Source/testing/InProcessEventPayload.ts new file mode 100644 index 00000000..451830ef --- /dev/null +++ b/Source/testing/InProcessEventPayload.ts @@ -0,0 +1,100 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { createHash } from 'node:crypto'; +import { conceptAsTypeKey, JsonSerializer, typeKeyOf, type Constructor } from '@cratis/fundamentals'; +import type { AppendedEvent } from '../events/AppendedEvent.js'; +import { EventType } from '../events/EventType.js'; +import { EventTypeId } from '../events/EventTypeId.js'; +import { EventTypeGeneration } from '../events/EventTypeGeneration.js'; +import { Tag } from '../events/Tag.js'; +import { Identity } from '../identity/Identity.js'; +import type { JsonSchema } from '../schemas/JsonSchema.js'; +import { matchesEventField } from './EventFieldSchema.js'; +import type { InProcessConstraints } from './InProcessConstraints.js'; +import type { InProcessEventSequenceEnvironment } from './InProcessEventSequenceEnvironment.js'; + +/** Schema-bound payload validation, canonical hashing, and isolated history snapshots. */ +export class InProcessEventPayload { + constructor(private readonly environment: InProcessEventSequenceEnvironment, private readonly constraints?: InProcessConstraints) {} + + checkedContent(event: object, serialized: string, operation: string, + mismatchReason = 'Content differs from the event schema.'): Record { + const content = this.parse(serialized, event, operation); + const metadata = this.environment.catalog.get(event.constructor)!; + const properties = metadata.schema.properties!; + if (!this.objectWithProperties(content, properties) || !this.matchesProperties(event, content, properties)) { + throw this.environment.unsupported(operation, event.constructor.name, mismatchReason); + } + return content; + } + + hash(typeId: string, source: string, content: Record): string { + const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)); + return createHash('sha256').update(`${typeId}|${source}|${JSON.stringify(canonical)}`).digest('base64'); + } + + snapshot(event: AppendedEvent): AppendedEvent { + const eventType = new EventType(new EventTypeId(event.eventType.id.value), + new EventTypeGeneration(event.eventType.generation.value), event.eventType.tombstone); + const causedBy = event.context.causedBy; + return { + eventType, + content: JSON.parse(JSON.stringify(event.content)) as Record, + context: { ...event.context, eventType, occurred: new Date(event.context.occurred), + causedBy: causedBy && new Identity(causedBy.subject, causedBy.name, causedBy.userName, causedBy.onBehalfOf), + causation: event.context.causation.map(item => ({ ...item, occurred: item.occurred && new Date(item.occurred), properties: { ...item.properties } })), + tags: event.context.tags.map(item => new Tag(item.value)), + namedTags: [...(event.context.namedTags ?? [])] } + }; + } + + private parse(serialized: string, event: object, operation: string): Record { + try { return JSON.parse(serialized) as Record; } + catch { throw this.environment.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); } + } + + private objectWithProperties(content: Record, properties: Record): boolean { + if (!this.isObject(content)) return false; + return Object.keys(content).length === Object.keys(properties).length; + } + + private isObject(content: unknown): boolean { + return !!content && typeof content === 'object' && !Array.isArray(content); + } + + private matchesProperties(event: object, content: Record, properties: Record): boolean { + return Object.entries(properties).every(property => this.matchesSchema(event, content, property)); + } + + private matchesSchema(event: object, content: Record, [key, property]: [string, JsonSchema]): boolean { + return this.matchesProperty(content, key, property) && this.matchesConstraint(event, content, key) && this.matchesText(content[key], property); + } + + private matchesProperty(content: Record, key: string, property: JsonSchema): boolean { + return Object.hasOwn(content, key) && matchesEventField(content[key], property); + } + + private matchesText(value: unknown, property: JsonSchema): boolean { + if (property.type !== 'string' || property.format !== undefined) return true; + return /^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(value as string); + } + + private matchesConstraint(event: object, content: Record, key: string): boolean { + const metadata = this.environment.catalog.get(event.constructor)!; + if (!this.constraints?.isConstrainedProperty(metadata.eventType.id.value, key)) return true; + return this.matchesConstrainedValue(Reflect.get(event, key), content[key]); + } + + private matchesConstrainedValue(value: unknown, content: unknown): boolean { + // Use production serialization for concepts, not their wrapper object's runtime type. + // Keep rejecting other unproven conversions, such as a Date assigned to a string field. + if (this.serializedConcept(value)) return JsonSerializer.serialize(value) === JSON.stringify(content); + return value === content; + } + + private serializedConcept(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + return [conceptAsTypeKey, 'Guid'].includes(typeKeyOf(value.constructor as Constructor) ?? ''); + } +} diff --git a/Source/testing/InProcessEventReadCriteria.ts b/Source/testing/InProcessEventReadCriteria.ts new file mode 100644 index 00000000..70d6be7a --- /dev/null +++ b/Source/testing/InProcessEventReadCriteria.ts @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Validated read criteria; route sentinels deliberately do not narrow history. */ +export interface InProcessEventReadCriteria { + readonly source?: string; + readonly sourceType?: string; + readonly streamType?: string; + readonly streamId?: string; + readonly ids: readonly string[]; +} diff --git a/Source/testing/InProcessEventReadFilter.ts b/Source/testing/InProcessEventReadFilter.ts new file mode 100644 index 00000000..b5954167 --- /dev/null +++ b/Source/testing/InProcessEventReadFilter.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 type { AppendedEvent } from '../events/AppendedEvent.js'; +import type { InProcessEventReadCriteria } from './InProcessEventReadCriteria.js'; + +/** Applies the same source, route-sentinel, and event-type semantics to each read surface. */ +export class InProcessEventReadFilter { + constructor(private readonly criteria: InProcessEventReadCriteria) {} + + matches(event: AppendedEvent): boolean { + return this.source(event) && this.sourceType(event) && this.streamType(event) && this.streamId(event) && this.eventType(event); + } + + private source(event: AppendedEvent): boolean { + return this.criteria.source === undefined || event.context.eventSourceId === this.criteria.source; + } + + private sourceType(event: AppendedEvent): boolean { + const value = this.criteria.sourceType; + return value === undefined || value === 'Default' || event.context.eventSourceType === value; + } + + private streamType(event: AppendedEvent): boolean { + const value = this.criteria.streamType; + return value === undefined || value === 'All' || event.context.eventStreamType === value; + } + + private streamId(event: AppendedEvent): boolean { + const value = this.criteria.streamId; + return value === undefined || value === 'Default' || event.context.eventStreamId === value; + } + + private eventType(event: AppendedEvent): boolean { + return !this.criteria.ids.length || this.criteria.ids.includes(event.eventType.id.value); + } +} diff --git a/Source/testing/InProcessEventSequence.ts b/Source/testing/InProcessEventSequence.ts index 2dd3f3b7..c7f556e9 100644 --- a/Source/testing/InProcessEventSequence.ts +++ b/Source/testing/InProcessEventSequence.ts @@ -1,23 +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 { createHash } from 'node:crypto'; -import { EventObservationState } from '@cratis/chronicle.contracts'; import { AppendOperationsBroadcaster } from '../eventSequences/AppendOperationsBroadcaster.js'; -import { prepareBatchAppend } from '../eventSequences/prepareBatchAppend.js'; import { createAppendNotification, mapAppendNotificationCausation } from '../eventSequences/createAppendNotification.js'; import { createAppendResult } from '../eventSequences/createAppendResult.js'; -import { conceptAsTypeKey, Guid, JsonSerializer, typeKeyOf, type Constructor } from '@cratis/fundamentals'; -import { getEventTypeMetadata, getEventTypeFor } from '../events/eventTypeDecorator.js'; +import { Guid, type Constructor } from '@cratis/fundamentals'; +import { getEventTypeMetadata } from '../events/eventTypeDecorator.js'; import { getTagsFor } from '../events/tagDecorator.js'; import type { AppendedEvent } from '../events/AppendedEvent.js'; -import { Tag } from '../events/Tag.js'; -import { EventType } from '../events/EventType.js'; -import { EventTypeId } from '../events/EventTypeId.js'; -import { EventTypeGeneration } from '../events/EventTypeGeneration.js'; -import { Identity } from '../identity/Identity.js'; + import type { AppendOptions } from '../eventSequences/AppendOptions.js'; -import { hasNamedTags, singleAppendRoute } from './singleAppendRoute.js'; import type { AppendResult } from '../eventSequences/AppendResult.js'; import type { AppendedEventWithResult } from '../eventSequences/AppendedEventWithResult.js'; import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js'; @@ -28,31 +20,20 @@ import type { ITransactionalEventSequence } from '../eventSequences/ITransaction import type { CompleteStreamResult } from '../eventSequences/CompleteStreamResult.js'; import type { IEventImport } from '../imports/IEventImport.js'; import type { ImportResult } from '../imports/ImportResult.js'; -import { prepareSingleAppend } from '../eventSequences/prepareSingleAppend.js'; -import { toContractsGuid } from '../connection/Guid.js'; import { getRemovedConstraintNames } from '../events/constraints/removeConstraint.js'; import { InProcessConstraints, type WireConstraintViolation } from './InProcessConstraints.js'; -import { matchesEventField, supportsEventField } from './EventFieldSchema.js'; +import { supportsEventField } from './EventFieldSchema.js'; +import { InProcessBatchAppend } from './InProcessBatchAppend.js'; +import { InProcessEventPayload } from './InProcessEventPayload.js'; +import { InProcessEventReadFilter } from './InProcessEventReadFilter.js'; +import { InProcessSingleAppend } from './InProcessSingleAppend.js'; +import type { InProcessEventSequenceEnvironment } from './InProcessEventSequenceEnvironment.js'; import type { EventScenarioOptions } from './EventScenarioOptions.js'; import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js'; // JS trim() omits U+0085, which the kernel trims. Reject unproven non-ASCII whitespace and controls. const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space}/u; -function resolveStoredSubject(subject: string | undefined, eventSourceId: string): string { - // Match the kernel's string.IsNullOrWhiteSpace, including U+0085 but not U+FEFF. - return subject && !/^\p{White_Space}*$/u.test(subject) ? subject : eventSourceId; -} - -function matchesConstrainedValue(value: unknown, content: unknown): boolean { - // Use production serialization for concepts, not their wrapper object's runtime type. - // Keep rejecting other unproven conversions, such as a Date assigned to a string field. - if (value && typeof value === 'object' && [conceptAsTypeKey, 'Guid'].includes(typeKeyOf(value.constructor as Constructor) ?? '')) { - return JsonSerializer.serialize(value) === JSON.stringify(content); - } - return value === content; -} - // Observers ReactorScenario registers to latch unsupported operations, keyed by sequence. const unsupportedObservers = new WeakMap void>(); @@ -75,6 +56,9 @@ export class InProcessEventSequence implements IEventSequence { private readonly _namespace: string; private readonly _clock: () => Date; private readonly _correlationId?: () => string; + private readonly _payload: InProcessEventPayload; + private readonly _singleAppend: InProcessSingleAppend; + private readonly _batchAppend: InProcessBatchAppend; private _setup = false; private _allowSeedAppend = false; private _stagedSeeds?: AppendedEvent[]; @@ -132,6 +116,14 @@ export class InProcessEventSequence implements IEventSequence { } this._catalog.set(type, metadata); } + const environment: InProcessEventSequenceEnvironment = { + catalog: this._catalog, store: this._store, namespace: this._namespace, sequenceId: this.id.value, + clock: () => this._clock(), correlationId: this._correlationId ? () => this._correlationId!() : undefined, + unsupported: (operation, artifact, reason) => this.unsupported(operation, artifact, reason) + }; + this._payload = new InProcessEventPayload(environment, this._constraints); + this._singleAppend = new InProcessSingleAppend(environment, this._payload); + this._batchAppend = new InProcessBatchAppend(environment, this._payload, (sequence, artifact) => this.success(sequence, artifact)); } get results(): readonly AppendResult[] { return Object.freeze([...this._results]); } @@ -169,72 +161,17 @@ export class InProcessEventSequence implements IEventSequence { get transactional(): ITransactionalEventSequence { throw this.unsupported('transactional', this.id.value, 'Transactions require a kernel.'); } async append(eventSourceId: string, event: object, options?: AppendOptions): Promise { - if (this._busy || (this._setup && !this._allowSeedAppend)) { + if (this.appendBusy()) { throw this.unsupported('append', this.id.value, 'Overlapping calls do not have proven ordering.'); } this._busy = true; try { - if (!event || typeof event !== 'object') throw this.unsupported('append.event', this.id.value, 'Only registered event instances are supported.'); - if (!/^[A-Za-z0-9_-]+$/.test(eventSourceId)) throw this.unsupported('append.source', eventSourceId, 'Only simple source identifiers are fixture-backed.'); - const route = singleAppendRoute(options, event.constructor.name, (operation, artifact, reason) => this.unsupported(operation, artifact, reason), true); - const metadata = this._catalog.get(event.constructor); - if (!metadata) throw this.unsupported('append.event', event.constructor.name, 'Event is not in the selected, validated catalog.'); - let prepared: ReturnType; - let content: Record; - try { - prepared = prepareSingleAppend(event, { subject: options?.subject, correlationId: this._correlationId?.() }); - content = this.checkedContent(event, prepared.content, 'append.content', - 'Content differs from the event schema; null, missing and extra values are not proven.'); - } catch (error) { - if (error instanceof UnsupportedEventSequenceOperation) throw error; - throw this.unsupported('append.serialization', event.constructor.name, `Payload or metadata could not be serialized: ${String(error)}.`); - } - try { - toContractsGuid(prepared.correlationId); - if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(prepared.correlationId.toString())) throw new Error('Invalid correlation ID.'); - } catch { - throw this.unsupported('append.correlationId', event.constructor.name, 'Correlation ID must be a valid GUID.'); - } - if (prepared.identity !== Identity.system || prepared.causationChain.length !== 2 || - Object.keys(prepared.causationChain[0].properties).length !== 0) { - throw this.unsupported('append.context', event.constructor.name, 'Ambient identity or causation metadata is not fixture-backed.'); - } - const occurred = this._clock(); - // Date has millisecond precision; only the UTC year range representable by DateTimeOffset is supported. - if (!(occurred instanceof Date) || Number.isNaN(occurred.getTime()) || - occurred.getUTCFullYear() < 1 || occurred.getUTCFullYear() > 9999) { - throw this.unsupported('append.clock', event.constructor.name, 'Clock must return a valid Date within UTC years 1–9999 (millisecond precision).'); - } - const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)); - const hash = createHash('sha256').update(`${prepared.eventType.id.value}|${eventSourceId}|${JSON.stringify(canonical)}`).digest('base64'); - const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length + (this._stagedSeeds?.length ?? 0))); - const stored: AppendedEvent = { - eventType: prepared.eventType, - content, - context: { - eventStore: this._store, namespace: this._namespace, - sequenceNumber: sequenceNumber.value, eventSourceId, - eventSourceType: route.sourceType, eventStreamType: route.streamType, eventStreamId: route.streamId, - subject: resolveStoredSubject(prepared.subject, eventSourceId), hash, causedBy: prepared.identity, observationState: EventObservationState.Initial, - eventType: prepared.eventType, occurred, correlationId: prepared.correlationId.toString(), - causation: prepared.causationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })), - tags: prepared.tags.map(tag => new Tag(tag)), - namedTags: [] - } - }; + const { prepared, stored, sequenceNumber } = this._singleAppend.prepare(eventSourceId, event, options, + this._history.length + (this._stagedSeeds?.length ?? 0)); const violations = this.validateConstraints([...this._history, ...(this._stagedSeeds ?? [])], [stored]); const result = violations.length ? this.rejected(violations) : this.success(sequenceNumber, event.constructor.name); - if (result.isSuccess) { - if (this._stagedSeeds) this._stagedSeeds.push(this.snapshot(stored)); - else this._history.push(this.snapshot(stored)); - } - if (!this._setup) this._results.push(result); - if (this._stagedNotifications || this.appendOperations.hasSubscribers) { - const notification = [createAppendNotification(eventSourceId, event, result, - prepared.correlationId.toString(), mapAppendNotificationCausation(prepared.causationChain), prepared.tags)]; - if (this._stagedNotifications) this._stagedNotifications.push(notification); - else this.appendOperations.publish(notification); - } + this.commitSingle(stored, result); + this.notifySingle(eventSourceId, event, result, prepared); return result; } finally { // Keep the append in flight until its promise crosses an async boundary. @@ -243,94 +180,35 @@ export class InProcessEventSequence implements IEventSequence { } } + private appendBusy(): boolean { + return this._busy || (this._setup && !this._allowSeedAppend); + } + + private commitSingle(stored: AppendedEvent, result: AppendResult): void { + if (result.isSuccess) { + if (this._stagedSeeds) this._stagedSeeds.push(this.snapshot(stored)); + else this._history.push(this.snapshot(stored)); + } + if (!this._setup) this._results.push(result); + } + + private notifySingle(eventSourceId: string, event: object, result: AppendResult, prepared: ReturnType['prepared']): void { + if (this._stagedNotifications || this.appendOperations.hasSubscribers) { + const notification = [createAppendNotification(eventSourceId, event, result, + prepared.correlationId.toString(), mapAppendNotificationCausation(prepared.causationChain), prepared.tags)]; + if (this._stagedNotifications) this._stagedNotifications.push(notification); + else this.appendOperations.publish(notification); + } + } + appendMany(eventSourceId: string, events: object[], options?: AppendOptions): Promise; appendMany(events: EventForEventSourceId[], options?: AppendOptions): Promise; async appendMany(sourceOrEvents: string | EventForEventSourceId[], eventsOrOptions?: object[] | AppendOptions, options?: AppendOptions): Promise { if (this._busy || this._setup) throw this.unsupported('appendMany', this.id.value, 'Overlapping calls do not have proven ordering.'); this._busy = true; try { - const entries = typeof sourceOrEvents === 'string' ? eventsOrOptions as object[] : sourceOrEvents; - const shared = typeof sourceOrEvents === 'string' ? options : eventsOrOptions as AppendOptions | undefined; - if (!Array.isArray(entries)) throw this.unsupported('appendMany.arguments', this.id.value, 'Events must be an array.'); - if (!entries.length) throw this.unsupported('appendMany.empty', this.id.value, 'The kernel rejects an empty batch.'); - this.validateBatchOptions(shared); - if (shared?.occurred !== undefined) this.checkedDate(shared.occurred, 'appendMany.occurred'); - for (const entry of entries) { - if (typeof sourceOrEvents === 'string') break; - if (!entry || typeof entry !== 'object' || Reflect.ownKeys(entry).some(key => - !['eventSourceId', 'event', 'eventSourceType', 'eventStreamType', 'eventStreamId', 'subject', 'occurred', 'tags', 'namedTags'].includes(String(key)))) { - throw this.unsupported('appendMany.entry', this.id.value, 'Unrecognized per-entry metadata.'); - } - const individual = entry as EventForEventSourceId; - if (hasNamedTags(individual.namedTags)) { - throw this.unsupported('appendMany.namedTags', this.id.value, 'Named tags are not fixture-backed.'); - } - if (individual.occurred !== undefined) this.checkedDate(individual.occurred, 'appendMany.occurred'); - if (individual.subject !== undefined && individual.subject !== null && typeof individual.subject !== 'string') { - throw this.unsupported('appendMany.subject', this.id.value, 'Subject must be a string.'); - } - const tags = individual.tags; - if (tags && (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string'))) { - throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.'); - } - } - let prepared: ReturnType; - try { - const batchOptions: AppendOptions | undefined = shared?.correlationId === undefined && this._correlationId - ? { correlationId: this._correlationId(), sourceType: shared?.sourceType, streamType: shared?.streamType, - streamId: shared?.streamId, subject: shared?.subject, occurred: shared?.occurred, tags: shared?.tags, - namedTags: shared?.namedTags } - : shared; - if (batchOptions?.correlationId !== undefined) this.validateGuid(batchOptions.correlationId, 'appendMany.correlationId'); - prepared = typeof sourceOrEvents === 'string' - ? prepareBatchAppend(sourceOrEvents, entries as object[], batchOptions) - : prepareBatchAppend(sourceOrEvents, batchOptions); - } catch (error) { - if (error instanceof UnsupportedEventSequenceOperation) throw error; - throw this.unsupported('appendMany.serialization', this.id.value, `Batch preparation failed: ${String(error)}.`); - } - const { eventsForEventSourceIds, eventsToAppend, correlationId, batchCausationChain, identity } = prepared; - this.validateGuid(correlationId, 'appendMany.correlationId'); - if (identity !== Identity.system || batchCausationChain.length !== 2 || - Object.keys(batchCausationChain[0].properties).length !== 0 || - batchCausationChain[1].type.name !== 'TypeScriptClient.AppendMany' || - batchCausationChain[1].properties.count !== String(entries.length)) { - throw this.unsupported('appendMany.context', this.id.value, 'Ambient identity or causation metadata is not fixture-backed.'); - } - const staged = eventsForEventSourceIds.map(({ eventSourceId, event }, index) => { - if (!event || typeof event !== 'object' || !this._catalog.has(event.constructor)) { - throw this.unsupported('appendMany.event', event?.constructor?.name ?? this.id.value, 'Event is not in the selected catalog.'); - } - this.validateSource(eventSourceId, 'appendMany.source'); - const wire = eventsToAppend[index]; - for (const [name, value] of Object.entries({ sourceType: wire.EventSourceType, streamType: wire.EventStreamType, - streamId: wire.EventStreamId })) { - const identifiers = /^[A-Za-z0-9_-]*$/; - if (value !== undefined && (typeof value !== 'string' || !identifiers.test(value))) { - throw this.unsupported(`appendMany.${name}`, String(value), 'Only simple metadata identifiers are fixture-backed.'); - } - } - if (wire.Tags.some(tag => !/^[A-Za-z0-9_-]+$/.test(tag))) { - throw this.unsupported('appendMany.tags', event.constructor.name, 'Only simple string tags are fixture-backed.'); - } - const occurred = wire.Occurred ? this.checkedDate(new Date(wire.Occurred.Value), 'appendMany.occurred') - : this.checkedDate(this._clock(), 'appendMany.clock'); - const content = this.checkedContent(event, wire.Content, 'appendMany.content'); - const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)); - const hash = createHash('sha256').update(`${wire.EventType.Id}|${eventSourceId}|${JSON.stringify(canonical)}`).digest('base64'); - const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length + index)); - const eventType = getEventTypeFor(event.constructor); - const stored: AppendedEvent = { eventType, content, context: { - eventStore: this._store, namespace: this._namespace, sequenceNumber: sequenceNumber.value, eventSourceId, - eventSourceType: wire.EventSourceType || 'Default', eventStreamType: wire.EventStreamType || 'All', - eventStreamId: wire.EventStreamId || 'Default', subject: resolveStoredSubject(wire.Subject, eventSourceId), hash, causedBy: identity, - observationState: EventObservationState.Initial, eventType, occurred, correlationId: correlationId.toString(), - causation: batchCausationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })), - tags: wire.Tags.map(tag => new Tag(tag)), - namedTags: [] - } }; - return { stored, result: this.success(sequenceNumber, event.constructor.name) }; - }); + const { prepared, staged } = this._batchAppend.prepare(sourceOrEvents, eventsOrOptions, options, this._history.length); + const { eventsForEventSourceIds, eventsToAppend, correlationId, batchCausationChain } = prepared; // The kernel validates the entire batch before persisting any events or allocating sequences. const violations = this.validateConstraints(this._history, staged.map(item => item.stored)); const results = violations.length ? staged.map(() => this.rejected(violations)) : staged.map(item => item.result); @@ -355,12 +233,8 @@ export class InProcessEventSequence implements IEventSequence { async getTailSequenceNumber(source?: string, sourceType?: string, streamType?: string, streamId?: string, types?: Constructor[]): Promise { if (source !== undefined) this.validateReadSource('getTailSequenceNumber.source', source); this.validateRouteFilters('getTailSequenceNumber.filters', sourceType, streamType, streamId); - const ids = this.filterIds(types); - const last = [...this._history].reverse().find(event => (source === undefined || event.context.eventSourceId === source) && - (sourceType === undefined || sourceType === 'Default' || event.context.eventSourceType === sourceType) && - (streamType === undefined || streamType === 'All' || event.context.eventStreamType === streamType) && - (streamId === undefined || streamId === 'Default' || event.context.eventStreamId === streamId) && - (!ids.length || ids.includes(event.eventType.id.value))); + const filter = new InProcessEventReadFilter({ source, sourceType, streamType, streamId, ids: this.filterIds(types) }); + const last = [...this._history].reverse().find(event => filter.matches(event)); return last ? new EventSequenceNumber(last.context.sequenceNumber) : EventSequenceNumber.unset; } @@ -377,11 +251,8 @@ export class InProcessEventSequence implements IEventSequence { if (!types.length) throw this.unsupported('getForEventSourceIdAndEventTypes.filters', source, 'Explicit event types are required.'); this.validateReadSource('getForEventSourceIdAndEventTypes.source', source); this.validateRouteFilters('getForEventSourceIdAndEventTypes.filters', sourceType, streamType, streamId); - const ids = this.filterIds(types); - return this._history.filter(event => event.context.eventSourceId === source && ids.includes(event.eventType.id.value) && - (sourceType === undefined || sourceType === 'Default' || event.context.eventSourceType === sourceType) && - (streamType === undefined || streamType === 'All' || event.context.eventStreamType === streamType) && - (streamId === undefined || streamId === 'Default' || event.context.eventStreamId === streamId)).map(event => this.snapshot(event)); + const filter = new InProcessEventReadFilter({ source, sourceType, streamType, streamId, ids: this.filterIds(types) }); + return this._history.filter(event => filter.matches(event)).map(event => this.snapshot(event)); } async getFromSequenceNumber(sequence: EventSequenceNumber, source?: string, types?: Constructor[]): Promise { @@ -389,10 +260,8 @@ export class InProcessEventSequence implements IEventSequence { throw this.unsupported('getFromSequenceNumber.sequenceNumber', sequence.value.toString(), 'Sequence numbers must fit unsigned 64-bit wire values.'); } if (source !== undefined) this.validateReadSource('getFromSequenceNumber.source', source); - const ids = this.filterIds(types); - return this._history.filter(event => event.context.sequenceNumber >= sequence.value && - (source === undefined || event.context.eventSourceId === source) && - (!ids.length || ids.includes(event.eventType.id.value))).map(event => this.snapshot(event)); + const filter = new InProcessEventReadFilter({ source, ids: this.filterIds(types) }); + return this._history.filter(event => event.context.sequenceNumber >= sequence.value && filter.matches(event)).map(event => this.snapshot(event)); } async compensate(): Promise { throw this.unsupported('compensate', this.id.value, 'Compensation requires kernel storage.'); } @@ -413,70 +282,14 @@ export class InProcessEventSequence implements IEventSequence { private validateRouteFilters(operation: string, ...filters: (string | undefined)[]): void { for (const filter of filters) { - if (filter !== undefined && (typeof filter !== 'string' || !/^[A-Za-z0-9_-]+$/.test(filter))) { + if (filter !== undefined && !this.simpleRouteFilter(filter)) { throw this.unsupported(operation, String(filter), 'Only simple, nonblank route filters are fixture-backed.'); } } } - private validateBatchOptions(options?: AppendOptions): void { - if (options === undefined) return; - if (!options || typeof options !== 'object' || Array.isArray(options)) { - throw this.unsupported('appendMany.options', this.id.value, 'Append options must be an object.'); - } - if (hasNamedTags(options.namedTags)) { - throw this.unsupported('appendMany.namedTags', this.id.value, 'Named tags are not fixture-backed.'); - } - const allowed = ['correlationId', 'sourceType', 'streamType', 'streamId', 'subject', 'occurred', 'tags', 'namedTags']; - if (Reflect.ownKeys(options).some(key => !allowed.includes(String(key))) || - options.concurrencyScope !== undefined || options.concurrencyScopes !== undefined || options.eventSourceId !== undefined) { - throw this.unsupported('appendMany.options', this.id.value, 'Concurrency and unrecognized metadata are not fixture-backed.'); - } - if (options.subject !== undefined && options.subject !== null && typeof options.subject !== 'string') { - throw this.unsupported('appendMany.subject', this.id.value, 'Subject must be a string.'); - } - if (options.tags && (!Array.isArray(options.tags) || options.tags.some(tag => typeof tag !== 'string'))) { - throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.'); - } - } - - private validateGuid(value: string | Guid, operation: string): void { - try { - toContractsGuid(Guid.as(value)); - if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value.toString())) throw new Error('Invalid GUID.'); - } catch { throw this.unsupported(operation, this.id.value, 'Correlation ID must be a valid GUID.'); } - } - - private checkedDate(value: Date, operation: string): Date { - if (!(value instanceof Date) || Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1 || value.getUTCFullYear() > 9999) { - throw this.unsupported(operation, this.id.value, 'Occurrence must be within UTC years 1–9999 at millisecond precision.'); - } - return new Date(value); - } - - private validateSource(source: string, operation: string): void { - if (typeof source !== 'string' || !/^[A-Za-z0-9_-]+$/.test(source)) { - throw this.unsupported(operation, String(source), 'Only simple source identifiers are fixture-backed.'); - } - } - - private checkedContent(event: object, serialized: string, operation: string, - mismatchReason = 'Content differs from the event schema.'): Record { - let content: Record; - try { content = JSON.parse(serialized) as Record; } - catch { throw this.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); } - const metadata = this._catalog.get(event.constructor)!; - const properties = metadata.schema.properties!; - if (!content || typeof content !== 'object' || Array.isArray(content) || - Object.keys(content).length !== Object.keys(properties).length || - Object.entries(properties).some(([key, property]) => !Object.hasOwn(content, key) || !matchesEventField(content[key], property) || - (this._constraints?.isConstrainedProperty(metadata.eventType.id.value, key) && - !matchesConstrainedValue(Reflect.get(event, key), content[key])) || - (property.type === 'string' && property.format === undefined && - !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) { - throw this.unsupported(operation, event.constructor.name, mismatchReason); - } - return content; + private simpleRouteFilter(filter: string): boolean { + return typeof filter === 'string' && /^[A-Za-z0-9_-]+$/.test(filter); } private validateConstraints(history: readonly AppendedEvent[], incoming: readonly AppendedEvent[]): WireConstraintViolation[] { @@ -499,26 +312,19 @@ export class InProcessEventSequence implements IEventSequence { } private snapshot(event: AppendedEvent): AppendedEvent { - const eventType = new EventType(new EventTypeId(event.eventType.id.value), - new EventTypeGeneration(event.eventType.generation.value), event.eventType.tombstone); - const causedBy = event.context.causedBy; - return { - eventType, - content: JSON.parse(JSON.stringify(event.content)) as Record, - context: { ...event.context, eventType, occurred: new Date(event.context.occurred), - causedBy: causedBy && new Identity(causedBy.subject, causedBy.name, causedBy.userName, causedBy.onBehalfOf), - causation: event.context.causation.map(item => ({ ...item, occurred: item.occurred && new Date(item.occurred), properties: { ...item.properties } })), - tags: event.context.tags.map(item => new Tag(item.value)), - namedTags: [...(event.context.namedTags ?? [])] } - }; + return this._payload.snapshot(event); } private validateReadSource(operation: string, source: string): void { - if (!source.trim() || source !== source.trim() || unprovenFilterCharacters.test(source)) { + if (!this.simpleReadSource(source)) { throw this.unsupported(operation, source, 'Blank and padded source filters have unproven kernel normalization.'); } } + private simpleReadSource(source: string): boolean { + return !!source.trim() && source === source.trim() && !unprovenFilterCharacters.test(source); + } + private unsupported(operation: string, artifact: string, reason: string): UnsupportedEventSequenceOperation { const error = new UnsupportedEventSequenceOperation(operation, artifact, reason); unsupportedObservers.get(this)?.(error); diff --git a/Source/testing/InProcessEventSequenceEnvironment.ts b/Source/testing/InProcessEventSequenceEnvironment.ts new file mode 100644 index 00000000..70b24263 --- /dev/null +++ b/Source/testing/InProcessEventSequenceEnvironment.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. + +import type { getEventTypeMetadata } from '../events/eventTypeDecorator.js'; +import type { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js'; + +/** Scenario-local dependencies used while preparing, but never committing, appends. */ +export interface InProcessEventSequenceEnvironment { + readonly catalog: ReadonlyMap>; + readonly store: string; + readonly namespace: string; + readonly clock: () => Date; + readonly correlationId?: () => string; + readonly unsupported: (operation: string, artifact: string, reason: string) => UnsupportedEventSequenceOperation; + readonly sequenceId: string; +} diff --git a/Source/testing/InProcessSingleAppend.ts b/Source/testing/InProcessSingleAppend.ts new file mode 100644 index 00000000..68e47638 --- /dev/null +++ b/Source/testing/InProcessSingleAppend.ts @@ -0,0 +1,99 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { toContractsGuid } from '../connection/Guid.js'; +import type { AppendedEvent } from '../events/AppendedEvent.js'; +import { EventObservationState } from '@cratis/chronicle.contracts'; +import { Tag } from '../events/Tag.js'; +import type { AppendOptions } from '../eventSequences/AppendOptions.js'; +import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js'; +import { prepareSingleAppend } from '../eventSequences/prepareSingleAppend.js'; +import { InProcessAppendMetadata } from './InProcessAppendMetadata.js'; +import { InProcessEventPayload } from './InProcessEventPayload.js'; +import type { InProcessEventSequenceEnvironment } from './InProcessEventSequenceEnvironment.js'; +import { singleAppendRoute } from './singleAppendRoute.js'; +import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js'; + +/** Prepares one append without allocating history, results, or notifications. */ +export class InProcessSingleAppend { + private readonly metadata: InProcessAppendMetadata; + + constructor(private readonly environment: InProcessEventSequenceEnvironment, private readonly payload: InProcessEventPayload) { + this.metadata = new InProcessAppendMetadata(environment); + } + + prepare(eventSourceId: string, event: object, options: AppendOptions | undefined, position: number) { + this.eventInstance(event); + if (!/^[A-Za-z0-9_-]+$/.test(eventSourceId)) throw this.environment.unsupported('append.source', eventSourceId, 'Only simple source identifiers are fixture-backed.'); + const route = singleAppendRoute(options, event.constructor.name, this.environment.unsupported, true); + if (!this.environment.catalog.get(event.constructor)) throw this.environment.unsupported('append.event', event.constructor.name, 'Event is not in the selected, validated catalog.'); + const { prepared, content } = this.serialize(event, options); + this.correlation(prepared, event); + this.context(prepared, event); + const occurred = this.occurred(event); + const hash = this.payload.hash(prepared.eventType.id.value, eventSourceId, content); + const sequenceNumber = new EventSequenceNumber(BigInt(position)); + const stored: AppendedEvent = { + eventType: prepared.eventType, + content, + context: { + eventStore: this.environment.store, namespace: this.environment.namespace, + sequenceNumber: sequenceNumber.value, eventSourceId, + eventSourceType: route.sourceType, eventStreamType: route.streamType, eventStreamId: route.streamId, + subject: this.metadata.storedSubject(prepared.subject, eventSourceId), hash, causedBy: prepared.identity, observationState: EventObservationState.Initial, + eventType: prepared.eventType, occurred, correlationId: prepared.correlationId.toString(), + causation: prepared.causationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })), + tags: prepared.tags.map(tag => new Tag(tag)), namedTags: [] + } + }; + return { prepared, stored, sequenceNumber }; + } + + private eventInstance(event: object): void { + if (!event || typeof event !== 'object') throw this.environment.unsupported('append.event', this.environment.sequenceId, 'Only registered event instances are supported.'); + } + + private serialize(event: object, options?: AppendOptions) { + try { + const prepared = prepareSingleAppend(event, this.options(options)); + const content = this.payload.checkedContent(event, prepared.content, 'append.content', + 'Content differs from the event schema; null, missing and extra values are not proven.'); + return { prepared, content }; + } catch (error) { + if (error instanceof UnsupportedEventSequenceOperation) throw error; + throw this.environment.unsupported('append.serialization', event.constructor.name, `Payload or metadata could not be serialized: ${String(error)}.`); + } + } + + private options(options?: AppendOptions): AppendOptions { + return { subject: options?.subject, correlationId: this.environment.correlationId?.() }; + } + + private correlation(prepared: ReturnType, event: object): void { + try { + toContractsGuid(prepared.correlationId); + if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(prepared.correlationId.toString())) throw new Error('Invalid correlation ID.'); + } catch { + throw this.environment.unsupported('append.correlationId', event.constructor.name, 'Correlation ID must be a valid GUID.'); + } + } + + private context(prepared: ReturnType, event: object): void { + if (!this.metadata.ambientContext(prepared.identity, prepared.causationChain)) { + throw this.environment.unsupported('append.context', event.constructor.name, 'Ambient identity or causation metadata is not fixture-backed.'); + } + } + + private occurred(event: object): Date { + const occurred = this.environment.clock(); + // Date has millisecond precision; only the UTC year range representable by DateTimeOffset is supported. + if (!(occurred instanceof Date) || !this.validTime(occurred)) { + throw this.environment.unsupported('append.clock', event.constructor.name, 'Clock must return a valid Date within UTC years 1–9999 (millisecond precision).'); + } + return occurred; + } + + private validTime(value: Date): boolean { + return !Number.isNaN(value.getTime()) && value.getUTCFullYear() >= 1 && value.getUTCFullYear() <= 9999; + } +} From 9a4b71805f56695becb05194398639d70d10162c Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 11 Nov 2026 20:41:41 +0100 Subject: [PATCH 7/7] Simplify remaining in-process validation predicates --- Source/testing/InProcessBatchAppend.ts | 6 +++++- .../ProjectionInitialValueCapabilities.ts | 6 +++++- .../projections/ProjectionScalarConverter.ts | 14 +++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Source/testing/InProcessBatchAppend.ts b/Source/testing/InProcessBatchAppend.ts index db21e314..2be398c2 100644 --- a/Source/testing/InProcessBatchAppend.ts +++ b/Source/testing/InProcessBatchAppend.ts @@ -38,13 +38,17 @@ export class InProcessBatchAppend { private validateEntries(entries: object[], shared: AppendOptions | undefined, sameSource: boolean): void { this.array(entries); this.metadata.batch(shared); - if (shared?.occurred !== undefined) this.metadata.checkedDate(shared.occurred, 'appendMany.occurred'); + this.sharedOccurrence(shared); for (const entry of entries) { if (sameSource) break; this.metadata.entry(entry as EventForEventSourceId); } } + private sharedOccurrence(shared?: AppendOptions): void { + if (shared?.occurred !== undefined) this.metadata.checkedDate(shared.occurred, 'appendMany.occurred'); + } + private array(entries: object[]): void { if (!Array.isArray(entries)) throw this.environment.unsupported('appendMany.arguments', this.environment.sequenceId, 'Events must be an array.'); if (!entries.length) throw this.environment.unsupported('appendMany.empty', this.environment.sequenceId, 'The kernel rejects an empty batch.'); diff --git a/Source/testing/projections/ProjectionInitialValueCapabilities.ts b/Source/testing/projections/ProjectionInitialValueCapabilities.ts index fd23e149..c40cd297 100644 --- a/Source/testing/projections/ProjectionInitialValueCapabilities.ts +++ b/Source/testing/projections/ProjectionInitialValueCapabilities.ts @@ -11,7 +11,7 @@ export class ProjectionInitialValueCapabilities { let initial: unknown; try { initial = JSON.parse(initialState); } catch { reject('InitialModelState', 'initial values must be a JSON object'); } - if (initial === null || typeof initial !== 'object' || Array.isArray(initial)) { + if (!this.object(initial)) { reject('InitialModelState', 'initial values must be a JSON object'); } for (const [property, value] of Object.entries(initial as Record)) { @@ -22,6 +22,10 @@ export class ProjectionInitialValueCapabilities { return initial as Record; } + private static object(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); + } + private static check(value: unknown, target: JsonSchema, path: string, reject: ProjectionCapabilityReject): void { if (value === null && this.nullable(target)) return; if (this.structured(target) || target.format === 'date-time') { diff --git a/Source/testing/projections/ProjectionScalarConverter.ts b/Source/testing/projections/ProjectionScalarConverter.ts index 4ed135d5..af5bf916 100644 --- a/Source/testing/projections/ProjectionScalarConverter.ts +++ b/Source/testing/projections/ProjectionScalarConverter.ts @@ -21,11 +21,15 @@ export class ProjectionScalarConverter { private static invalidInteger(number: number, schema: JsonSchema): boolean { if (schema.type !== 'integer') return false; if (!Number.isSafeInteger(number)) return true; - if (schema.format === 'int32') return number < -2147483648 || number > 2147483647; - if (schema.format === 'uint32') return number < 0 || number > 4294967295; + if (schema.format === 'int32') return this.outsideBounds(number, [-2147483648, 2147483647]); + if (schema.format === 'uint32') return this.outsideBounds(number, [0, 4294967295]); return false; } + private static outsideBounds(number: number, [minimum, maximum]: readonly [number, number]): boolean { + return number < minimum || number > maximum; + } + private static boolean(value: unknown, eventContent: boolean): boolean { if (typeof value === 'boolean') return value; if (!eventContent && this.booleanText(value)) return value.toLowerCase() === 'true'; @@ -63,11 +67,15 @@ export class ProjectionScalarConverter { private static utcDateTime(value: unknown): boolean { // Date.parse accepts years beyond DateTime.MaxValue and normalizes invalid calendar dates. - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) return false; + if (!this.utcDateTimeText(value)) return false; if (+value.slice(0, 4) < 1 || !Number.isFinite(Date.parse(value))) return false; return new Date(value).toISOString().slice(0, 19) === value.slice(0, 19); } + private static utcDateTimeText(value: unknown): value is string { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value); + } + private static stringSchema(schema: JsonSchema): boolean { return schema.type === 'string' || (Array.isArray(schema.type) && schema.type[0] === 'string'); }