@cratis/chronicle 6.34.0 → 6.36.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 (119) hide show
  1. package/ChronicleClient.ts +8 -2
  2. package/EventStore.ts +8 -0
  3. package/dist/ChronicleClient.d.ts.map +1 -1
  4. package/dist/ChronicleClient.js +10 -2
  5. package/dist/ChronicleClient.js.map +1 -1
  6. package/dist/EventStore.d.ts.map +1 -1
  7. package/dist/EventStore.js +8 -0
  8. package/dist/EventStore.js.map +1 -1
  9. package/dist/events/handles.d.ts +12 -0
  10. package/dist/events/handles.d.ts.map +1 -0
  11. package/dist/events/handles.js +33 -0
  12. package/dist/events/handles.js.map +1 -0
  13. package/dist/events/index.d.ts +1 -0
  14. package/dist/events/index.d.ts.map +1 -1
  15. package/dist/events/index.js +1 -0
  16. package/dist/events/index.js.map +1 -1
  17. package/dist/observation/EventHandler.d.ts +7 -0
  18. package/dist/observation/EventHandler.d.ts.map +1 -0
  19. package/dist/observation/EventHandler.js +4 -0
  20. package/dist/observation/EventHandler.js.map +1 -0
  21. package/dist/observation/createHandlerInstanceValidator.d.ts +4 -0
  22. package/dist/observation/createHandlerInstanceValidator.d.ts.map +1 -0
  23. package/dist/observation/createHandlerInstanceValidator.js +40 -0
  24. package/dist/observation/createHandlerInstanceValidator.js.map +1 -0
  25. package/dist/observation/getEventHandlers.d.ts +5 -0
  26. package/dist/observation/getEventHandlers.d.ts.map +1 -0
  27. package/dist/observation/getEventHandlers.js +40 -0
  28. package/dist/observation/getEventHandlers.js.map +1 -0
  29. package/dist/observation/getInstanceMethods.d.ts +4 -0
  30. package/dist/observation/getInstanceMethods.d.ts.map +1 -0
  31. package/dist/observation/getInstanceMethods.js +29 -0
  32. package/dist/observation/getInstanceMethods.js.map +1 -0
  33. package/dist/reactors/ReactorDispatcher.d.ts.map +1 -1
  34. package/dist/reactors/ReactorDispatcher.js +27 -32
  35. package/dist/reactors/ReactorDispatcher.js.map +1 -1
  36. package/dist/reactors/Reactors.d.ts +1 -0
  37. package/dist/reactors/Reactors.d.ts.map +1 -1
  38. package/dist/reactors/Reactors.js +27 -14
  39. package/dist/reactors/Reactors.js.map +1 -1
  40. package/dist/reactors/index.d.ts +1 -0
  41. package/dist/reactors/index.d.ts.map +1 -1
  42. package/dist/reactors/index.js +1 -0
  43. package/dist/reactors/index.js.map +1 -1
  44. package/dist/reducers/ReducerEventDispatcher.d.ts +1 -0
  45. package/dist/reducers/ReducerEventDispatcher.d.ts.map +1 -1
  46. package/dist/reducers/ReducerEventDispatcher.js +5 -17
  47. package/dist/reducers/ReducerEventDispatcher.js.map +1 -1
  48. package/dist/reducers/ReducerEventHandler.d.ts +3 -6
  49. package/dist/reducers/ReducerEventHandler.d.ts.map +1 -1
  50. package/dist/reducers/Reducers.d.ts +1 -0
  51. package/dist/reducers/Reducers.d.ts.map +1 -1
  52. package/dist/reducers/Reducers.js +22 -10
  53. package/dist/reducers/Reducers.js.map +1 -1
  54. package/dist/reducers/index.d.ts +1 -0
  55. package/dist/reducers/index.d.ts.map +1 -1
  56. package/dist/reducers/index.js +1 -0
  57. package/dist/reducers/index.js.map +1 -1
  58. package/dist/testing/ReactorScenario.d.ts +1 -0
  59. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  60. package/dist/testing/ReactorScenario.js +9 -1
  61. package/dist/testing/ReactorScenario.js.map +1 -1
  62. package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
  63. package/dist/testing/ReducerReadModelProcessor.js +1 -0
  64. package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
  65. package/dist/testing/projections/ProjectionArithmetic.d.ts +11 -0
  66. package/dist/testing/projections/ProjectionArithmetic.d.ts.map +1 -0
  67. package/dist/testing/projections/ProjectionArithmetic.js +70 -0
  68. package/dist/testing/projections/ProjectionArithmetic.js.map +1 -0
  69. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  70. package/dist/testing/projections/ProjectionCapabilities.js +26 -10
  71. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  72. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  73. package/dist/testing/projections/ProjectionReadModelProcessor.js +10 -3
  74. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  75. package/dist/tsconfig.tsbuildinfo +1 -1
  76. package/events/for_handles/when_decorating_a_method.legacy.spec.ts +91 -0
  77. package/events/for_handles/when_decorating_a_method.standard.spec.ts +102 -0
  78. package/events/handles.ts +37 -0
  79. package/events/index.ts +1 -0
  80. package/for_ChronicleClient/when_registering_artifacts/with_invalid_handler_mappings.spec.ts +142 -0
  81. package/observation/EventHandler.ts +9 -0
  82. package/observation/createHandlerInstanceValidator.ts +42 -0
  83. package/observation/for_getEventHandlers/when_discovering_handlers.spec.ts +137 -0
  84. package/observation/for_getEventHandlers/when_reobserving_explicit_handlers.spec.ts +73 -0
  85. package/observation/getEventHandlers.ts +43 -0
  86. package/observation/getInstanceMethods.ts +29 -0
  87. package/package.json +2 -2
  88. package/reactors/ReactorDispatcher.ts +26 -30
  89. package/reactors/Reactors.ts +32 -16
  90. package/reactors/decorators.standard.spec.ts +8 -0
  91. package/reactors/for_ReactorDispatcher/when_selecting_explicit_handlers.spec.ts +64 -0
  92. package/reactors/for_Reactors/when_activating_a_field_override.spec.ts +62 -0
  93. package/reactors/for_Reactors/when_delivering_to_instance_handlers.spec.ts +108 -0
  94. package/reactors/for_Reactors/when_observing_explicit_handlers.legacy.spec.ts +77 -0
  95. package/reactors/for_Reactors/when_registering_explicit_handlers.spec.ts +53 -0
  96. package/reactors/index.ts +1 -0
  97. package/reducers/ReducerEventDispatcher.ts +5 -16
  98. package/reducers/ReducerEventHandler.ts +4 -6
  99. package/reducers/Reducers.ts +24 -12
  100. package/reducers/for_ReducerEventDispatcher/when_invoking_an_explicit_handler.spec.ts +51 -0
  101. package/reducers/for_ReducerEventDispatcher/when_invoking_instance_handlers.spec.ts +69 -0
  102. package/reducers/for_Reducers/when_activating_a_field_override.spec.ts +63 -0
  103. package/reducers/for_Reducers/when_observing_explicit_handlers.legacy.spec.ts +73 -0
  104. package/reducers/for_Reducers/when_registering_explicit_handlers.spec.ts +57 -0
  105. package/reducers/index.ts +1 -0
  106. package/testing/ReactorScenario.ts +9 -1
  107. package/testing/ReducerReadModelProcessor.ts +1 -0
  108. package/testing/for_ReactorScenario/when_delivering_to_an_explicit_handler.spec.ts +100 -0
  109. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_a_fluent_projection.spec.ts +85 -0
  110. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_legacy_decorators.legacy.spec.ts +91 -0
  111. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts +92 -0
  112. package/testing/for_ReducerReadModelProcessor/when_an_instance_field_hides_a_handler.spec.ts +29 -0
  113. package/testing/projections/ProjectionArithmetic.ts +71 -0
  114. package/testing/projections/ProjectionCapabilities.ts +27 -9
  115. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +9 -6
  116. package/testing/projections/ProjectionReadModelProcessor.ts +9 -3
  117. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +3 -3
  118. package/testing/projections/for_ProjectionCapabilities/when_validating_arithmetic.spec.ts +127 -0
  119. package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +4 -5
