@cratis/chronicle 6.18.0 → 6.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/testing/IReadModelProcessor.d.ts +7 -0
  2. package/dist/testing/IReadModelProcessor.d.ts.map +1 -0
  3. package/dist/testing/IReadModelProcessor.js +4 -0
  4. package/dist/testing/IReadModelProcessor.js.map +1 -0
  5. package/dist/testing/ReadModelScenario.d.ts +6 -8
  6. package/dist/testing/ReadModelScenario.d.ts.map +1 -1
  7. package/dist/testing/ReadModelScenario.js +50 -50
  8. package/dist/testing/ReadModelScenario.js.map +1 -1
  9. package/dist/testing/ReadModelState.d.ts +6 -0
  10. package/dist/testing/ReadModelState.d.ts.map +1 -0
  11. package/dist/testing/ReadModelState.js +4 -0
  12. package/dist/testing/ReadModelState.js.map +1 -0
  13. package/dist/testing/ReducerReadModelProcessor.d.ts +13 -0
  14. package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -0
  15. package/dist/testing/ReducerReadModelProcessor.js +38 -0
  16. package/dist/testing/ReducerReadModelProcessor.js.map +1 -0
  17. package/dist/testing/ScenarioEvent.d.ts +8 -0
  18. package/dist/testing/ScenarioEvent.d.ts.map +1 -0
  19. package/dist/testing/ScenarioEvent.js +4 -0
  20. package/dist/testing/ScenarioEvent.js.map +1 -0
  21. package/dist/testing/index.d.ts +1 -0
  22. package/dist/testing/index.d.ts.map +1 -1
  23. package/dist/testing/index.js +1 -0
  24. package/dist/testing/index.js.map +1 -1
  25. package/dist/testing/projections/ProjectionCapabilities.d.ts +3 -0
  26. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  27. package/dist/testing/projections/ProjectionCapabilities.js +117 -43
  28. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  29. package/dist/testing/projections/ProjectionExpressionEvaluator.d.ts +9 -0
  30. package/dist/testing/projections/ProjectionExpressionEvaluator.d.ts.map +1 -0
  31. package/dist/testing/projections/ProjectionExpressionEvaluator.js +68 -0
  32. package/dist/testing/projections/ProjectionExpressionEvaluator.js.map +1 -0
  33. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +30 -0
  34. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -0
  35. package/dist/testing/projections/ProjectionReadModelProcessor.js +142 -0
  36. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -0
  37. package/dist/testing/projections/ProjectionValueConverter.d.ts +13 -0
  38. package/dist/testing/projections/ProjectionValueConverter.d.ts.map +1 -0
  39. package/dist/testing/projections/ProjectionValueConverter.js +122 -0
  40. package/dist/testing/projections/ProjectionValueConverter.js.map +1 -0
  41. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts +0 -2
  42. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts.map +1 -1
  43. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js +0 -4
  44. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js.map +1 -1
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +1 -1
  47. package/testing/IReadModelProcessor.ts +10 -0
  48. package/testing/ReadModelScenario.spec.ts +156 -10
  49. package/testing/ReadModelScenario.ts +57 -57
  50. package/testing/ReadModelState.ts +5 -0
  51. package/testing/ReducerReadModelProcessor.ts +39 -0
  52. package/testing/ScenarioEvent.ts +7 -0
  53. package/testing/index.ts +1 -0
  54. package/testing/projections/ProjectionCapabilities.ts +100 -39
  55. package/testing/projections/ProjectionExpressionEvaluator.ts +57 -0
  56. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +135 -0
  57. package/testing/projections/ProjectionReadModelProcessor.ts +147 -0
  58. package/testing/projections/ProjectionValueConverter.spec.ts +81 -0
  59. package/testing/projections/ProjectionValueConverter.ts +113 -0
  60. package/testing/projections/for_ProjectionCapabilities/given/Changed.ts +1 -3
  61. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +81 -1
  62. package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +40 -12
  63. package/testing/projections/for_ProjectionCapabilities/when_validating_schema_boundaries.spec.ts +76 -29
  64. package/testing/projections/for_ProjectionCapabilities/when_validating_supported_operations.spec.ts +8 -35
