@cratis/chronicle 6.35.0 → 6.37.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 (135) hide show
  1. package/EventStore.ts +3 -0
  2. package/IEventStore.ts +4 -0
  3. package/connection/ChronicleConnection.ts +6 -0
  4. package/connection/ChronicleServices.ts +2 -0
  5. package/dist/EventStore.d.ts +2 -0
  6. package/dist/EventStore.d.ts.map +1 -1
  7. package/dist/EventStore.js +3 -0
  8. package/dist/EventStore.js.map +1 -1
  9. package/dist/IEventStore.d.ts +3 -0
  10. package/dist/IEventStore.d.ts.map +1 -1
  11. package/dist/connection/ChronicleConnection.d.ts +1 -0
  12. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  13. package/dist/connection/ChronicleConnection.js +5 -1
  14. package/dist/connection/ChronicleConnection.js.map +1 -1
  15. package/dist/connection/ChronicleServices.d.ts +2 -1
  16. package/dist/connection/ChronicleServices.d.ts.map +1 -1
  17. package/dist/index.d.ts +3 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/patterns/BehaviorPattern.d.ts +25 -0
  22. package/dist/patterns/BehaviorPattern.d.ts.map +1 -0
  23. package/dist/patterns/BehaviorPattern.js +4 -0
  24. package/dist/patterns/BehaviorPattern.js.map +1 -0
  25. package/dist/patterns/DayOfWeek.d.ts +11 -0
  26. package/dist/patterns/DayOfWeek.d.ts.map +1 -0
  27. package/dist/patterns/DayOfWeek.js +14 -0
  28. package/dist/patterns/DayOfWeek.js.map +1 -0
  29. package/dist/patterns/FacetName.d.ts +15 -0
  30. package/dist/patterns/FacetName.d.ts.map +1 -0
  31. package/dist/patterns/FacetName.js +18 -0
  32. package/dist/patterns/FacetName.js.map +1 -0
  33. package/dist/patterns/FacetSet.d.ts +7 -0
  34. package/dist/patterns/FacetSet.d.ts.map +1 -0
  35. package/dist/patterns/FacetSet.js +4 -0
  36. package/dist/patterns/FacetSet.js.map +1 -0
  37. package/dist/patterns/IPatterns.d.ts +45 -0
  38. package/dist/patterns/IPatterns.d.ts.map +1 -0
  39. package/dist/patterns/IPatterns.js +4 -0
  40. package/dist/patterns/IPatterns.js.map +1 -0
  41. package/dist/patterns/PatternMoment.d.ts +11 -0
  42. package/dist/patterns/PatternMoment.d.ts.map +1 -0
  43. package/dist/patterns/PatternMoment.js +4 -0
  44. package/dist/patterns/PatternMoment.js.map +1 -0
  45. package/dist/patterns/PatternQueryOptions.d.ts +8 -0
  46. package/dist/patterns/PatternQueryOptions.d.ts.map +1 -0
  47. package/dist/patterns/PatternQueryOptions.js +4 -0
  48. package/dist/patterns/PatternQueryOptions.js.map +1 -0
  49. package/dist/patterns/Patterns.d.ts +32 -0
  50. package/dist/patterns/Patterns.d.ts.map +1 -0
  51. package/dist/patterns/Patterns.js +76 -0
  52. package/dist/patterns/Patterns.js.map +1 -0
  53. package/dist/patterns/PatternsAtOptions.d.ts +8 -0
  54. package/dist/patterns/PatternsAtOptions.d.ts.map +1 -0
  55. package/dist/patterns/PatternsAtOptions.js +4 -0
  56. package/dist/patterns/PatternsAtOptions.js.map +1 -0
  57. package/dist/patterns/TimeBucket.d.ts +16 -0
  58. package/dist/patterns/TimeBucket.d.ts.map +1 -0
  59. package/dist/patterns/TimeBucket.js +19 -0
  60. package/dist/patterns/TimeBucket.js.map +1 -0
  61. package/dist/patterns/index.d.ts +13 -0
  62. package/dist/patterns/index.d.ts.map +1 -0
  63. package/dist/patterns/index.js +9 -0
  64. package/dist/patterns/index.js.map +1 -0
  65. package/dist/patterns/toBehaviorPattern.d.ts +9 -0
  66. package/dist/patterns/toBehaviorPattern.d.ts.map +1 -0
  67. package/dist/patterns/toBehaviorPattern.js +22 -0
  68. package/dist/patterns/toBehaviorPattern.js.map +1 -0
  69. package/dist/patterns/toDayOfWeek.d.ts +10 -0
  70. package/dist/patterns/toDayOfWeek.d.ts.map +1 -0
  71. package/dist/patterns/toDayOfWeek.js +16 -0
  72. package/dist/patterns/toDayOfWeek.js.map +1 -0
  73. package/dist/patterns/toOffsetDate.d.ts +9 -0
  74. package/dist/patterns/toOffsetDate.d.ts.map +1 -0
  75. package/dist/patterns/toOffsetDate.js +20 -0
  76. package/dist/patterns/toOffsetDate.js.map +1 -0
  77. package/dist/patterns/toTimeBucket.d.ts +10 -0
  78. package/dist/patterns/toTimeBucket.d.ts.map +1 -0
  79. package/dist/patterns/toTimeBucket.js +25 -0
  80. package/dist/patterns/toTimeBucket.js.map +1 -0
  81. package/dist/testing/ReactorScenario.js +1 -1
  82. package/dist/testing/ReactorScenario.js.map +1 -1
  83. package/dist/testing/projections/ProjectionArithmetic.d.ts +11 -0
  84. package/dist/testing/projections/ProjectionArithmetic.d.ts.map +1 -0
  85. package/dist/testing/projections/ProjectionArithmetic.js +70 -0
  86. package/dist/testing/projections/ProjectionArithmetic.js.map +1 -0
  87. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  88. package/dist/testing/projections/ProjectionCapabilities.js +26 -10
  89. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  90. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  91. package/dist/testing/projections/ProjectionReadModelProcessor.js +10 -3
  92. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  93. package/dist/tsconfig.tsbuildinfo +1 -1
  94. package/index.ts +3 -0
  95. package/package.json +7 -2
  96. package/patterns/BehaviorPattern.ts +28 -0
  97. package/patterns/DayOfWeek.ts +13 -0
  98. package/patterns/FacetName.ts +17 -0
  99. package/patterns/FacetSet.ts +9 -0
  100. package/patterns/IPatterns.ts +52 -0
  101. package/patterns/PatternMoment.ts +13 -0
  102. package/patterns/PatternQueryOptions.ts +10 -0
  103. package/patterns/Patterns.ts +88 -0
  104. package/patterns/PatternsAtOptions.ts +11 -0
  105. package/patterns/TimeBucket.ts +18 -0
  106. package/patterns/for_PatternMoment/when_deriving_facets/with_invalid_moments.spec.ts +31 -0
  107. package/patterns/for_Patterns/given/a_patterns_service.fixture.ts +21 -0
  108. package/patterns/for_Patterns/when_browsing/with_scopes.spec.ts +21 -0
  109. package/patterns/for_Patterns/when_browsing/within_a_scope.spec.ts +25 -0
  110. package/patterns/for_Patterns/when_querying/with_query_outcomes.spec.ts +47 -0
  111. package/patterns/for_Patterns/when_querying_a_context/with_established_patterns.spec.ts +34 -0
  112. package/patterns/for_Patterns/when_querying_a_context/with_facets.spec.ts +32 -0
  113. package/patterns/for_Patterns/when_querying_at_a_moment/with_additional_facets.spec.ts +34 -0
  114. package/patterns/for_Patterns/when_querying_at_a_moment/with_an_invalid_moment.spec.ts +20 -0
  115. package/patterns/for_Patterns/when_querying_at_a_moment/without_a_moment.spec.ts +37 -0
  116. package/patterns/for_Patterns/when_querying_through_the_connection/after_resetting_the_channel.spec.ts +58 -0
  117. package/patterns/for_toDayOfWeek/when_reading_the_day/at_an_offset.spec.ts +31 -0
  118. package/patterns/for_toTimeBucket/when_bucketing/at_boundaries.spec.ts +39 -0
  119. package/patterns/index.ts +15 -0
  120. package/patterns/toBehaviorPattern.ts +25 -0
  121. package/patterns/toDayOfWeek.ts +19 -0
  122. package/patterns/toOffsetDate.ts +22 -0
  123. package/patterns/toTimeBucket.ts +22 -0
  124. package/testing/ReactorScenario.spec.ts +13 -0
  125. package/testing/ReactorScenario.ts +1 -1
  126. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_a_fluent_projection.spec.ts +85 -0
  127. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_legacy_decorators.legacy.spec.ts +91 -0
  128. package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts +92 -0
  129. package/testing/projections/ProjectionArithmetic.ts +71 -0
  130. package/testing/projections/ProjectionCapabilities.ts +27 -9
  131. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +9 -6
  132. package/testing/projections/ProjectionReadModelProcessor.ts +9 -3
  133. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +3 -3
  134. package/testing/projections/for_ProjectionCapabilities/when_validating_arithmetic.spec.ts +127 -0
  135. package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +4 -5