@@ -0,0 +1,92 @@
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 { field } from '@cratis/fundamentals';
5
+ import { chai, describe, it, type Assertion } from 'vitest';
6
+ import { eventType } from '../../../events/eventTypeDecorator.js';
7
+ import { addFrom } from '../../../projections/modelBound/addFrom.js';
8
+ import { subtractFrom } from '../../../projections/modelBound/subtractFrom.js';
9
+ import { count } from '../../../projections/modelBound/count.js';
10
+ import { increment } from '../../../projections/modelBound/increment.js';
11
+ import { decrement } from '../../../projections/modelBound/decrement.js';
12
+ import { setFrom } from '../../../projections/modelBound/setFrom.js';
13
+ import { ReadModelScenario } from '../../ReadModelScenario.js';
14
+ import { UnsupportedProjectionOperation } from '../../projections/UnsupportedProjectionOperation.js';
15
+
16
+ chai.should();
17
+ function should(value: unknown): Assertion { return (value as { should: Assertion }).should; }
18
+
19
+ @eventType('standard-arithmetic-added')
20
+ class Added {
21
+ @field(Number) amount: number;
22
+ @field(String) title = 'not mapped';
23
+ constructor(amount: number) { this.amount = amount; }
24
+ }
25
+ @eventType('standard-arithmetic-subtracted')
26
+ class Subtracted {
27
+ @field(Number) amount: number;
28
+ constructor(amount: number) { this.amount = amount; }
29
+ }
30
+ @eventType('standard-arithmetic-renamed')
31
+ class Renamed {
32
+ @field(Number) amount = 1;
33
+ @field(String) caption = 'explicit';
34
+ @field(String) title = 'automapped';
35
+ }
36
+ class Totals {
37
+ @field(String) id = '';
38
+ @field(Number) @addFrom(Added, 'amount') @subtractFrom(Subtracted, 'amount') @addFrom(Renamed, 'amount') total = 0;
39
+ @field(Number) @addFrom(Added) @subtractFrom(Subtracted) amount = 0;
40
+ @field(Number) @count(Added) counted = 0;
41
+ @field(Number) @increment(Added) incremented = 0;
42
+ @field(Number) @decrement(Added) decremented = 0;
43
+ @field(String) @setFrom(Renamed, 'caption') label = '';
44
+ @field(String) title = '';
45
+ }
46
+ const artifacts = { eventTypes: [Added, Subtracted, Renamed], readModels: [Totals], reducers: [], projections: [] };
47
+
48
+ // The same operations are captured in arithmetic.json, arithmetic-numeric-matrix.json and arithmetic-automap.json.
49
+ describe('when evaluating arithmetic with standard model-bound decorators', () => {
50
+ for (const [operation, decorate] of [['add', addFrom], ['subtract', subtractFrom]] as const) {
51
+ it(`should reject @${operation}From with a snake_case operand before any events are seeded`, () => {
52
+ @eventType(`standard-arithmetic-snake-case-${operation}`)
53
+ class PriceChanged {
54
+ @field(Number) unit_price = 2;
55
+ }
56
+ class PriceTotals {
57
+ @field(String) id = '';
58
+ @field(Number) @decorate(PriceChanged, 'unit_price') total = 0;
59
+ }
60
+ should(() => new ReadModelScenario(PriceTotals, { eventTypes: [PriceChanged], readModels: [PriceTotals], reducers: [], projections: [] }))
61
+ .throw(UnsupportedProjectionOperation).with.property('message')
62
+ .that.includes('kernel only permits an underscore as the first character')
63
+ .and.includes('https://github.com/Cratis/Chronicle/issues/4491');
64
+ });
65
+ }
66
+
67
+ it('should accumulate all five operations and default event property names', async () => {
68
+ const scenario = new ReadModelScenario(Totals, artifacts);
69
+ scenario.given.forEventSource('A').events(new Added(2.5), new Added(3), new Subtracted(1));
70
+ const result = (await scenario.instance)!;
71
+ should([result.total, result.amount, result.counted, result.incremented, result.decremented]).deep.equal([4.5, 4.5, 2, 2, -2]);
72
+ });
73
+
74
+ it('should replay additional seeds without counting the previous replay twice and keep sources independent', async () => {
75
+ const scenario = new ReadModelScenario(Totals, artifacts);
76
+ scenario.given.forEventSource('A').events(new Added(2));
77
+ should((await scenario.instance)!.total).equal(2);
78
+ scenario.given.forEventSource('B').events(new Added(7));
79
+ scenario.given.forEventSource('A').events(new Added(3));
80
+ should((await scenario.instanceForEventSourceId('A'))!.total).equal(5);
81
+ should((await scenario.instanceForEventSourceId('B'))!.total).equal(7);
82
+ });
83
+
84
+ it('should suppress aggregate-only AutoMap and restore it for mixed mappings', async () => {
85
+ const scenario = new ReadModelScenario(Totals, artifacts);
86
+ scenario.given.forEventSource('A').events(new Added(2));
87
+ should((await scenario.instance)!.title === undefined).be.true;
88
+ scenario.given.forEventSource('A').events(new Renamed());
89
+ const result = (await scenario.instance)!;
90
+ should([result.total, result.title, result.label]).deep.equal([3, 'automapped', 'explicit']);
91
+ });
92
+ });
@@ -0,0 +1,29 @@
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, it } from 'vitest';
5
+ import { eventType } from '../../events/eventTypeDecorator.js';
6
+ import { handles } from '../../events/handles.js';
7
+ import { ReducerReadModelProcessor } from '../ReducerReadModelProcessor.js';
8
+
9
+ chai.should();
10
+
11
+ @eventType('scenario-field-reducer')
12
+ class AuthorRegistered {}
13
+ class Model {}
14
+ class Base {
15
+ @handles(AuthorRegistered) update() { return new Model(); }
16
+ }
17
+ class Derived extends Base {
18
+ override update = () => new Model();
19
+ }
20
+
21
+ describe('when a reducer scenario instance field hides an inherited handler', () => {
22
+ it('should reject the instance before processing events', async () => {
23
+ const processor = new ReducerReadModelProcessor(Model, Derived, [AuthorRegistered]);
24
+ const [result] = await Promise.allSettled([processor.process([])]);
25
+ result.status.should.equal('rejected');
26
+ if (result.status === 'rejected') (result.reason as Error).message.should.equal(
27
+ "Override 'update' on 'Derived' hides @handles(AuthorRegistered) declared on 'Base'; use a method with @handles instead of an instance field.");
28
+ });
29
+ });
@@ -0,0 +1,71 @@
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
+ import { ProjectionExpressionEvaluator } from './ProjectionExpressionEvaluator.js';
6
+ import { ProjectionValueConverter } from './ProjectionValueConverter.js';
7
+
8
+ /** The root arithmetic subset captured by the packaged-kernel arithmetic fixtures. */
9
+ export class ProjectionArithmetic {
10
+ static isArithmetic(expression: string): boolean {
11
+ return /^\$(?:add|subtract)\(/.test(expression) || ['$count', '$increment', '$decrement'].includes(expression);
12
+ }
13
+
14
+ /** The kernel suppresses AutoMap for a nonempty set of aggregate-only mappings. */
15
+ static suppressesAutoMap(properties: Record<string, string>): boolean {
16
+ const expressions = Object.values(properties);
17
+ return expressions.length > 0 && expressions.every(expression => this.isArithmetic(expression));
18
+ }
19
+
20
+ static validate(expression: string, target: JsonSchema, eventSchema: JsonSchema, fail: (reason: string) => never): void {
21
+ if (!((target.type === 'number' && (!target.format || target.format === 'double')) ||
22
+ (target.type === 'integer' && target.format === 'int32'))) {
23
+ fail('arithmetic targets must be number/double or integer/int32; other targets require a kernel-backed test');
24
+ }
25
+ if (['$count', '$increment', '$decrement'].includes(expression)) return;
26
+ const operand = /^\$(?:add|subtract)\(([^()]+)\)$/.exec(expression)?.[1];
27
+ if (operand?.slice(1).includes('_')) {
28
+ fail('the Chronicle 19.26.2 kernel only permits an underscore as the first character of an arithmetic operand; snake_case operands are unsupported (https://github.com/Cratis/Chronicle/issues/4491)');
29
+ }
30
+ // Direct-property subset of EventValueProviderRegularExpressions.Expression in Chronicle 19.26.2.
31
+ // AddExpressionResolver and SubtractExpressionResolver anchor that grammar; nested/$ operands remain unproven.
32
+ const source = /^\$(?:add|subtract)\(([A-Za-z_][A-Za-z0-9]*)\)$/.exec(expression)?.[1];
33
+ if (!source || ['true', 'True', 'false', 'False'].includes(source)) {
34
+ fail('arithmetic operands must be direct numeric event properties; nested paths, literals and context require a kernel-backed test');
35
+ }
36
+ const schema = eventSchema.properties?.[source!];
37
+ if (!schema) fail(`arithmetic event property '${source}' is absent from the participating event schema`);
38
+ if (schema!.compliance?.length || schema!.security?.length) fail('protected arithmetic operands require a kernel-backed test');
39
+ if (!((schema!.type === 'number' && (!schema!.format || schema!.format === 'double')) ||
40
+ (schema!.type === 'integer' && ['int32', 'uint32'].includes(schema!.format ?? '')))) {
41
+ fail('arithmetic operands must have number/double, integer/int32 or integer/uint32 schemas; non-numeric and other operands require a kernel-backed test');
42
+ }
43
+ }
44
+
45
+ static value(expression: string, content: unknown, target: JsonSchema, current: unknown): number {
46
+ const operation = /^\$(add|subtract)\(([^()]+)\)$/.exec(expression);
47
+ const operand = operation ? ProjectionExpressionEvaluator.pathValue(content, operation[2]) : 1;
48
+ // Absent accumulators start at zero, unlike explicitly cleared (null) accumulators.
49
+ if (current === null) throw new RangeError('Projection arithmetic cannot use a null accumulator.');
50
+ const previous = current === undefined ? 0 : this.number(current, target);
51
+ const amount = this.number(operand, target);
52
+ const result = operation?.[1] === 'subtract' || expression === '$decrement' ? previous - amount : previous + amount;
53
+ // Int32 arithmetic is unchecked in the kernel; operand conversion is checked and rounds ties to even.
54
+ if (target.format === 'int32') return result | 0;
55
+ ProjectionValueConverter.checkNumber(result, target);
56
+ return result;
57
+ }
58
+
59
+ private static number(value: unknown, target: JsonSchema): number {
60
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
61
+ throw new RangeError('Projection arithmetic requires finite numeric values.');
62
+ }
63
+ let number = value;
64
+ if (target.format === 'int32') {
65
+ const lower = Math.floor(value);
66
+ number = value - lower === 0.5 ? (lower % 2 === 0 ? lower : lower + 1) : Math.round(value);
67
+ }
68
+ ProjectionValueConverter.checkNumber(number, target);
69
+ return number;
70
+ }
71
+ }
@@ -8,6 +8,7 @@ import { eventContractPath } from '../../projections/eventContractPath.js';
8
8
  import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