@@ -0,0 +1,135 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { readFileSync, readdirSync } from 'node:fs';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { ReadModelObserverType, type ProjectionDefinition } from '@cratis/chronicle.contracts';
8
+ import { EventType } from '../../events/EventType.js';
9
+ import type { EventContext } from '../../events/EventContext.js';
10
+ import { buildReadModelDefinition } from '../../readModels/buildReadModelDefinition.js';
11
+ import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js';
12
+ import type { ProjectionEventSchema } from '../../projections/ProjectionEventSchema.js';
13
+ import type { JsonSchema } from '../../schemas/JsonSchema.js';
14
+ import type { ScenarioEvent } from '../ScenarioEvent.js';
15
+ import { ProjectionReadModelProcessor } from './ProjectionReadModelProcessor.js';
16
+
17
+ chai.should();
18
+
19
+ type FixtureSchema = Omit<JsonSchema, 'type' | 'properties' | 'items' | 'additionalProperties'> & {
20
+ type?: JsonSchema['type'] | readonly ['string', 'null'];
21
+ properties?: Record<string, FixtureSchema>;
22
+ items?: FixtureSchema;
23
+ additionalProperties?: boolean | FixtureSchema;
24
+ };
25
+
26
+ interface Fixture {
27
+ kind: 'kernelSemantics' | 'oracleGuard';
28
+ wireDefinition: ProjectionDefinition;
29
+ readModel: { schema: FixtureSchema };
30
+ eventSchemas: Array<{ eventType: { Id: string; Generation: number }; schema: FixtureSchema }>;
31
+ events: Array<{ context: Record<string, unknown> & { eventType: { Id: string; Generation: number }; eventSourceId: string; sequenceNumber: string; occurred: string }; content: unknown; expectedError?: { type: string; message: string } }>;
32
+ expected: Array<{ sequenceNumber: string; engineState: Record<string, unknown>; publicRead: Record<string, unknown>; error?: { type: string; message: string } }>;
33
+ }
34
+
35
+ class OracleReadModel {}
36
+ const directory = new URL('./fixtures/', import.meta.url);
37
+ const allFixtures = readdirSync(directory).filter(name => name.endsWith('.json'))
38
+ .map(name => ({ name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture }));
39
+ const fixtures = allFixtures.filter(({ fixture }) => fixture.kind === 'kernelSemantics');
40
+ const guardErrors: Record<string, { type: string; message: string }> = {
41
+ 'aggregate-only-automap.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic requires a kernel-backed test' },
42
+ 'arithmetic.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic requires a kernel-backed test' },
43
+ 'rounded-integer-operand.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic requires a kernel-backed test' },
44
+ 'int32-overflow.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic requires a kernel-backed test' },
45
+ 'null-accumulator.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic requires a kernel-backed test' },
46
+ 'concept-shaped-objects.json': { type: 'UnsupportedProjectionOperation', message: 'nested event property paths require a kernel-backed test' },
47
+ 'mapped-and-removed.json': { type: 'UnsupportedProjectionOperation', message: 'both From and RemovedWith require a kernel-backed test' },
48
+ 'mapped-id-key-wins.json': { type: 'UnsupportedProjectionOperation', message: 'identifier or case-insensitively colliding target mappings require a kernel-backed test' },
49
+ 'reject-protected-fields.json': { type: 'UnsupportedProjectionOperation', message: 'protected fields require a kernel-backed test' },
50
+ 'reject-protected-identifier.json': { type: 'UnsupportedProjectionOperation', message: 'protected fields require a kernel-backed test' }
51
+ };
52
+
53
+ function setup(fixture: Fixture): { processor: ProjectionReadModelProcessor<OracleReadModel>; events: ScenarioEvent[] } {
54
+ const definition = fixture.wireDefinition;
55
+ const schemas = new Map<string, ProjectionEventSchema>(fixture.eventSchemas.map(entry => [
56
+ `${entry.eventType.Id}:${entry.eventType.Generation}:0`,
57
+ { eventType: { ...entry.eventType, Tombstone: false }, schema: entry.schema as JsonSchema }
58
+ ]));
59
+ const compiled: CompiledProjectionDefinitions = {
60
+ definitions: [definition],
61
+ readModels: [buildReadModelDefinition({ identifier: definition.ReadModel, schema: JSON.stringify(fixture.readModel.schema),
62
+ sinkTypeId: 'test', observerType: ReadModelObserverType.Projection, observerIdentifier: definition.Identifier })],
63
+ provenance: new Map([[definition, []]]), eventSchemas: new Map([[definition, schemas]])
64
+ };
65
+ const events: ScenarioEvent[] = fixture.events.map(({ context, content }) => ({
66
+ sourceId: context.eventSourceId, content,
67
+ context: {
68
+ ...context,
69
+ eventType: EventType.parse(`${context.eventType.Id}+${context.eventType.Generation}`),
70
+ sequenceNumber: BigInt(context.sequenceNumber), occurred: new Date(context.occurred),
71
+ causation: context.causation ?? [], tags: context.tags ?? [],
72
+ correlationId: context.correlationId ?? '00000000-0000-0000-0000-000000000000'
73
+ } as EventContext
74
+ }));
75
+ return { processor: new ProjectionReadModelProcessor(OracleReadModel, compiled, definition), events };
76
+ }
77
+
78
+ describe('when replaying committed kernel semantics fixtures', () => {
79
+ it('should run kernel fixtures and assert every oracle guard', () => {
80
+ fixtures.length.should.be.greaterThan(0);
81
+ allFixtures.filter(({ fixture }) => fixture.kind === 'oracleGuard').length.should.equal(Object.keys(guardErrors).length);
82
+ allFixtures.every(({ fixture }) => fixture.kind === 'kernelSemantics' || fixture.kind === 'oracleGuard').should.be.true;
83
+ });
84
+
85
+ for (const { name, fixture } of fixtures) {
86
+ it(`should match each engine and public read snapshot for ${name}`, async () => {
87
+ const { processor, events } = setup(fixture);
88
+ fixture.expected.length.should.equal(fixture.events.length);
89
+ for (let step = 0; step < events.length; step++) {
90
+ const expected = fixture.expected[step];
91
+ const input = fixture.events[step];
92
+ expected.sequenceNumber.should.equal(input.context.sequenceNumber);
93
+ if (input.expectedError) {
94
+ expected.error!.should.deep.equal(input.expectedError);
95
+ (step === events.length - 1).should.be.true;
96
+ await processor.process(events.slice(0, step + 1)).then(
97
+ () => { throw new Error('Evaluator accepted a kernel-rejected mapping'); },
98
+ error => {
99
+ (error instanceof RangeError).should.be.true;
100
+ (error as Error).message.should.include('not supported by integer/int32');
101
+ }
102
+ );
103
+ } else {
104
+ await processor.process(events.slice(0, step + 1));
105
+ processor.engineState.should.deep.equal(expected.engineState);
106
+ processor.publicRead.should.deep.equal(expected.publicRead);
107
+ }
108
+ }
109
+ });
110
+ }
111
+
112
+ for (const { name, fixture } of allFixtures.filter(({ fixture }) => fixture.kind === 'oracleGuard')) {
113
+ it(`should reject the oracle guard ${name} rather than silently reproduce unproven behavior`, async () => {
114
+ const expected = guardErrors[name];
115
+ (expected !== undefined).should.be.true;
116
+ for (let index = 0; index < fixture.events.length; index++) {
117
+ const error = fixture.events[index].expectedError;
118
+ if (error) {
119
+ fixture.expected[index].error!.should.deep.equal(error);
120
+ (index === fixture.events.length - 1).should.be.true;
121
+ }
122
+ }
123
+ let actual: unknown;
124
+ try {
125
+ const { processor, events } = setup(fixture);
126
+ await processor.process(events);
127
+ } catch (error) {
128
+ actual = error;
129
+ }
130
+ (actual instanceof Error).should.be.true;
131
+ (actual as Error).name.should.equal(expected.type);
132
+ (actual as Error).message.should.include(expected.message);
133
+ });
134
+ }
135
+ });
@@ -0,0 +1,147 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { AutoMap, type ProjectionDefinition } from '@cratis/chronicle.contracts';
5
+ import type { Constructor } from '@cratis/fundamentals';
6
+ import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js';
7
+ import { eventContractPath } from '../../projections/eventContractPath.js';
8
+ import type { FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
9
+ import { deserializeReadModel } from '../../readModels/deserializeReadModel.js';
10
+ import type { JsonSchema } from '../../schemas/JsonSchema.js';
11
+ import type { IReadModelProcessor } from '../IReadModelProcessor.js';
12
+ import type { ReadModelState } from '../ReadModelState.js';
13
+ import type { ScenarioEvent } from '../ScenarioEvent.js';
14
+ import { ProjectionCapabilities } from './ProjectionCapabilities.js';
15
+ import { ProjectionExpressionEvaluator } from './ProjectionExpressionEvaluator.js';
16
+ import { ProjectionValueConverter } from './ProjectionValueConverter.js';
17
+ import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
18
+
19
+ /** In-process interpreter of the validated, registered projection contract subset. */
20
+ export class ProjectionReadModelProcessor<TReadModel extends object> implements IReadModelProcessor<TReadModel> {
21
+ private readonly _schema: JsonSchema;
22
+ private readonly _initial: Record<string, unknown>;
23
+ private readonly _from: ReadonlyMap<string, FromRecord>;
24
+ private readonly _removed: ReadonlySet<string>;
25
+ private readonly _eventSchemas: ReadonlyMap<string, JsonSchema>;
26
+ private readonly _bindings: ReadonlyMap<string, { generation: number; path: string; declaration: string }>;
27
+ private _engineState: Record<string, Record<string, unknown>> = {};
28
+ private _publicRead: Record<string, Record<string, unknown>> = {};
29
+
30
+ constructor(private readonly _model: Constructor<TReadModel>, compiled: CompiledProjectionDefinitions, private readonly _definition: ProjectionDefinition) {
31
+ ProjectionCapabilities.validate(compiled, _definition);
32
+ const registration = compiled.readModels.find(entry => entry.Type.Identifier === _definition.ReadModel)!;
33
+ this._schema = JSON.parse(registration.Schema) as JsonSchema;
34
+ this._initial = JSON.parse(_definition.InitialModelState || '{}') as Record<string, unknown>;
35
+ const wire = _definition as unknown as { From: FromRecord[]; RemovedWith: RemovedWithRecord[] };
36
+ this._from = new Map((wire.From ?? []).map(entry => [entry.Key.Id, entry]));
37
+ this._removed = new Set((wire.RemovedWith ?? []).map(entry => entry.Key.Id));
38
+ this._bindings = new Map([...(wire.From ?? []), ...(wire.RemovedWith ?? [])].map(entry => {
39
+ const section = (wire.From ?? []).includes(entry as FromRecord) ? 'From' : 'RemovedWith';
40
+ const path = eventContractPath(section, entry.Key);
41
+ const declaration = compiled.provenance.get(_definition)?.find(provenance => provenance.contractPath === path)?.declaration ?? 'contract';
42
+ return [entry.Key.Id, { generation: entry.Key.Generation, path, declaration }];
43
+ }));
44
+ this._eventSchemas = new Map([...(compiled.eventSchemas.get(_definition) ?? new Map()).values()]
45
+ .map(entry => [entry.eventType.Id, entry.schema]));
46
+ }
47
+
48
+ /** A snapshot of the production-style in-memory sink, for kernel conformance fixtures. */
49
+ get engineState(): Record<string, Record<string, unknown>> { return structuredClone(this._engineState); }
50
+ /** A schema-normalized keyed read, for kernel conformance fixtures. */
51
+ get publicRead(): Record<string, Record<string, unknown>> { return structuredClone(this._publicRead); }
52
+
53
+ async process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>> {
54
+ const states = new Map<string, ReadModelState<TReadModel>>();
55
+ const engine: Record<string, Record<string, unknown>> = Object.create(null);
56
+ for (const event of events) {
57
+ const typeId = event.context.eventType.id.value;
58
+ const from = this._from.get(typeId);
59
+ const removed = this._removed.has(typeId);
60
+ if (!from && !removed) continue;
61
+ const binding = this._bindings.get(typeId)!;
62
+ if (event.context.eventType.generation.value !== binding.generation) {
63
+ throw new UnsupportedProjectionOperation(String(this._definition.ReadModel), binding.path, binding.declaration,
64
+ `seeded event generation ${event.context.eventType.generation.value} differs from subscribed generation ${binding.generation}; multi-generation history requires a kernel-backed test`);
65
+ }
66
+ // A root removal has the final say when an event is registered for both operations.
67
+ const key = this.keyFor(event.sourceId);
68
+ if (removed) {
69
+ delete engine[key];
70
+ states.set(event.sourceId, { instance: null, deleted: true });
71
+ continue;
72
+ }
73
+ if (!from) continue;
74
+ const state = engine[key] ?? this.initialState(key, event);
75
+ const properties = { ...from.Value.Properties };
76
+ const schema = this._eventSchemas.get(typeId);
77
+ const content = ProjectionValueConverter.eventContent(event.content, schema!);
78
+ if (this._definition.AutoMap !== AutoMap.Disabled && schema) {
79
+ const explicit = new Set(Object.keys(properties).map(name => name.toLowerCase()));
80
+ const excluded = new Set((this._definition.NoAutoMapProperties ?? []).map(name => name.toLowerCase()));
81
+ for (const source of Object.keys(schema.properties ?? {})) {
82
+ const destination = Object.keys(this._schema.properties ?? {}).find(name => name.toLowerCase() === source.toLowerCase());
83
+ if (destination && !explicit.has(destination.toLowerCase()) && !excluded.has(destination.toLowerCase())) {
84
+ properties[destination] = source;
85
+ explicit.add(destination.toLowerCase());
86
+ }
87
+ }
88
+ }
89
+ for (const [destination, expression] of Object.entries(properties)) {
90
+ const target = this._schema.properties![destination];
91
+ const value = ProjectionExpressionEvaluator.value(expression, content, event.context, target);
92
+ // The kernel compares old and new null values; null on an absent member is no change.
93
+ if (value !== null || state[destination] !== undefined) state[destination] = value;
94
+ }
95
+ // InMemorySink.ApplyChanges always restores lowercase id after applying mappings.
96
+ state.id = ProjectionValueConverter.convert(key, this._schema.properties!.id);
97
+ engine[key] = state;
98
+ states.set(event.sourceId, { instance: this.materialize(state), deleted: false });
99
+ }
100
+ this._engineState = engine;
101
+ this._publicRead = Object.fromEntries(Object.entries(engine).map(([key, value]) => [key, this.normalize(value)]));
102
+ return states;
103
+ }
104
+
105
+ private keyFor(source: string): string {
106
+ const identifier = this._schema.properties!.id;
107
+ const canonical = String(ProjectionValueConverter.convert(source, identifier));
108
+ if (identifier.format === 'guid' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(source)) {
109
+ throw new RangeError(`Projection event source '${source}' is not a canonical GUID identifier.`);
110
+ }
111
+ if (canonical !== source) throw new RangeError(`Projection event source '${source}' is not a canonical ${identifier.type} identifier.`);
112
+ return canonical;
113
+ }
114
+
115
+ private initialState(key: string, event: ScenarioEvent): Record<string, unknown> {
116
+ const initial: Record<string, unknown> = Object.create(null);
117
+ if (Object.keys(this._initial).length) {
118
+ Object.assign(initial, ProjectionValueConverter.convert(structuredClone(this._initial), this._schema, false, false));
119
+ }
120
+ if (!Object.keys(this._initial).length) {
121
+ for (const [name, property] of Object.entries(this._schema.properties ?? {})) {
122
+ if (property.type === 'array') initial[name] = [];
123
+ }
124
+ }
125
+ initial.id = ProjectionValueConverter.convert(key, this._schema.properties!.id);
126
+ initial.__subject = event.context.subject ?? event.sourceId;
127
+ initial.__initialized = true;
128
+ return initial;
129
+ }
130
+
131
+ private normalize(state: Record<string, unknown>): Record<string, unknown> {
132
+ const result: Record<string, unknown> = Object.create(null);
133
+ for (const [name, schema] of Object.entries(this._schema.properties ?? {})) {
134
+ const value = state[name];
135
+ if (value !== undefined && value !== null) result[name] = value;
136
+ else {
137
+ const fallback = ProjectionValueConverter.defaultValue(schema);
138
+ if (fallback !== null && fallback !== undefined) result[name] = fallback;
139
+ }
140
+ }
141
+ return result;
142
+ }
143
+
144
+ private materialize(state: Record<string, unknown>): TReadModel {
145
+ return deserializeReadModel(this._model, JSON.stringify(this.normalize(state)));
146
+ }
147
+ }
@@ -0,0 +1,81 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { chai, describe, expect, it } from 'vitest';
5
+ import { ProjectionValueConverter } from './ProjectionValueConverter.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when converting schema-bound projection values', () => {
10
+ for (const type of ['number', 'integer'] as const) {
11
+ it(`should reject numeric text in a ${type} event field`, () => {
12
+ (() => ProjectionValueConverter.eventContent({ amount: '12' }, { type: 'object', properties: {
13
+ amount: { type, format: type === 'number' ? 'double' : 'int32' }
14
+ } })).should.throw('must be a JSON number');
15
+ });
16
+ }
17
+
18
+ for (const format of ['int32', 'uint32']) {
19
+ it(`should reject decimal-spelled text mapped into ${format} even when the number is integral`, () => {
20
+ (() => ProjectionValueConverter.convert('1.0', { type: 'integer', format })).should.throw(RangeError, `not supported by integer/${format}`);
21
+ (ProjectionValueConverter.convert('1', { type: 'integer', format }) as number).should.equal(1);
22
+ });
23
+ }
24
+
25
+ it('should reject invalid boolean text rather than materialize it as a string', () => {
26
+ (() => ProjectionValueConverter.convert('not-boolean', { type: 'boolean' })).should.throw(RangeError, 'requires a kernel-backed test');
27
+ (() => ProjectionValueConverter.eventContent({ flag: 'not-boolean' }, { type: 'object', properties: {
28
+ flag: { type: 'boolean' }
29
+ } })).should.throw(RangeError, 'requires a kernel-backed test');
30
+ });
31
+
32
+ it('should reject invalid GUID text at the expression boundary', () => {
33
+ (() => ProjectionValueConverter.convert('not-a-guid', { type: 'string', format: 'guid' })).should.throw(RangeError, 'requires a kernel-backed test');
34
+ });
35
+
36
+ it('should prefer exact-case event properties over case-insensitive fallbacks', () => {
37
+ const schema = { type: 'object', properties: { name: { type: 'string' }, Name: { type: 'string' } } };
38
+ const content = { Name: 'uppercase', name: 'lowercase' };
39
+ expect(ProjectionValueConverter.eventContent(content, schema)).toEqual({ name: 'lowercase', Name: 'uppercase' });
40
+ expect(ProjectionValueConverter.convert(content, schema)).toEqual({ name: 'lowercase', Name: 'uppercase' });
41
+ expect(ProjectionValueConverter.eventContent({ NAME: 'fallback' }, schema)).toEqual({ name: 'fallback', Name: 'fallback' });
42
+ expect(ProjectionValueConverter.eventContent({ Name: 'uppercase', name: null }, schema)).toEqual({ name: 'uppercase', Name: 'uppercase' });
43
+ expect(ProjectionValueConverter.eventContent({ name: null, Name: 'uppercase' }, schema)).toEqual({ Name: 'uppercase' });
44
+ });
45
+
46
+ for (const [field, property] of [
47
+ ['text', { type: 'string' }], ['nullableText', { type: ['string', 'null'] }],
48
+ ['identifier', { type: 'string', format: 'guid' }]
49
+ ] as const) {
50
+ for (const value of [5, { value: 'text' }]) {
51
+ it(`should reject a non-string ${field} event field`, () => {
52
+ (() => ProjectionValueConverter.eventContent({ [field]: value }, { type: 'object', properties: {
53
+ [field]: property
54
+ } })).should.throw(RangeError, 'must be a JSON string');
55
+ });
56
+ }
57
+ }
58
+
59
+ it('should retain string coercion for expression values', () => {
60
+ (ProjectionValueConverter.convert(5n, { type: 'string' }) as string).should.equal('5');
61
+ });
62
+
63
+ it('should admit UTC date-time boundaries and reject years or calendar dates outside DateTime', () => {
64
+ const schema = { type: 'string', format: 'date-time' };
65
+ (ProjectionValueConverter.convert('0001-01-01T00:00:00Z', schema) as string).should.equal('0001-01-01T00:00:00Z');
66
+ (ProjectionValueConverter.convert('9999-12-31T23:59:59.999Z', schema) as string).should.equal('9999-12-31T23:59:59.999Z');
67
+ for (const value of ['+010000-01-01T00:00:00.000Z', '0000-01-01T00:00:00Z',
68
+ '2025-02-30T00:00:00Z', '9999-12-31T24:00:00Z', '2025-01-01T00:00:00+01:00']) {
69
+ (() => ProjectionValueConverter.convert(value, schema)).should.throw(RangeError, 'outside the supported DateTime range or UTC ISO format');
70
+ }
71
+ });
72
+
73
+ it('should accept numeric text in an unformatted TypeScript Number field', () => {
74
+ (ProjectionValueConverter.convert('1.0', { type: 'number' }) as number).should.equal(1);
75
+ });
76
+
77
+ it('should lowercase a GUID supplied as an expression literal', () => {
78
+ (ProjectionValueConverter.convert('ABCDEFAB-ABCD-ABCD-ABCD-ABCDEFABCDEF', { type: 'string', format: 'guid' }) as string)
79
+ .should.equal('abcdefab-abcd-abcd-abcd-abcdefabcdef');
80
+ });
81
+ });
@@ -0,0 +1,113 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { JsonSchema } from '../../schemas/JsonSchema.js';
5
+
6
+ /** Converts values only in the numeric and scalar schema subset admitted by ProjectionCapabilities. */
7
+ export class ProjectionValueConverter {
8
+ /** Kernel JsonSchemaExtensions.GetDefaultValue: only nullable schemas have a null default. */
9
+ static defaultValue(schema: JsonSchema): unknown {
10
+ if (Array.isArray(schema.type) && schema.type.includes('null')) return null;
11
+ if (schema.format === 'guid') return '00000000-0000-0000-0000-000000000000';
12
+ if (schema.format === 'date-time') return '0001-01-01T00:00:00';
13
+ if (schema.type === 'number' || schema.type === 'integer') return 0;
14
+ if (schema.type === 'boolean') return false;
15
+ return undefined;
16
+ }
17
+
18
+ /** A null exact-case value falls through to the first case-insensitive property in JSON order. */
19
+ private static sourceKey(input: Record<string, unknown>, name: string): string | undefined {
20
+ return Object.hasOwn(input, name) && input[name] != null
21
+ ? name : Object.keys(input).find(key => key.toLowerCase() === name.toLowerCase());
22
+ }
23
+
24
+ /** The kernel deserializes event JSON against its registered schema before mapping. */
25
+ static eventContent(content: unknown, schema: JsonSchema): Record<string, unknown> {
26
+ const serialized = content !== null && typeof content === 'object' && !Array.isArray(content)
27
+ ? content as Record<string, unknown> : {};
28
+ const result: Record<string, unknown> = Object.create(null);
29
+ for (const [name, property] of Object.entries(schema.properties ?? {})) {
30
+ const source = this.sourceKey(serialized, name);
31
+ const value = source === undefined ? null : serialized[source];
32
+ if (value !== null && value !== undefined) result[name] = this.convert(value, property, true, false);
33
+ else {
34
+ const fallback = this.defaultValue(property);
35
+ if (fallback !== null && fallback !== undefined) result[name] = fallback;
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+
41
+ static convert(value: unknown, schema: JsonSchema, eventContent = false, expression = true): unknown {
42
+ if (value == null) return null;
43
+ // Concept conversion belongs to expression evaluation, never schema-bound input deserialization.
44
+ if (expression && schema.type !== 'object' && schema.type !== 'array' &&
45
+ typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && 'value' in value) {
46
+ return this.convert((value as Record<string, unknown>).value, schema, eventContent, expression);
47
+ }
48
+ if (schema.type === 'object' && typeof value === 'object' && !Array.isArray(value)) {
49
+ const input = value as Record<string, unknown>;
50
+ if (schema.additionalProperties || !schema.properties) return structuredClone(input);
51
+ const result: Record<string, unknown> = Object.create(null);
52
+ for (const [name, property] of Object.entries(schema.properties)) {
53
+ const source = this.sourceKey(input, name);
54
+ if (source !== undefined && input[source] != null) result[name] = this.convert(input[source], property, eventContent, false);
55
+ else {
56
+ const fallback = this.defaultValue(property);
57
+ if (fallback !== null && fallback !== undefined) result[name] = fallback;
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+ if (schema.type === 'array' && Array.isArray(value)) {
63
+ return value.map(item => schema.items ? this.convert(item, schema.items, eventContent, false) : structuredClone(item));
64
+ }
65
+ if (schema.type === 'boolean') {
66
+ if (typeof value === 'boolean') return value;
67
+ if (!eventContent && typeof value === 'string' && /^(true|false)$/i.test(value)) return value.toLowerCase() === 'true';
68
+ throw new RangeError(`Projection boolean value '${String(value)}' requires a kernel-backed test (ChronicleKernelScenario / live kernel).`);
69
+ }
70
+ if (schema.type === 'number' || schema.type === 'integer') {
71
+ if (eventContent && typeof value !== 'number') {
72
+ throw new RangeError(`Projection event numeric value '${String(value)}' must be a JSON number.`);
73
+ }
74
+ if (typeof value !== 'number' && (typeof value !== 'string' ||
75
+ !(schema.type === 'integer' ? /^[+-]?\d+$/.test(value) : /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)))) {
76
+ throw new RangeError(`Projection numeric value '${String(value)}' is not supported by ${schema.type}/${schema.format ?? 'double'}.`);
77
+ }
78
+ const number = typeof value === 'number' ? value : Number(value);
79
+ this.checkNumber(number, schema);
80
+ return number;
81
+ }
82
+ if (schema.format === 'date-time') {
83
+ if (value === '0001-01-01T00:00:00') return value; // Kernel default for a missing DateTime member.
84
+ // Bound the accepted subset to UTC ISO text with a four-digit year. Date.parse accepts
85
+ // years beyond DateTime.MaxValue, and silently normalizes some invalid calendar dates.
86
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) ||
87
+ +value.slice(0, 4) < 1 || !Number.isFinite(Date.parse(value)) ||
88
+ new Date(value).toISOString().slice(0, 19) !== value.slice(0, 19)) {
89
+ throw new RangeError(`Projection date-time value '${String(value)}' is outside the supported DateTime range or UTC ISO format.`);
90
+ }
91
+ return value;
92
+ }
93
+ if (eventContent && (schema.type === 'string' || (Array.isArray(schema.type) && schema.type[0] === 'string')) && typeof value !== 'string') {
94
+ throw new RangeError(`Projection event string value '${String(value)}' must be a JSON string.`);
95
+ }
96
+ if (schema.format === 'guid' && typeof value === 'string') {
97
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
98
+ throw new RangeError(`Projection GUID value '${value}' requires a kernel-backed test (ChronicleKernelScenario / live kernel).`);
99
+ }
100
+ return value.toLowerCase();
101
+ }
102
+ if (schema.type === 'string' && typeof value !== 'string') return String(value);
103
+ return value;
104
+ }
105
+
106
+ static checkNumber(number: number, schema: JsonSchema): void {
107
+ if (!Number.isFinite(number) || (schema.type === 'integer' && (!Number.isSafeInteger(number) ||
108
+ (schema.format === 'int32' && (number < -2147483648 || number > 2147483647)) ||
109
+ (schema.format === 'uint32' && (number < 0 || number > 4294967295))))) {
110
+ throw new RangeError(`Projection numeric value '${number}' is outside the supported ${schema.type}/${schema.format ?? 'double'} range.`);
111
+ }
112
+ }
113
+ }
@@ -4,9 +4,7 @@
4
4
  import { field } from '@cratis/fundamentals';
5
5
  import { eventType } from '../../../../events/eventTypeDecorator.js';
6
6
 
7
- export class Changed { name!: string; quantity!: number; labels!: string[]; details!: object; }
7
+ export class Changed { name!: string; quantity!: number; }
8
8
  field(String)(Changed.prototype, 'name');
9
9
  field(Number)(Changed.prototype, 'quantity');
10
- field(Array)(Changed.prototype, 'labels');
11
- field(Object)(Changed.prototype, 'details');
12
10
  eventType('capability-changed')(Changed);
@@ -80,7 +80,7 @@ describe('when rejecting unsupported operations before any event is seeded', ()
80
80
  { name: 'children', define: builder => { builder.from(Changed).children(model => model.labels, child => child.from(Removed)); }, path: 'Children.labels (.children)' },
81
81
  { name: 'nested', define: builder => { builder.from(Changed).nested(model => model.details, child => child.clearWith(Removed)); }, path: 'Nested.details (.nested)' },
82
82
  { name: 'removedWithJoin', define: builder => { builder.from(Changed).removedWithJoin(Removed); }, path: 'RemovedWithJoin[capability-removed:1] (.removedWithJoin)' },
83
- { name: 'removal key', define: builder => { builder.from(Changed).removedWith(Changed, removal => removal.usingKey(event => event.name)); }, path: 'RemovedWith[capability-changed:1].Key (.removedWith)' },
83
+ { name: 'removal key', define: builder => { builder.from(Removed).removedWith(Changed, removal => removal.usingKey(event => event.name)); }, path: 'RemovedWith[capability-changed:1].Key (.removedWith)' },
84
84
  { name: 'fromEvery', define: builder => { builder.from(Changed).fromEvery(all => all.set(model => model.name).toEventSourceId()); }, path: 'All (.fromEvery)' },
85
85
  { name: 'empty fromEvery', define: builder => { builder.from(Changed).fromEvery(all => all.excludeChildProjections()); }, path: 'All (.fromEvery)' },
86
86
  { name: 'passive', define: builder => { builder.from(Changed).passive(); }, path: 'IsActive (.passive)' },
@@ -103,6 +103,28 @@ describe('when rejecting unsupported operations before any event is seeded', ()
103
103
  'multiple generations of the same event-type id');
104
104
  });
105
105
 
106
+ it('should reject dotted event-content source paths before replay', () => {
107
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from =>
108
+ from.set(model => model.state).to(event => event.name)));
109
+ definition.From[0].Value.Properties.state = 'inner.value';
110
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties!.inner = {
111
+ type: 'object', properties: { value: { type: 'string' } }
112
+ };
113
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
114
+ .with.property('message').that.includes('From[capability-changed:1].Properties.state')
115
+ .and.includes('nested event property paths require a kernel-backed test');
116
+ });
117
+
118
+ for (const expression of ['true', 'True', 'false', 'False']) {
119
+ it(`should reject reserved literal ${expression} as an event property mapping`, () => {
120
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.state).to(event => event.name)));
121
+ definition.From[0].Value.Properties.state = expression;
122
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties![expression] = { type: 'string' };
123
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
124
+ .with.property('message').that.includes(`expression '${expression}'`).and.includes('boolean literal before event content');
125
+ });
126
+ }
127
+
106
128
  it('should reject a literal legacy $context. wire expression as unknown with its declaration', () => {
107
129
  const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.state).toEventContextProperty('eventType')));