@@ -0,0 +1,58 @@
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 { afterEach, beforeEach, chai, describe, it } from 'vitest';
5
+ import { createServer, type ServiceImplementation } from 'nice-grpc';
6
+ import { ConnectionServiceDefinition, PatternsDefinition, type UsualActionsRequest } from '@cratis/chronicle.contracts';
7
+ import { EventStore } from '../../../EventStore.js';
8
+ import { EventStoreName } from '../../../EventStoreName.js';
9
+ import { EventStoreNamespaceName } from '../../../EventStoreNamespaceName.js';
10
+ import { ChronicleConnection } from '../../../connection/index.js';
11
+ import { ConnectionLifecycle } from '../../../connection/ConnectionLifecycle.js';
12
+ import { DayOfWeek, TimeBucket, type BehaviorPattern } from '../../index.js';
13
+
14
+ chai.should();
15
+
16
+ describe('when querying patterns through an event store after resetting its connection', () => {
17
+ let server: ReturnType<typeof createServer>;
18
+ let connection: ChronicleConnection;
19
+ let eventStore: EventStore;
20
+ let request: UsualActionsRequest;
21
+ let apiKey: string | undefined;
22
+ let result: BehaviorPattern[];
23
+ beforeEach(async () => {
24
+ server = createServer();
25
+ const unused = async () => { throw new Error('Unexpected RPC'); };
26
+ server.add(ConnectionServiceDefinition, {
27
+ ...Object.fromEntries(Object.keys(ConnectionServiceDefinition.methods).map(name => [name, unused])),
28
+ checkCompatibility: async () => ({ IsCompatible: true, Incompatibilities: [], ServerVersion: 'test' })
29
+ } as ServiceImplementation<typeof ConnectionServiceDefinition>);
30
+ server.add(PatternsDefinition, {
31
+ allPatterns: unused, allPatternScopes: unused, matchingPatterns: unused, patternsForScope: unused,
32
+ usualActions: async (received, context) => {
33
+ request = received;
34
+ apiKey = context.metadata.get('api-key');
35
+ return { IsAuthorized: true, Data: [{ Id: 'pattern-1', Occurrences: 9_007_199_254_740_993n }] };
36
+ }
37
+ });
38
+ const port = await server.listen('127.0.0.1:0');
39
+ connection = new ChronicleConnection({ connectionString: `chronicle://127.0.0.1:${port}?disableTls=true&apiKey=patterns-test` });
40
+ await connection.connect();
41
+ eventStore = new EventStore(new EventStoreName('accounts'), new EventStoreNamespaceName('tenant-a'), connection, new ConnectionLifecycle(), 'sink');
42
+ await connection.resetChannel();
43
+ result = await eventStore.patterns.getPatternsAt('user-42', { instant: new Date('2026-01-05T00:30:00Z'), offsetMinutes: -240 });
44
+ });
45
+ afterEach(async () => {
46
+ eventStore?.disposeObservations();
47
+ connection?.dispose();
48
+ await server?.shutdown();
49
+ });
50
+ it('should send the offset-derived context to the generated usual actions service', () => {
51
+ request.should.deep.equal({
52
+ EventStore: 'accounts', Namespace: 'tenant-a', GroupingKey: 'user-42',
53
+ Context: { Day: DayOfWeek.Sunday, TimeBucket: TimeBucket.Evening }, MinimumConfidence: 0, MaximumResults: 0
54
+ });
55
+ });
56
+ it('should use the connection authentication middleware', () => apiKey!.should.equal('patterns-test'));
57
+ it('should preserve occurrence precision across the actual wire codec', () => result[0].occurrences.should.equal(9_007_199_254_740_993n));
58
+ });
@@ -0,0 +1,31 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { DayOfWeek, toDayOfWeek } from '../../index.js';
6
+
7
+ chai.should();
8
+
9
+ const cases: [string, number, DayOfWeek][] = [
10
+ ['2026-01-04T23:59:59.999Z', 0, DayOfWeek.Sunday],
11
+ ['2026-01-05T00:00:00.000Z', 0, DayOfWeek.Monday],
12
+ ['2026-01-06T12:00:00Z', 0, DayOfWeek.Tuesday],
13
+ ['2026-01-07T12:00:00Z', 0, DayOfWeek.Wednesday],
14
+ ['2026-01-08T12:00:00Z', 0, DayOfWeek.Thursday],
15
+ ['2026-01-09T12:00:00Z', 0, DayOfWeek.Friday],
16
+ ['2026-01-10T12:00:00Z', 0, DayOfWeek.Saturday],
17
+ ['2026-01-04T23:30:00Z', 120, DayOfWeek.Monday],
18
+ ['2026-01-05T00:30:00Z', -120, DayOfWeek.Sunday],
19
+ ['2025-12-31T23:30:00Z', 345, DayOfWeek.Thursday],
20
+ ['2026-01-01T00:30:00Z', -210, DayOfWeek.Wednesday],
21
+ ['2026-01-04T12:00:00Z', 840, DayOfWeek.Monday],
22
+ ['2026-01-05T12:00:00Z', -840, DayOfWeek.Sunday]
23
+ ];
24
+
25
+ for (const [instant, offsetMinutes, expected] of cases) {
26
+ describe(`when reading the day of ${instant} at offset ${offsetMinutes}`, () => {
27
+ let result: DayOfWeek;
28
+ beforeEach(() => result = toDayOfWeek({ instant: new Date(instant), offsetMinutes }));
29
+ it('should name the day at the moment offset rather than UTC', () => result.should.equal(expected));
30
+ });
31
+ }
@@ -0,0 +1,39 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { TimeBucket, toTimeBucket } from '../../index.js';
6
+
7
+ chai.should();
8
+
9
+ // Chronicle v19.26.2: TimeBucketExtensions.ToTimeBucket uses >= start and < end on the offset's hour.
10
+ const boundaries: [string, TimeBucket][] = [
11
+ ['00:00:00.000', TimeBucket.Night],
12
+ ['04:59:59.999', TimeBucket.Night],
13
+ ['05:00:00.000', TimeBucket.EarlyMorning],
14
+ ['07:59:59.999', TimeBucket.EarlyMorning],
15
+ ['08:00:00.000', TimeBucket.Morning],
16
+ ['10:59:59.999', TimeBucket.Morning],
17
+ ['11:00:00.000', TimeBucket.Midday],
18
+ ['13:59:59.999', TimeBucket.Midday],
19
+ ['14:00:00.000', TimeBucket.Afternoon],
20
+ ['16:59:59.999', TimeBucket.Afternoon],
21
+ ['17:00:00.000', TimeBucket.Evening],
22
+ ['21:59:59.999', TimeBucket.Evening],
23
+ ['22:00:00.000', TimeBucket.Night],
24
+ ['23:59:59.999', TimeBucket.Night]
25
+ ];
26
+
27
+ for (const offsetMinutes of [0, 330, -210, 345, -840, 840]) {
28
+ for (const [time, expected] of boundaries) {
29
+ describe(`when bucketing ${time} at offset ${offsetMinutes}`, () => {
30
+ let result: TimeBucket;
31
+ beforeEach(() => {
32
+ const wallClock = new Date(`2026-01-05T${time}Z`);
33
+ const instant = new Date(wallClock.getTime() - offsetMinutes * 60_000);
34
+ result = toTimeBucket({ instant, offsetMinutes });
35
+ });
36
+ it('should use the same inclusive and exclusive boundaries as the miner', () => result.should.equal(expected));
37
+ });
38
+ }
39
+ }
@@ -0,0 +1,15 @@
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
+ export type { BehaviorPattern } from './BehaviorPattern.js';
5
+ export { DayOfWeek } from './DayOfWeek.js';
6
+ export { FacetName } from './FacetName.js';
7
+ export type { FacetSet } from './FacetSet.js';
8
+ export type { IPatterns } from './IPatterns.js';
9
+ export type { PatternMoment } from './PatternMoment.js';
10
+ export type { PatternQueryOptions } from './PatternQueryOptions.js';
11
+ export { Patterns } from './Patterns.js';
12
+ export type { PatternsAtOptions } from './PatternsAtOptions.js';
13
+ export { TimeBucket } from './TimeBucket.js';
14
+ export { toDayOfWeek } from './toDayOfWeek.js';
15
+ export { toTimeBucket } from './toTimeBucket.js';
@@ -0,0 +1,25 @@
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 { BehaviorPatternDetailsResponse } from '@cratis/chronicle.contracts';
5
+ import type { BehaviorPattern } from './BehaviorPattern.js';
6
+
7
+ /**
8
+ * Converts the wire representation without losing 64-bit occurrence counts or inventing missing timestamps.
9
+ * @param pattern - The pattern returned by the kernel.
10
+ * @returns The client representation.
11
+ */
12
+ export function toBehaviorPattern(pattern: BehaviorPatternDetailsResponse): BehaviorPattern {
13
+ return {
14
+ id: pattern.Id,
15
+ groupingKey: pattern.GroupingKey,
16
+ facets: { ...pattern.Facets },
17
+ confidence: pattern.Confidence,
18
+ support: pattern.Support,
19
+ occurrences: pattern.Occurrences,
20
+ weight: pattern.Weight,
21
+ specificity: pattern.Specificity,
22
+ firstSeen: pattern.FirstSeen ? new Date(pattern.FirstSeen.Value) : undefined,
23
+ lastSeen: pattern.LastSeen ? new Date(pattern.LastSeen.Value) : undefined
24
+ };
25
+ }
@@ -0,0 +1,19 @@
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 { DayOfWeek } from './DayOfWeek.js';
5
+ import type { PatternMoment } from './PatternMoment.js';
6
+ import { toOffsetDate } from './toOffsetDate.js';
7
+
8
+ const days = [DayOfWeek.Sunday, DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday,
9
+ DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday];
10
+
11
+ /**
12
+ * Reads the day at a moment's own offset, using Chronicle's English day names.
13
+ * @param moment - The instant and the offset it occurred at.
14
+ * @returns The day of week used by the Day facet.
15
+ * @throws {RangeError} If the date or offset is invalid.
16
+ */
17
+ export function toDayOfWeek(moment: PatternMoment): DayOfWeek {
18
+ return days[toOffsetDate(moment).getUTCDay()];
19
+ }
@@ -0,0 +1,22 @@
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 { PatternMoment } from './PatternMoment.js';
5
+
6
+ /**
7
+ * Produces a date whose UTC fields represent the moment's wall-clock fields.
8
+ * Internal only: the shifted value must never be used as an actual instant.
9
+ * @param moment - An instant and its explicit offset.
10
+ * @returns The shifted date for reading UTC calendar fields.
11
+ */
12
+ export function toOffsetDate(moment: PatternMoment): Date {
13
+ if (!Number.isInteger(moment.offsetMinutes) || Math.abs(moment.offsetMinutes) > 14 * 60) {
14
+ throw new RangeError('The moment offset must be a whole number of minutes between -840 and 840.');
15
+ }
16
+ const milliseconds = moment.instant.getTime();
17
+ const shifted = new Date(milliseconds + moment.offsetMinutes * 60_000);
18
+ if (!Number.isFinite(milliseconds) || !Number.isFinite(shifted.getTime())) {
19
+ throw new RangeError('The moment must represent a valid date at its offset.');
20
+ }
21
+ return shifted;
22
+ }
@@ -0,0 +1,22 @@
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 { PatternMoment } from './PatternMoment.js';
5
+ import { TimeBucket } from './TimeBucket.js';
6
+ import { toOffsetDate } from './toOffsetDate.js';
7
+
8
+ /**
9
+ * Buckets a moment using its own offset, matching DateTimeOffset.ToTimeBucket() in Chronicle.
10
+ * @param moment - The instant and the offset it occurred at, not the machine's offset.
11
+ * @returns The time bucket, with inclusive starts and exclusive ends.
12
+ * @throws {RangeError} If the date or offset is invalid.
13
+ */
14
+ export function toTimeBucket(moment: PatternMoment): TimeBucket {
15
+ const hour = toOffsetDate(moment).getUTCHours();
16
+ if (hour >= 5 && hour < 8) return TimeBucket.EarlyMorning;
17
+ if (hour >= 8 && hour < 11) return TimeBucket.Morning;
18
+ if (hour >= 11 && hour < 14) return TimeBucket.Midday;
19
+ if (hour >= 14 && hour < 17) return TimeBucket.Afternoon;
20
+ if (hour >= 17 && hour < 22) return TimeBucket.Evening;
21
+ return TimeBucket.Night;
22
+ }
@@ -187,6 +187,19 @@ describe('ReactorScenario live delivery', () => {
187
187
  scenario.results[0].completed.should.be.false;
188
188
  });