9
9
  import { getEventTypeMapKey, type ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js';
10
10
  import type { JsonSchema } from '../../schemas/JsonSchema.js';
11
+ import { ProjectionArithmetic } from './ProjectionArithmetic.js';
11
12
  import { ProjectionChildrenCapabilities } from './ProjectionChildrenCapabilities.js';
12
13
  import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
13
14
 
@@ -95,6 +96,19 @@ export class ProjectionCapabilities {
95
96
  reject(eventContractPath('From', entry.Key), 'events subscribed through both From and RemovedWith require a kernel-backed test (ChronicleKernelScenario / live kernel)');
96
97
  }
97
98
  }
99
+ const accumulators = new Set(from.flatMap(entry => Object.entries(entry.Value.Properties ?? {})
100
+ .filter(([, expression]) => ProjectionArithmetic.isArithmetic(expression)).map(([property]) => property)));
101
+ if (accumulators.size && Object.keys(wire.Children as object ?? {}).length) {
102
+ reject('Children', 'root arithmetic combined with children requires a kernel-backed test');
103
+ }
104
+ for (const entry of from) {
105
+ for (const [property, expression] of Object.entries(entry.Value.Properties ?? {})) {
106
+ if (expression === '$null' && accumulators.has(property)) {
107
+ reject(`${eventContractPath('From', entry.Key)}.Properties.${property}`,
108
+ 'clearing an arithmetic accumulator requires a kernel-backed test; the kernel rejects arithmetic on a present null');
109
+ }
110
+ }
111
+ }
98
112
  for (const entry of [...from, ...removedWith]) {
99
113
  const section = from.includes(entry as FromRecord) ? 'From' : 'RemovedWith';
100
114
  const path = eventContractPath(section, entry.Key);
@@ -105,10 +119,10 @@ export class ProjectionCapabilities {
105
119
  const properties = (entry as FromRecord).Value.Properties ?? {};
106
120
  for (const [property, expression] of Object.entries(properties)) {
107
121
  const mappingPath = `${path}.Properties.${property}`;
108
- this.checkMapping(schema!, eventSchema, property, expression, mappingPath, reject);
122
+ this.checkMapping(schema!, eventSchema, property, expression, mappingPath, reject, accumulators);
109
123
  }
110
- if (wire.AutoMap !== AutoMap.Disabled) {
111
- this.checkAutoMap(schema!, eventSchema, properties, wire.NoAutoMapProperties as string[] ?? [], path, reject);
124
+ if (wire.AutoMap !== AutoMap.Disabled && !ProjectionArithmetic.suppressesAutoMap(properties)) {
125
+ this.checkAutoMap(schema!, eventSchema, properties, wire.NoAutoMapProperties as string[] ?? [], path, reject, accumulators);
112
126
  }
113
127
  }
114
128
  ProjectionChildrenCapabilities.validate(wire.Children as Record<string, ChildrenDefinitionLike> ?? {}, {
@@ -121,20 +135,20 @@ export class ProjectionCapabilities {
121
135
 
122
136
  private static checkAutoMap(
123
137
  modelSchema: JsonSchema, eventSchema: JsonSchema, explicit: Record<string, string>, exclusions: string[],
124
- path: string, reject: (path: string, reason: string) => never
138
+ path: string, reject: (path: string, reason: string) => never, accumulators: ReadonlySet<string>
125
139
  ): void {
126
140
  for (const [destination] of Object.entries(modelSchema.properties ?? {})) {
127
141
  if (Object.keys(explicit).some(name => name.toLowerCase() === destination.toLowerCase()) ||
128
142
  exclusions.some(name => name.toLowerCase() === destination.toLowerCase())) continue;
129
143
  const candidates = Object.keys(eventSchema.properties ?? {}).filter(source => source.toLowerCase() === destination.toLowerCase());
130
144
  if (candidates.length > 1) reject(`${path}.AutoMap.${destination}`, `inferred AutoMap source is ambiguous: ${candidates.join(', ')}`);
131
- if (candidates.length) this.checkMapping(modelSchema, eventSchema, destination, candidates[0], `${path}.AutoMap.${destination}`, reject);
145
+ if (candidates.length) this.checkMapping(modelSchema, eventSchema, destination, candidates[0], `${path}.AutoMap.${destination}`, reject, accumulators);
132
146
  }
133
147
  }
134
148
 
135
149
  private static checkMapping(
136
150
  modelSchema: JsonSchema, eventSchema: JsonSchema, destination: string, expression: string,
137
- path: string, reject: (path: string, reason: string) => never
151
+ path: string, reject: (path: string, reason: string) => never, accumulators?: ReadonlySet<string>
138
152
  ): void {
139
153
  const fail = (reason: string): never => reject(path, `expression '${expression}': ${reason}`);
140
154
  const target = modelSchema.properties?.[destination]
@@ -184,9 +198,10 @@ export class ProjectionCapabilities {
184
198
  }
185
199
  return;
186
200
  }
187
- const operation = /^(\$add|\$subtract)\(([^()]+)\)$/.exec(expression);
188
- const arithmetic = operation || ['$count', '$increment', '$decrement'].includes(expression);
189
- if (arithmetic) fail('arithmetic requires a kernel-backed test (ChronicleKernelScenario / live kernel)');
201
+ if (ProjectionArithmetic.isArithmetic(expression)) {
202
+ ProjectionArithmetic.validate(expression, target, eventSchema, fail);
203
+ return;
204
+ }
190
205
  if (/^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*$/.test(expression)) {
191
206
  if (['true', 'True', 'false', 'False'].includes(expression)) {
192
207
  fail('kernel resolves this expression as a boolean literal before event content');
@@ -195,6 +210,9 @@ export class ProjectionCapabilities {
195
210
  const source = this.propertyAt(eventSchema, expression);
196
211
  if (!source) return fail(`event property '${expression}' is absent from the participating event schema`);
197
212
  this.checkSchema(source, path, (_path, reason) => fail(reason));
213
+ if (accumulators?.has(destination) && !this.numeric(source)) {
214
+ fail('assigning non-numeric event properties to an arithmetic accumulator requires a kernel-backed test; missing values can clear it');
215
+ }
198
216
  if (!this.compatible(source, target)) {
199
217
  fail(`source '${expression}' (${source.type}${source.format ? `/${source.format}` : ''}) to target '${destination}' (${target.type}${target.format ? `/${target.format}` : ''}) requires a kernel-backed test`);
200
218
  }
@@ -38,11 +38,11 @@ const allFixtures = readdirSync(directory).filter(name => name.endsWith('.json')
38
38
  .map(name => ({ name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture }));
39
39
  const fixtures = allFixtures.filter(({ fixture }) => fixture.kind === 'kernelSemantics');
40
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' },
41
+ 'arithmetic-string-operand.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic operands must have number/double' },
42
+ 'arithmetic-nonnumeric-operand.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic operands must have number/double' },
43
+ 'arithmetic-uint32-overflow.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic targets must be number/double or integer/int32' },
44
+ 'arithmetic-null-initial.json': { type: 'UnsupportedProjectionOperation', message: 'initial numeric value must be a JSON number' },
45
+ 'null-accumulator.json': { type: 'UnsupportedProjectionOperation', message: 'clearing an arithmetic accumulator requires a kernel-backed test' },
46
46
  'concept-shaped-objects.json': { type: 'UnsupportedProjectionOperation', message: 'nested event property paths require a kernel-backed test' },
47
47
  'mapped-and-removed.json': { type: 'UnsupportedProjectionOperation', message: 'both From and RemovedWith require a kernel-backed test' },
48
48
  'mapped-id-key-wins.json': { type: 'UnsupportedProjectionOperation', message: 'identifier or case-insensitively colliding target mappings require a kernel-backed test' },
@@ -97,9 +97,12 @@ describe('when replaying committed kernel semantics fixtures', () => {
97
97
  () => { throw new Error('Evaluator accepted a kernel-rejected mapping'); },
98
98
  error => {
99
99
  (error instanceof RangeError).should.be.true;
100
- (error as Error).message.should.include('not supported by integer/int32');
100
+ (error as Error).message.should.include(input.expectedError!.type === 'System.OverflowException'
101
+ ? 'outside the supported integer/int32 range' : 'not supported by integer/int32');
101
102
  }
102
103
  );
104
+ processor.engineState.should.deep.equal(expected.engineState);
105
+ processor.publicRead.should.deep.equal(expected.publicRead);
103
106
  } else {
104
107
  await processor.process(events.slice(0, step + 1));
105
108
  processor.engineState.should.deep.equal(expected.engineState);
@@ -12,6 +12,7 @@ import type { JsonSchema } from '../../schemas/JsonSchema.js';
12
12
  import type { IReadModelProcessor } from '../IReadModelProcessor.js';
13
13
  import type { ReadModelState } from '../ReadModelState.js';
14
14
  import type { ScenarioEvent } from '../ScenarioEvent.js';
15
+ import { ProjectionArithmetic } from './ProjectionArithmetic.js';
15
16
  import { ProjectionCapabilities } from './ProjectionCapabilities.js';
16
17
  import { ProjectionExpressionEvaluator } from './ProjectionExpressionEvaluator.js';
17
18
  import { ProjectionValueConverter } from './ProjectionValueConverter.js';
@@ -107,7 +108,8 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
107
108
  if (from) {
108
109
  const explicit = { ...from.Value.Properties };
109
110
  const excluded = (this._definition.NoAutoMapProperties ?? []);
110
- const properties = this._definition.AutoMap !== AutoMap.Disabled ? this.withAutoMap(explicit, schema, this._schema, excluded) : explicit;
111
+ const properties = this._definition.AutoMap !== AutoMap.Disabled && !ProjectionArithmetic.suppressesAutoMap(explicit)
112
+ ? this.withAutoMap(explicit, schema, this._schema, excluded) : explicit;
111
113
  this.apply(properties, content, event, this._schema, state);
112
114
  }
113
115
  if (child) this.applyChild(child, content, event, schema, state, binding);
@@ -168,8 +170,12 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
168
170
 
169
171
  private apply(properties: Record<string, string>, content: unknown, event: ScenarioEvent, target: JsonSchema, state: Record<string, unknown>): void {
170
172
  for (const [destination, expression] of Object.entries(properties)) {
171
- const value = ProjectionExpressionEvaluator.value(expression, content, event.context, target.properties![destination]);
172
- // The kernel compares old and new null values; null on an absent member is no change.
173
+ const arithmetic = ProjectionArithmetic.isArithmetic(expression);
174
+ const value = arithmetic
175
+ ? ProjectionArithmetic.value(expression, content, target.properties![destination], state[destination])
176
+ : ProjectionExpressionEvaluator.value(expression, content, event.context, target.properties![destination]);
177
+ // Zero arithmetic on an absent accumulator is no change, just like null on an absent scalar.
178
+ if (arithmetic && value === 0 && state[destination] === undefined) continue;
173
179
  if (value !== null || state[destination] !== undefined) state[destination] = value;
174
180
  }
175
181
  }
@@ -148,11 +148,11 @@ describe('when rejecting unsupported operations before any event is seeded', ()
148
148
  });
149
149
 
150
150
  for (const expression of ['$add(quantity)', '$subtract(quantity)', '$count', '$increment', '$decrement']) {
151
- it(`should reject all arithmetic ${expression} before replay`, () => {
151
+ it(`should reject arithmetic ${expression} on non-numeric targets before replay`, () => {
152
152
  const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.total).toValue(0)));
153
- definition.From[0].Value.Properties.total = expression;
153
+ definition.From[0].Value.Properties = { name: expression };
154
154
  (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
155
- .with.property('message').that.includes('arithmetic requires a kernel-backed test (ChronicleKernelScenario / live kernel)');
155
+ .with.property('message').that.includes('arithmetic targets must be number/double or integer/int32');
156
156
  });
157
157
  }
158
158
 
@@ -0,0 +1,127 @@
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, it } from 'vitest';
5
+ import { count } from '../../../projections/modelBound/count.js';
6
+ import { increment } from '../../../projections/modelBound/increment.js';
7
+ import { decrement } from '../../../projections/modelBound/decrement.js';
8
+ import type { JsonSchema } from '../../../schemas/JsonSchema.js';
9
+ import { ProjectionCapabilities } from '../ProjectionCapabilities.js';
10
+ import { UnsupportedProjectionOperation } from '../UnsupportedProjectionOperation.js';
11
+ import { Changed } from './given/Changed.js';
12
+ import { Removed } from './given/Removed.js';
13
+ import { compileDeclarative, compileModelBound } from './given/compile.js';
14
+
15
+ chai.should();
16
+
17
+ describe('when validating arithmetic before replay', () => {
18
+ for (const expression of ['$add(quantity)', '$subtract(quantity)', '$count', '$increment', '$decrement']) {
19
+ it(`should admit ${expression} on number fields without seeding events`, () => {
20
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed));
21
+ definition.From[0].Value.Properties.total = expression;
22
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.not.throw();
23
+ });
24
+ }
25
+
26
+ for (const expression of ['$add(name)', '$subtract(name)', '$add(missing)', '$add(inner.quantity)', '$subtract($eventSourceId)', '$add($value(1))', '$add(true)', '$add()']) {
27
+ it(`should reject unproven arithmetic operands in ${expression} with the contract path`, () => {
28
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.add(model => model.total).with(event => event.quantity)));
29
+ definition.From[0].Value.Properties.total = expression;
30
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
31
+ .with.property('message').that.includes('From[capability-changed:1].Properties.total (.from().addFrom)').and.includes('arithmetic');
32
+ });
33
+ }
34
+
35
+ for (const operation of ['add', 'subtract']) {
36
+ for (const property of ['unit_price', '_unit_price']) {
37
+ it(`should reject $${operation}(${property}) with the kernel operand limitation before replay`, () => {
38
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed));
39
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties![property] = { type: 'number' };
40
+ definition.From[0].Value.Properties.total = `$${operation}(${property})`;
41
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
42
+ .with.property('message').that.includes('From[capability-changed:1].Properties.total')
43
+ .and.includes('kernel only permits an underscore as the first character')
44
+ .and.includes('https://github.com/Cratis/Chronicle/issues/4491');
45
+ });
46
+ }
47
+ it(`should admit $${operation}(_quantity) with a leading underscore`, () => {
48
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed));
49
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties!._quantity = { type: 'number' };
50
+ definition.From[0].Value.Properties.total = `$${operation}(_quantity)`;
51
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.not.throw();
52
+ });
53
+ }
54
+
55
+ for (const target of [{ type: 'integer', format: 'uint32' }, { type: ['number', 'null'] }, { type: 'string' }, { type: 'boolean' }]) {
56
+ it(`should reject an unproven ${JSON.stringify(target)} accumulator schema`, () => {
57
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.increment(model => model.total)));
58
+ const schema = JSON.parse(compiled.readModels[0].Schema) as JsonSchema;
59
+ schema.properties!.total = target as JsonSchema;
60
+ compiled.readModels[0].Schema = JSON.stringify(schema);
61
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
62
+ .with.property('message').that.includes('From[capability-changed:1].Properties.total');
63
+ });
64
+ }
65
+
66
+ for (const source of [{ type: 'boolean' }, { type: 'object' }, { type: ['number', 'null'] }, { type: 'number', format: 'decimal' }, { type: 'integer', format: 'int64' }]) {
67
+ it(`should reject an unproven ${JSON.stringify(source)} operand schema`, () => {
68
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.add(model => model.total).with(event => event.quantity)));
69
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties!.quantity = source as JsonSchema;
70
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
71
+ .with.property('message').that.includes('arithmetic operands must have number/double');
72
+ });
73
+ }
74
+
75
+ it('should reject arithmetic on a protected operand', () => {
76
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.add(model => model.total).with(event => event.quantity)));
77
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties!.quantity.security = [{ metadataType: 'EncryptedSubject', details: '' }];
78
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
79
+ .with.property('message').that.includes('protected arithmetic operands');
80
+ });
81
+
82
+ it('should reject any declaration that can clear an accumulator before arithmetic', () => {
83
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.increment(model => model.total))
84
+ .from(Removed, from => from.set(model => model.total).toValue(0)));
85
+ definition.From[1].Value.Properties.total = '$null';
86
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
87
+ .with.property('message').that.includes('From[capability-removed:1].Properties.total').and.includes('clearing an arithmetic accumulator');
88
+ });
89
+
90
+ it('should reject a text assignment that can clear an arithmetic accumulator', () => {
91
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.set(model => model.total).to(event => event.name))
92
+ .from(Removed, from => from.increment(model => model.total)));
93
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
94
+ .with.property('message').that.includes('assigning non-numeric event properties to an arithmetic accumulator');
95
+ });
96
+
97
+ it('should reject inferred text AutoMap that can clear an arithmetic accumulator', () => {
98
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed)
99
+ .from(Removed, from => from.increment(model => model.total)));
100
+ compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties!.total = { type: 'string' };
101
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
102
+ .with.property('message').that.includes('AutoMap.total').and.includes('assigning non-numeric event properties');
103
+ });
104
+
105
+ it('should reject root arithmetic combined with children without widening the child boundary', () => {
106
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.increment(model => model.total))
107
+ .children(model => model.labels, child => child.from(Removed)));
108
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
109
+ .with.property('message').that.includes('root arithmetic combined with children');
110
+ });
111
+
112
+ for (const [name, decorate] of [['count', count], ['increment', increment], ['decrement', decrement]] as const) {
113
+ it(`should keep @${name} constant keys outside the supported source-id shape`, () => {
114
+ const { compiled, definition } = compileModelBound(model => decorate(Removed, 'all')(model.prototype, 'total'));
115
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
116
+ .with.property('message').that.includes(`(@${name})`).and.includes('only $eventSourceId keys');
117
+ });
118
+ }
119
+
120
+ it('should not validate suppressed AutoMap as an executed mapping', () => {
121
+ const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.increment(model => model.total)));
122
+ const schema = JSON.parse(compiled.readModels[0].Schema) as JsonSchema;
123
+ schema.properties!.quantity = { type: 'number', format: 'decimal' };
124
+ compiled.readModels[0].Schema = JSON.stringify(schema);
125
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.not.throw();
126
+ });
127
+ });
@@ -12,7 +12,7 @@ chai.should();
12
12
 