108
130
  definition.From[0].Value.Properties.state = '$context.eventType';
@@ -111,6 +133,64 @@ describe('when rejecting unsupported operations before any event is seeded', ()
111
133
  .and.includes("expression '$context.eventType'").and.includes('$eventContext(...)');
112
134
  });
113
135
 
136
+ for (const property of ['causedBy', 'causedBy.onBehalfOf', 'causation', 'tags', 'observationState', 'eventType']) {
137
+ it(`should reject unproven ${property} context mappings with provenance`, () => {
138
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from =>
139
+ from.set(model => model.state).toEventContextProperty(property)));
140
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
141
+ .with.property('message').that.includes('From[capability-changed:1].Properties.state (.from().setFromContext)')
142
+ .and.includes('kernel-backed test');
143
+ });
144
+ }
145
+
146
+ it('should reject an event subscribed through both From and RemovedWith', () => {
147
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed).removedWith(Changed));
148
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
149
+ .with.property('message').that.includes('From[capability-changed:1]')
150
+ .and.includes('both From and RemovedWith require a kernel-backed test');
151
+ });
152
+
153
+ for (const expression of ['$add(quantity)', '$subtract(quantity)', '$count', '$increment', '$decrement']) {
154
+ it(`should reject all arithmetic ${expression} before replay`, () => {
155
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.total).toValue(0)));
156
+ definition.From[0].Value.Properties.total = expression;
157
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
158
+ .with.property('message').that.includes('arithmetic requires a kernel-backed test (ChronicleKernelScenario / live kernel)');
159
+ });
160
+ }
161
+
162
+ it('should reject explicit mappings into the sink-managed id', () => {
163
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.id).toEventSourceId()));
164
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
165
+ .with.property('message').that.includes('identifier or case-insensitively colliding target mappings require a kernel-backed test');
166
+ });
167
+
168
+ it('should reject AutoMap into the sink-managed id', () => {
169
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed));
170
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties!.id = { type: 'string' };
171
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
172
+ .with.property('message').that.includes('AutoMap.id').and.includes('identifier or case-insensitively colliding target mappings');
173
+ });
174
+
175
+ for (const target of [{ type: 'boolean' }, { type: 'integer', format: 'int32' }, { type: 'string', format: 'date-time' }]) {
176
+ it(`should reject $eventSourceId mapped into ${target.type}/${target.format ?? 'unformatted'}`, () => {
177
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.state).toEventSourceId()));
178
+ const schema = JSON.parse(compiled.readModels[0].Schema) as { properties: { state: typeof target } };
179
+ schema.properties.state = target;
180
+ compiled.readModels[0].Schema = JSON.stringify(schema);
181
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
182
+ .with.property('message').that.includes('$eventSourceId requires a string or GUID target');
183
+ });
184
+ }
185
+
186
+ it('should reject raw Occurred until the kernel conversion is consistent', () => {
187
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from =>
188
+ from.set(model => model.state).toEventContextProperty('occurred')));
189
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
190
+ .with.property('message').that.includes('(.from().setFromContext)')
191
+ .and.includes('raw Occurred is converted inconsistently by the kernel');
192
+ });
193
+
114
194
  it('should reject a client-emitted derived context function at the phase-one boundary', () => {
115
195
  const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from =>
116
196
  from.set(model => model.state).toEventContextProperty('occurred.week()')));