@cratis/chronicle 6.36.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 (115) 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/tsconfig.tsbuildinfo +1 -1
  84. package/index.ts +3 -0
  85. package/package.json +6 -1
  86. package/patterns/BehaviorPattern.ts +28 -0
  87. package/patterns/DayOfWeek.ts +13 -0
  88. package/patterns/FacetName.ts +17 -0
  89. package/patterns/FacetSet.ts +9 -0
  90. package/patterns/IPatterns.ts +52 -0
  91. package/patterns/PatternMoment.ts +13 -0
  92. package/patterns/PatternQueryOptions.ts +10 -0
  93. package/patterns/Patterns.ts +88 -0
  94. package/patterns/PatternsAtOptions.ts +11 -0
  95. package/patterns/TimeBucket.ts +18 -0
  96. package/patterns/for_PatternMoment/when_deriving_facets/with_invalid_moments.spec.ts +31 -0
  97. package/patterns/for_Patterns/given/a_patterns_service.fixture.ts +21 -0
  98. package/patterns/for_Patterns/when_browsing/with_scopes.spec.ts +21 -0
  99. package/patterns/for_Patterns/when_browsing/within_a_scope.spec.ts +25 -0
  100. package/patterns/for_Patterns/when_querying/with_query_outcomes.spec.ts +47 -0
  101. package/patterns/for_Patterns/when_querying_a_context/with_established_patterns.spec.ts +34 -0
  102. package/patterns/for_Patterns/when_querying_a_context/with_facets.spec.ts +32 -0
  103. package/patterns/for_Patterns/when_querying_at_a_moment/with_additional_facets.spec.ts +34 -0
  104. package/patterns/for_Patterns/when_querying_at_a_moment/with_an_invalid_moment.spec.ts +20 -0
  105. package/patterns/for_Patterns/when_querying_at_a_moment/without_a_moment.spec.ts +37 -0
  106. package/patterns/for_Patterns/when_querying_through_the_connection/after_resetting_the_channel.spec.ts +58 -0
  107. package/patterns/for_toDayOfWeek/when_reading_the_day/at_an_offset.spec.ts +31 -0
  108. package/patterns/for_toTimeBucket/when_bucketing/at_boundaries.spec.ts +39 -0
  109. package/patterns/index.ts +15 -0
  110. package/patterns/toBehaviorPattern.ts +25 -0
  111. package/patterns/toDayOfWeek.ts +19 -0
  112. package/patterns/toOffsetDate.ts +22 -0
  113. package/patterns/toTimeBucket.ts +22 -0
  114. package/testing/ReactorScenario.spec.ts +13 -0
  115. package/testing/ReactorScenario.ts +1 -1