189
189
 
190
+ it('rejects behavior pattern queries through the default event store', async () => {
191
+ let error: unknown;
192
+ @reactor('pattern-rejection-reactor')
193
+ class PatternRejection {
194
+ registered(_event: Registered, _context: EventContext, services: ReactorServices) {
195
+ try { void services.eventStore.patterns; } catch (caught) { error = caught; }
196
+ }
197
+ }
198
+ const scenario = new ReactorScenario(PatternRejection, options);
199
+ await scenario.when.forEventSource('A').events(new Registered('first')).catch(() => undefined);
200
+ (error instanceof UnsupportedReactorOperation && String(error).includes('services.eventStore.patterns')).should.be.true;
201
+ });
202
+
190
203
  it('stops a multi-event delivery at the event whose handler swallowed an unsupported call', async () => {
191
204
  const invoked: string[] = [];
192
205
  @reactor('swallowed-in-batch-reactor')
@@ -131,7 +131,7 @@ export class ReactorScenario {
131
131
  namespace: new EventStoreNamespaceName(this._options.namespace ?? 'default'), eventLog: guardedLog, readModels };
132
132
  const unsupportedStoreProperties = new Set(['eventTypes', 'constraints', 'projections', 'reactors', 'reducers',
133
133
  'unitOfWorkManager', 'jobs', 'webhooks', 'subscriptions', 'seeding', 'externalServices', 'identities',
134
- 'pii', 'failedPartitions', 'observers']);
134
+ 'pii', 'failedPartitions', 'observers', 'patterns']);
135
135
  return new Proxy(store, { get: (target, key) => {
136
136
  if (typeof key !== 'string') return undefined;
137
137
  if (Object.hasOwn(target, key)) return target[key as keyof typeof target];
@@ -0,0 +1,85 @@
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 } from 'vitest';
6
+ import { eventType } from '../../../events/eventTypeDecorator.js';
7
+ import type { IProjectionBuilderFor } from '../../../projections/declarative/IProjectionBuilderFor.js';
8
+ import { projection } from '../../../projections/declarative/projection.js';
9
+ import { ReadModelScenario } from '../../ReadModelScenario.js';
10
+ import { UnsupportedProjectionOperation } from '../../projections/UnsupportedProjectionOperation.js';
11
+
12
+ chai.should();
13
+
14
+ class Changed {
15
+ constructor(readonly amount: number) {}
16
+ }
17
+ field(Number)(Changed.prototype, 'amount');
18
+ eventType('fluent-arithmetic-changed')(Changed);
19
+ class Removed {}
20
+ eventType('fluent-arithmetic-removed')(Removed);
21
+ class Totals {
22
+ id = '';
23
+ added = 0;
24
+ subtracted = 0;
25
+ counted = 0;
26
+ incremented = 0;
27
+ decremented = 0;
28
+ }
29
+ field(String)(Totals.prototype, 'id');
30
+ for (const property of ['added', 'subtracted', 'counted', 'incremented', 'decremented']) field(Number)(Totals.prototype, property);
31
+ class TotalsProjection {
32
+ define(builder: IProjectionBuilderFor<Totals>): void {
33
+ builder.noAutoMap().withInitialValues(() => ({ id: '', added: 10, subtracted: 10, counted: 0, incremented: 0, decremented: 0 }))
34
+ .from(Changed, from => from.add(model => model.added).with(event => event.amount)
35
+ .subtract(model => model.subtracted).with(event => event.amount)
36
+ .count(model => model.counted).increment(model => model.incremented).decrement(model => model.decremented))
37
+ .removedWith(Removed);
38
+ }
39
+ }
40
+ projection('fluent-arithmetic', Totals)(TotalsProjection);
41
+ const artifacts = { eventTypes: [Changed, Removed], projections: [TotalsProjection], reducers: [] };
42
+
43
+ describe('when evaluating arithmetic with a fluent projection', () => {
44
+ for (const operation of ['add', 'subtract'] as const) {
45
+ it(`should reject fluent ${operation} with a snake_case operand before any events are seeded`, () => {
46
+ class PriceChanged {
47
+ unit_price = 2;
48
+ }
49
+ field(Number)(PriceChanged.prototype, 'unit_price');
50
+ eventType(`fluent-arithmetic-snake-case-${operation}`)(PriceChanged);
51
+ class PriceTotals {
52
+ id = '';
53
+ total = 0;
54
+ }
55
+ field(String)(PriceTotals.prototype, 'id');
56
+ field(Number)(PriceTotals.prototype, 'total');
57
+ class PriceProjection {
58
+ define(builder: IProjectionBuilderFor<PriceTotals>): void {
59
+ builder.from(PriceChanged, from => from[operation](model => model.total).with(event => event.unit_price));
60
+ }
61
+ }
62
+ projection(`fluent-arithmetic-snake-case-${operation}`, PriceTotals)(PriceProjection);
63
+ (() => new ReadModelScenario(PriceTotals, { eventTypes: [PriceChanged], projections: [PriceProjection], reducers: [] }))
64
+ .should.throw(UnsupportedProjectionOperation).with.property('message')
65
+ .that.includes('kernel only permits an underscore as the first character')
66
+ .and.includes('https://github.com/Cratis/Chronicle/issues/4491');
67
+ });
68
+ }
69
+
70
+ it('should apply all five property operations to initial values', async () => {
71
+ const scenario = new ReadModelScenario(Totals, artifacts);
72
+ scenario.given.forEventSource('A').events(new Changed(2.5), new Changed(-1));
73
+ const result = (await scenario.instance)!;
74
+ [result.added, result.subtracted, result.counted, result.incremented, result.decremented].should.deep.equal([11.5, 8.5, 2, 2, -2]);
75
+ });
76
+
77
+ it('should discard the accumulator on removal and reapply initial values on recreation', async () => {
78
+ const scenario = new ReadModelScenario(Totals, artifacts);
79
+ scenario.given.forEventSource('A').events(new Changed(3), new Removed());
80
+ (await scenario.wasDeletedForEventSourceId('A')).should.be.true;
81
+ scenario.given.forEventSource('A').events(new Changed(4));
82
+ const result = (await scenario.instance)!;
83
+ [result.added, result.counted].should.deep.equal([14, 1]);
84
+ });
85
+ });
@@ -0,0 +1,91 @@
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 } 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
+
18
+ @eventType('legacy-arithmetic-added')
19
+ class Added {
20
+ @field(Number) amount: number;
21
+ @field(String) title = 'not mapped';
22
+ constructor(amount: number) { this.amount = amount; }
23
+ }
24
+ @eventType('legacy-arithmetic-subtracted')
25
+ class Subtracted {
26
+ @field(Number) amount: number;
27
+ constructor(amount: number) { this.amount = amount; }
28
+ }
29
+ @eventType('legacy-arithmetic-renamed')
30
+ class Renamed {
31
+ @field(Number) amount = 1;
32
+ @field(String) caption = 'explicit';
33
+ @field(String) title = 'automapped';
34
+ }
35
+ class Totals {
36
+ @field(String) id = '';
37
+ @field(Number) @addFrom(Added, 'amount') @subtractFrom(Subtracted, 'amount') @addFrom(Renamed, 'amount') total = 0;
38
+ @field(Number) @addFrom(Added) @subtractFrom(Subtracted) amount = 0;
39
+ @field(Number) @count(Added) counted = 0;
40
+ @field(Number) @increment(Added) incremented = 0;
41
+ @field(Number) @decrement(Added) decremented = 0;
42
+ @field(String) @setFrom(Renamed, 'caption') label = '';
43
+ @field(String) title = '';
44
+ }
45
+ const artifacts = { eventTypes: [Added, Subtracted, Renamed], readModels: [Totals], reducers: [], projections: [] };
46
+
47
+ // The same operations are captured in arithmetic.json, arithmetic-numeric-matrix.json and arithmetic-automap.json.
48
+ describe('when evaluating arithmetic with legacy model-bound decorators', () => {
49
+ for (const [operation, decorate] of [['add', addFrom], ['subtract', subtractFrom]] as const) {
50
+ it(`should reject @${operation}From with a snake_case operand before any events are seeded`, () => {
51
+ @eventType(`legacy-arithmetic-snake-case-${operation}`)
52
+ class PriceChanged {
53
+ @field(Number) unit_price = 2;
54
+ }
55
+ class PriceTotals {
56
+ @field(String) id = '';
57
+ @field(Number) @decorate(PriceChanged, 'unit_price') total = 0;
58
+ }
59
+ (() => new ReadModelScenario(PriceTotals, { eventTypes: [PriceChanged], readModels: [PriceTotals], reducers: [], projections: [] }))
60
+ .should.throw(UnsupportedProjectionOperation).with.property('message')
61
+ .that.includes('kernel only permits an underscore as the first character')
62
+ .and.includes('https://github.com/Cratis/Chronicle/issues/4491');
63
+ });
64
+ }
65
+
66
+ it('should accumulate all five operations and default event property names', async () => {
67
+ const scenario = new ReadModelScenario(Totals, artifacts);
68
+ scenario.given.forEventSource('A').events(new Added(2.5), new Added(3), new Subtracted(1));
69
+ const result = (await scenario.instance)!;
70
+ [result.total, result.amount, result.counted, result.incremented, result.decremented].should.deep.equal([4.5, 4.5, 2, 2, -2]);
71
+ });
72
+
73
+ it('should replay additional seeds without counting the previous replay twice and keep sources independent', async () => {
74
+ const scenario = new ReadModelScenario(Totals, artifacts);
75
+ scenario.given.forEventSource('A').events(new Added(2));
76
+ (await scenario.instance)!.total.should.equal(2);
77
+ scenario.given.forEventSource('B').events(new Added(7));
78
+ scenario.given.forEventSource('A').events(new Added(3));
79
+ (await scenario.instanceForEventSourceId('A'))!.total.should.equal(5);
80
+ (await scenario.instanceForEventSourceId('B'))!.total.should.equal(7);
81
+ });
82
+
83
+ it('should suppress aggregate-only AutoMap and restore it for mixed mappings', async () => {
84
+ const scenario = new ReadModelScenario(Totals, artifacts);
85
+ scenario.given.forEventSource('A').events(new Added(2));
86
+ ((await scenario.instance)!.title === undefined).should.be.true;
87
+ scenario.given.forEventSource('A').events(new Renamed());
88
+ const result = (await scenario.instance)!;
89
+ [result.total, result.title, result.label].should.deep.equal([3, 'automapped', 'explicit']);
90
+ });
91
+ });
@@ -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,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
+ }