13
13
  describe('when validating the numeric capability matrix', () => {
14
14
  for (const format of ['int32', 'uint32']) {
15
- it(`should accept a finite ${format} literal without admitting arithmetic`, () => {
15
+ it(`should accept a finite ${format} literal`, () => {
16
16
  const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from
17
17
  .set(model => model.quantity).toValue(12)));
18
18
  const modelSchema = JSON.parse(compiled.readModels[0].Schema) as { properties: { quantity: { type: string; format?: string } } };
@@ -63,13 +63,12 @@ describe('when validating the numeric capability matrix', () => {
63
63
  });
64
64
  }
65
65
 
66
- it('should reject a double event operand into an integer accumulator before replay', () => {
66
+ it('should admit fixture-backed rounding of a double event operand into an int32 accumulator', () => {
67
67
  const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.add(model => model.total).with(event => event.quantity)));
68
68
  const schema = JSON.parse(compiled.readModels[0].Schema) as { properties: { total: { type: string; format?: string } } };
69
69
  schema.properties.total = { type: 'integer', format: 'int32' };
70
70
  compiled.readModels[0].Schema = JSON.stringify(schema);
71
- (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
72
- .with.property('message').that.includes('arithmetic requires a kernel-backed test');
71
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.not.throw();
73
72
  });
74
73
 
75
74
  it('should reject an int32 $value literal with a fractional spelling even if its value is integral', () => {
@@ -96,6 +95,6 @@ describe('when validating the numeric capability matrix', () => {
96
95
  const { compiled, definition } = compileDeclarative(builder => builder.from(Changed, from => from.add(model => model.total).with(event => event.quantity)));
97
96
  compiled.eventSchemas.get(definition)!.get('capability-changed:1:0')!.schema.properties!.quantity.format = 'int64';
98
97
  (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
99
- .with.property('message').that.includes('arithmetic requires a kernel-backed test');
98
+ .with.property('message').that.includes('arithmetic operands must have number/double');
100
99
  });
101
100
  });