@@ -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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { BehaviorPatternDetailsResponse } from '@cratis/chronicle.contracts';
6
+ import type { BehaviorPattern } from '../../index.js';
7
+ import { a_patterns_service } from '../given/a_patterns_service.fixture.js';
8
+
9
+ chai.should();
10
+
11
+ describe('when browsing every pattern for a scope', () => {
12
+ let context: a_patterns_service;
13
+ let result: BehaviorPattern[];
14
+ beforeEach(async () => {
15
+ context = new a_patterns_service();
16
+ context.response.Data = [BehaviorPatternDetailsResponse.create({ Id: 'pattern-1', GroupingKey: 'user-42' })];
17
+ result = await context.patterns.getPatternsForScope('user-42');
18
+ });
19
+ it('should address the scope within the event store namespace without limits', () => {
20
+ context.client.patternsForScope.mock.calls[0][0].should.deep.equal({
21
+ EventStore: 'accounts', Namespace: 'tenant-a', GroupingKey: 'user-42'
22
+ });
23
+ });
24
+ it('should convert the established patterns', () => result[0].id.should.equal('pattern-1'));
25
+ });
@@ -0,0 +1,47 @@
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 { ChronicleCallFailed } from '../../../connection/callResults.js';
6
+ import type { IPatterns } from '../../index.js';
7
+ import { a_patterns_service } from '../given/a_patterns_service.fixture.js';
8
+
9
+ const should = chai.should();
10
+ const queries: Record<string, (patterns: IPatterns) => Promise<unknown[]>> = {
11
+ matching: patterns => patterns.getPatterns('user-42', {}),
12
+ actions: patterns => patterns.getUsualActions('user-42', {}),
13
+ moment: patterns => patterns.getPatternsAt('user-42'),
14
+ scope: patterns => patterns.getPatternsForScope('user-42'),
15
+ scopes: patterns => patterns.getScopes()
16
+ };
17
+
18
+ for (const [name, query] of Object.entries(queries)) {
19
+ describe(`when querying ${name} with no established behavior`, () => {
20
+ let result: unknown[];
21
+ beforeEach(async () => result = await query(new a_patterns_service().patterns));
22
+ it('should return an empty answer without inventing behavior', () => result.should.deep.equal([]));
23
+ });
24
+
25
+ for (const failureKind of ['authorization', 'validation', 'exception', 'transport']) {
26
+ describe(`when querying ${name} with a ${failureKind} failure`, () => {
27
+ let failure: unknown;
28
+ const transportFailure = new Error('transport unavailable');
29
+ beforeEach(async () => {
30
+ const context = new a_patterns_service();
31
+ for (const response of [context.response, context.scopesResponse]) {
32
+ if (failureKind === 'authorization') response.IsAuthorized = false;
33
+ if (failureKind === 'validation') response.ValidationResults = [{ Severity: 3, Message: 'invalid context', Members: [] }];
34
+ if (failureKind === 'exception') response.ExceptionMessages = ['query failed'];
35
+ }
36
+ if (failureKind === 'transport') {
37
+ for (const client of Object.values(context.client)) client.mockRejectedValue(transportFailure);
38
+ }
39
+ try { await query(context.patterns); } catch (error) { failure = error; }
40
+ });
41
+ it('should reject instead of disguising failure as no behavior', () => {
42
+ if (failureKind === 'transport') should.equal(failure, transportFailure);
43
+ else (failure as Error).should.be.instanceOf(ChronicleCallFailed);
44
+ });
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,34 @@
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 { BehaviorPatternDetailsResponse } from '@cratis/chronicle.contracts';
6
+ import type { BehaviorPattern } from '../../index.js';
7
+ import { a_patterns_service } from '../given/a_patterns_service.fixture.js';
8
+
9
+ const should = chai.should();
10
+
11
+ describe('when querying a context with established patterns', () => {
12
+ let result: BehaviorPattern[];
13
+ beforeEach(async () => {
14
+ const context = new a_patterns_service();
15
+ context.response.Data = [
16
+ BehaviorPatternDetailsResponse.create({
17
+ Id: 'pattern-1', GroupingKey: 'user-42', Facets: { Day: 'Monday', CommandType: 'RegisterInvoice' },
18
+ Confidence: 0.75, Support: 0.2, Occurrences: 9_007_199_254_740_993n, Weight: 3.5, Specificity: 2,
19
+ FirstSeen: { Value: '2026-01-05T09:00:00+02:00' }, LastSeen: { Value: '2026-01-12T09:00:00+02:00' }
20
+ }),
21
+ BehaviorPatternDetailsResponse.create({ Id: 'pattern-2', Confidence: 0.95 })
22
+ ];
23
+ result = await context.patterns.getPatterns('user-42', {});
24
+ });
25
+ it('should convert every response field without losing occurrence precision', () => {
26
+ result[0].should.deep.equal({
27
+ id: 'pattern-1', groupingKey: 'user-42', facets: { Day: 'Monday', CommandType: 'RegisterInvoice' },
28
+ confidence: 0.75, support: 0.2, occurrences: 9_007_199_254_740_993n, weight: 3.5, specificity: 2,
29
+ firstSeen: new Date('2026-01-05T07:00:00Z'), lastSeen: new Date('2026-01-12T07:00:00Z')
30
+ });
31
+ });
32
+ it('should preserve the server ranking', () => result.map(pattern => pattern.id).should.deep.equal(['pattern-1', 'pattern-2']));
33
+ it('should not invent a missing observation timestamp', () => should.equal(result[1].firstSeen, undefined));
34
+ });
@@ -0,0 +1,32 @@
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 { FacetName } from '../../index.js';
6
+ import { a_patterns_service } from '../given/a_patterns_service.fixture.js';
7
+
8
+ chai.should();
9
+
10
+ for (const method of ['getPatterns', 'getUsualActions'] as const) {
11
+ for (const limited of [false, true]) {
12
+ describe(`when ${method} queries facets ${limited ? 'with explicit limits' : 'with server defaults'}`, () => {
13
+ let context: a_patterns_service;
14
+ beforeEach(async () => {
15
+ context = new a_patterns_service();
16
+ await context.patterns[method]('user-42', {
17
+ [FacetName.CommandType]: 'RegisterInvoice',
18
+ [FacetName.AggregateType]: '',
19
+ CustomFacet: 'custom-value'
20
+ }, limited ? { minimumConfidence: 0.75, maximumResults: 3 } : undefined);
21
+ });
22
+ it('should scope the query and omit unspecified facet values', () => {
23
+ const client = method === 'getPatterns' ? context.client.matchingPatterns : context.client.usualActions;
24
+ client.mock.calls[0][0].should.deep.equal({
25
+ EventStore: 'accounts', Namespace: 'tenant-a', GroupingKey: 'user-42',
26
+ Context: { CommandType: 'RegisterInvoice', CustomFacet: 'custom-value' },
27
+ MinimumConfidence: limited ? 0.75 : 0, MaximumResults: limited ? 3 : 0
28
+ });
29
+ });
30
+ });
31
+ }
32
+ }
@@ -0,0 +1,34 @@
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, FacetName, type FacetSet, TimeBucket } from '../../index.js';
6
+ import { a_patterns_service } from '../given/a_patterns_service.fixture.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when querying at a moment with additional facets', () => {
11
+ let context: a_patterns_service;
12
+ const facets: FacetSet = Object.freeze({
13
+ [FacetName.AggregateType]: 'Invoice',
14
+ [FacetName.CommandType]: 'RegisterInvoice',
15
+ [FacetName.Day]: DayOfWeek.Friday,
16
+ [FacetName.TimeBucket]: TimeBucket.Evening,
17
+ CustomFacet: 'custom-value'
18
+ });
19
+ beforeEach(async () => {
20
+ context = new a_patterns_service();
21
+ await context.patterns.getPatternsAt('user-42', {
22
+ instant: new Date('2026-01-04T23:30:00Z'), offsetMinutes: 345
23
+ }, { alsoConstraining: facets, minimumConfidence: 0.8, maximumResults: 5 });
24
+ });
25
+ it('should ask about usual actions in the explicit offset day and bucket', () => {
26
+ context.client.usualActions.mock.calls[0][0].should.deep.equal({
27
+ EventStore: 'accounts', Namespace: 'tenant-a', GroupingKey: 'user-42',
28
+ Context: { ...facets, Day: DayOfWeek.Monday, TimeBucket: TimeBucket.EarlyMorning },
29
+ MinimumConfidence: 0.8, MaximumResults: 5
30
+ });
31
+ });
32
+ it('should not use the descriptive matching endpoint', () => context.client.matchingPatterns.mock.calls.should.have.lengthOf(0));
33
+ it('should leave the caller context unchanged', () => facets[FacetName.Day].should.equal(DayOfWeek.Friday));
34
+ });
@@ -0,0 +1,20 @@
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 { a_patterns_service } from '../given/a_patterns_service.fixture.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when querying an invalid moment', () => {
10
+ let context: a_patterns_service;
11
+ let failure: unknown;
12
+ beforeEach(async () => {
13
+ context = new a_patterns_service();
14
+ try {
15
+ await context.patterns.getPatternsAt('user-42', { instant: new Date(NaN), offsetMinutes: 0 });
16
+ } catch (error) { failure = error; }
17
+ });
18
+ it('should reject the query', () => (failure as Error).should.be.instanceOf(RangeError));
19
+ it('should not send a misleading context to the kernel', () => context.client.usualActions.mock.calls.should.have.lengthOf(0));
20
+ });
@@ -0,0 +1,37 @@
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, vi } from 'vitest';
5
+ import { DayOfWeek, FacetName, TimeBucket } from '../../index.js';
6
+ import { a_patterns_service } from '../given/a_patterns_service.fixture.js';
7
+
8
+ chai.should();
9
+
10
+ for (const withOptions of [false, true]) {
11
+ describe(`when querying now ${withOptions ? 'with additional facets' : 'with only a scope'}`, () => {
12
+ let context: a_patterns_service;
13
+ beforeEach(async () => {
14
+ vi.useFakeTimers({ toFake: ['Date'] });
15
+ vi.setSystemTime(new Date('2026-01-05T01:00:00Z'));
16
+ // Simulate a machine west of UTC regardless of the machine running the specs.
17
+ vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(240);
18
+ context = new a_patterns_service();
19
+ if (withOptions) {
20
+ await context.patterns.getPatternsAt('user-42', undefined, { alsoConstraining: { [FacetName.AggregateType]: 'Invoice' } });
21
+ } else {
22
+ await context.patterns.getPatternsAt('user-42');
23
+ }
24
+ });
25
+ afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); });
26
+ it('should capture now at the local system offset with server default limits', () => {
27
+ context.client.usualActions.mock.calls[0][0].should.deep.equal({
28
+ EventStore: 'accounts', Namespace: 'tenant-a', GroupingKey: 'user-42',
29
+ Context: {
30
+ ...(withOptions ? { AggregateType: 'Invoice' } : {}),
31
+ Day: DayOfWeek.Sunday, TimeBucket: TimeBucket.Evening
32
+ },
33
+ MinimumConfidence: 0, MaximumResults: 0
34
+ });
35
+ });
36
+ });
37
+ }
@@ -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];