@cratis/chronicle 6.36.0 → 6.38.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.
- package/EventStore.ts +3 -0
- package/IEventStore.ts +4 -0
- package/connection/ChronicleConnection.ts +6 -0
- package/connection/ChronicleServices.ts +2 -0
- package/dist/EventStore.d.ts +2 -0
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +3 -0
- package/dist/EventStore.js.map +1 -1
- package/dist/IEventStore.d.ts +3 -0
- package/dist/IEventStore.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.d.ts +1 -0
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +5 -1
- package/dist/connection/ChronicleConnection.js.map +1 -1
- package/dist/connection/ChronicleServices.d.ts +2 -1
- package/dist/connection/ChronicleServices.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/patterns/BehaviorPattern.d.ts +25 -0
- package/dist/patterns/BehaviorPattern.d.ts.map +1 -0
- package/dist/patterns/BehaviorPattern.js +4 -0
- package/dist/patterns/BehaviorPattern.js.map +1 -0
- package/dist/patterns/DayOfWeek.d.ts +11 -0
- package/dist/patterns/DayOfWeek.d.ts.map +1 -0
- package/dist/patterns/DayOfWeek.js +14 -0
- package/dist/patterns/DayOfWeek.js.map +1 -0
- package/dist/patterns/FacetName.d.ts +15 -0
- package/dist/patterns/FacetName.d.ts.map +1 -0
- package/dist/patterns/FacetName.js +18 -0
- package/dist/patterns/FacetName.js.map +1 -0
- package/dist/patterns/FacetSet.d.ts +7 -0
- package/dist/patterns/FacetSet.d.ts.map +1 -0
- package/dist/patterns/FacetSet.js +4 -0
- package/dist/patterns/FacetSet.js.map +1 -0
- package/dist/patterns/IPatterns.d.ts +45 -0
- package/dist/patterns/IPatterns.d.ts.map +1 -0
- package/dist/patterns/IPatterns.js +4 -0
- package/dist/patterns/IPatterns.js.map +1 -0
- package/dist/patterns/PatternMoment.d.ts +11 -0
- package/dist/patterns/PatternMoment.d.ts.map +1 -0
- package/dist/patterns/PatternMoment.js +4 -0
- package/dist/patterns/PatternMoment.js.map +1 -0
- package/dist/patterns/PatternQueryOptions.d.ts +8 -0
- package/dist/patterns/PatternQueryOptions.d.ts.map +1 -0
- package/dist/patterns/PatternQueryOptions.js +4 -0
- package/dist/patterns/PatternQueryOptions.js.map +1 -0
- package/dist/patterns/Patterns.d.ts +32 -0
- package/dist/patterns/Patterns.d.ts.map +1 -0
- package/dist/patterns/Patterns.js +76 -0
- package/dist/patterns/Patterns.js.map +1 -0
- package/dist/patterns/PatternsAtOptions.d.ts +8 -0
- package/dist/patterns/PatternsAtOptions.d.ts.map +1 -0
- package/dist/patterns/PatternsAtOptions.js +4 -0
- package/dist/patterns/PatternsAtOptions.js.map +1 -0
- package/dist/patterns/TimeBucket.d.ts +16 -0
- package/dist/patterns/TimeBucket.d.ts.map +1 -0
- package/dist/patterns/TimeBucket.js +19 -0
- package/dist/patterns/TimeBucket.js.map +1 -0
- package/dist/patterns/index.d.ts +13 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +9 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/patterns/toBehaviorPattern.d.ts +9 -0
- package/dist/patterns/toBehaviorPattern.d.ts.map +1 -0
- package/dist/patterns/toBehaviorPattern.js +22 -0
- package/dist/patterns/toBehaviorPattern.js.map +1 -0
- package/dist/patterns/toDayOfWeek.d.ts +10 -0
- package/dist/patterns/toDayOfWeek.d.ts.map +1 -0
- package/dist/patterns/toDayOfWeek.js +16 -0
- package/dist/patterns/toDayOfWeek.js.map +1 -0
- package/dist/patterns/toOffsetDate.d.ts +9 -0
- package/dist/patterns/toOffsetDate.d.ts.map +1 -0
- package/dist/patterns/toOffsetDate.js +20 -0
- package/dist/patterns/toOffsetDate.js.map +1 -0
- package/dist/patterns/toTimeBucket.d.ts +10 -0
- package/dist/patterns/toTimeBucket.d.ts.map +1 -0
- package/dist/patterns/toTimeBucket.js +25 -0
- package/dist/patterns/toTimeBucket.js.map +1 -0
- package/dist/testing/ReactorScenario.js +1 -1
- package/dist/testing/ReactorScenario.js.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.js +9 -2
- package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
- package/dist/testing/projections/ProjectionChildrenCapabilities.js +1 -1
- package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -1
- package/dist/testing/projections/ProjectionJoinRecord.d.ts +9 -0
- package/dist/testing/projections/ProjectionJoinRecord.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionJoinRecord.js +4 -0
- package/dist/testing/projections/ProjectionJoinRecord.js.map +1 -0
- package/dist/testing/projections/ProjectionJoinValidationContext.d.ts +12 -0
- package/dist/testing/projections/ProjectionJoinValidationContext.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionJoinValidationContext.js +4 -0
- package/dist/testing/projections/ProjectionJoinValidationContext.js.map +1 -0
- package/dist/testing/projections/ProjectionJoins.d.ts +8 -0
- package/dist/testing/projections/ProjectionJoins.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionJoins.js +29 -0
- package/dist/testing/projections/ProjectionJoins.js.map +1 -0
- package/dist/testing/projections/ProjectionJoinsCapabilities.d.ts +10 -0
- package/dist/testing/projections/ProjectionJoinsCapabilities.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionJoinsCapabilities.js +93 -0
- package/dist/testing/projections/ProjectionJoinsCapabilities.js.map +1 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +2 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.js +35 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/index.ts +3 -0
- package/package.json +7 -2
- package/patterns/BehaviorPattern.ts +28 -0
- package/patterns/DayOfWeek.ts +13 -0
- package/patterns/FacetName.ts +17 -0
- package/patterns/FacetSet.ts +9 -0
- package/patterns/IPatterns.ts +52 -0
- package/patterns/PatternMoment.ts +13 -0
- package/patterns/PatternQueryOptions.ts +10 -0
- package/patterns/Patterns.ts +88 -0
- package/patterns/PatternsAtOptions.ts +11 -0
- package/patterns/TimeBucket.ts +18 -0
- package/patterns/for_PatternMoment/when_deriving_facets/with_invalid_moments.spec.ts +31 -0
- package/patterns/for_Patterns/given/a_patterns_service.fixture.ts +21 -0
- package/patterns/for_Patterns/when_browsing/with_scopes.spec.ts +21 -0
- package/patterns/for_Patterns/when_browsing/within_a_scope.spec.ts +25 -0
- package/patterns/for_Patterns/when_querying/with_query_outcomes.spec.ts +47 -0
- package/patterns/for_Patterns/when_querying_a_context/with_established_patterns.spec.ts +34 -0
- package/patterns/for_Patterns/when_querying_a_context/with_facets.spec.ts +32 -0
- package/patterns/for_Patterns/when_querying_at_a_moment/with_additional_facets.spec.ts +34 -0
- package/patterns/for_Patterns/when_querying_at_a_moment/with_an_invalid_moment.spec.ts +20 -0
- package/patterns/for_Patterns/when_querying_at_a_moment/without_a_moment.spec.ts +37 -0
- package/patterns/for_Patterns/when_querying_through_the_connection/after_resetting_the_channel.spec.ts +58 -0
- package/patterns/for_toDayOfWeek/when_reading_the_day/at_an_offset.spec.ts +31 -0
- package/patterns/for_toTimeBucket/when_bucketing/at_boundaries.spec.ts +39 -0
- package/patterns/index.ts +15 -0
- package/patterns/toBehaviorPattern.ts +25 -0
- package/patterns/toDayOfWeek.ts +19 -0
- package/patterns/toOffsetDate.ts +22 -0
- package/patterns/toTimeBucket.ts +22 -0
- package/testing/ReactorScenario.spec.ts +13 -0
- package/testing/ReactorScenario.ts +1 -1
- package/testing/for_ReadModelScenario/when_evaluating_joins/with_a_fluent_projection.spec.ts +111 -0
- package/testing/for_ReadModelScenario/when_evaluating_joins/with_legacy_decorators.legacy.spec.ts +68 -0
- package/testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts +69 -0
- package/testing/projections/ProjectionCapabilities.ts +10 -2
- package/testing/projections/ProjectionChildrenCapabilities.ts +1 -1
- package/testing/projections/ProjectionJoinRecord.ts +10 -0
- package/testing/projections/ProjectionJoinValidationContext.ts +15 -0
- package/testing/projections/ProjectionJoins.ts +30 -0
- package/testing/projections/ProjectionJoinsCapabilities.ts +90 -0
- package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +2 -0
- package/testing/projections/ProjectionReadModelProcessor.ts +34 -1
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +1 -1
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +2 -2
- package/testing/projections/for_ProjectionCapabilities/when_validating_joins.spec.ts +118 -0
package/testing/for_ReadModelScenario/when_evaluating_joins/with_legacy_decorators.legacy.spec.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import { field } from '@cratis/fundamentals';
|
|
6
|
+
import { chai, describe, it } from 'vitest';
|
|
7
|
+
import { eventType } from '../../../events/eventTypeDecorator.js';
|
|
8
|
+
import { fromEvent } from '../../../projections/modelBound/fromEvent.js';
|
|
9
|
+
import { join } from '../../../projections/modelBound/join.js';
|
|
10
|
+
import { noAutoMap } from '../../../projections/modelBound/noAutoMap.js';
|
|
11
|
+
import { removedWith } from '../../../projections/modelBound/removedWith.js';
|
|
12
|
+
import { ReadModelScenario } from '../../ReadModelScenario.js';
|
|
13
|
+
|
|
14
|
+
chai.should();
|
|
15
|
+
|
|
16
|
+
@eventType('legacy-join-placed')
|
|
17
|
+
class Placed {
|
|
18
|
+
@field(String) customerId: string;
|
|
19
|
+
constructor(customerId: string) { this.customerId = customerId; }
|
|
20
|
+
}
|
|
21
|
+
@eventType('legacy-join-named')
|
|
22
|
+
class Named {
|
|
23
|
+
@field(String) name: string;
|
|
24
|
+
@field(String) city = 'Oslo';
|
|
25
|
+
@field(String) ignored = 'excluded';
|
|
26
|
+
constructor(name: string) { this.name = name; }
|
|
27
|
+
}
|
|
28
|
+
@eventType('legacy-join-removed')
|
|
29
|
+
class Removed {}
|
|
30
|
+
@fromEvent(Placed)
|
|
31
|
+
@removedWith(Removed)
|
|
32
|
+
class Order {
|
|
33
|
+
@field(String) id = '';
|
|
34
|
+
@field(String) customerId = '';
|
|
35
|
+
@field(String) @join(Named, 'customerId', 'name') customerName = '';
|
|
36
|
+
@field(String) name = '';
|
|
37
|
+
@field(String) city = '';
|
|
38
|
+
@field(String) @noAutoMap ignored = '';
|
|
39
|
+
}
|
|
40
|
+
const artifacts = { eventTypes: [Placed, Named, Removed], readModels: [Order], reducers: [], projections: [] };
|
|
41
|
+
|
|
42
|
+
describe('when evaluating joins with legacy model-bound decorators', () => {
|
|
43
|
+
it('should backfill and automap joined values without mapping an explicitly consumed source twice', async () => {
|
|
44
|
+
const scenario = new ReadModelScenario(Order, artifacts);
|
|
45
|
+
scenario.given.forEventSource('customer').events(new Named('before'));
|
|
46
|
+
scenario.given.forEventSource('A').events(new Placed('customer'));
|
|
47
|
+
const result = (await scenario.instance)!;
|
|
48
|
+
result.customerName.should.equal('before');
|
|
49
|
+
result.city.should.equal('Oslo');
|
|
50
|
+
(result.name === undefined).should.be.true;
|
|
51
|
+
(result.ignored === undefined).should.be.true;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should update existing roots and recreate a removed root from the latest joined history', async () => {
|
|
55
|
+
const scenario = new ReadModelScenario(Order, artifacts);
|
|
56
|
+
scenario.given.forEventSource('A').events(new Placed('customer'));
|
|
57
|
+
scenario.given.forEventSource('B').events(new Placed('customer'));
|
|
58
|
+
scenario.given.forEventSource('customer').events(new Named('first'));
|
|
59
|
+
(await scenario.instanceForEventSourceId('A'))!.customerName.should.equal('first');
|
|
60
|
+
scenario.given.forEventSource('A').events(new Removed());
|
|
61
|
+
scenario.given.forEventSource('customer').events(new Named('second'));
|
|
62
|
+
(await scenario.wasDeletedForEventSourceId('A')).should.be.true;
|
|
63
|
+
(await scenario.instanceForEventSourceId('B'))!.customerName.should.equal('second');
|
|
64
|
+
scenario.given.forEventSource('A').events(new Placed('customer'));
|
|
65
|
+
(await scenario.instanceForEventSourceId('A'))!.customerName.should.equal('second');
|
|
66
|
+
(await scenario.wasDeletedForEventSourceId('A')).should.be.false;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { fromEvent } from '../../../projections/modelBound/fromEvent.js';
|
|
8
|
+
import { join } from '../../../projections/modelBound/join.js';
|
|
9
|
+
import { noAutoMap } from '../../../projections/modelBound/noAutoMap.js';
|
|
10
|
+
import { ReadModelScenario } from '../../ReadModelScenario.js';
|
|
11
|
+
import { UnsupportedProjectionOperation } from '../../projections/UnsupportedProjectionOperation.js';
|
|
12
|
+
|
|
13
|
+
chai.should();
|
|
14
|
+
|
|
15
|
+
@eventType('standard-join-placed')
|
|
16
|
+
class Placed {
|
|
17
|
+
@field(String) customerId: string;
|
|
18
|
+
constructor(customerId: string) { this.customerId = customerId; }
|
|
19
|
+
}
|
|
20
|
+
@eventType('standard-join-named')
|
|
21
|
+
class Named {
|
|
22
|
+
@field(String) name: string;
|
|
23
|
+
@field(String) city = 'Oslo';
|
|
24
|
+
@field(String) ignored = 'excluded';
|
|
25
|
+
constructor(name: string) { this.name = name; }
|
|
26
|
+
}
|
|
27
|
+
@fromEvent(Placed)
|
|
28
|
+
class Order {
|
|
29
|
+
@field(String) id = '';
|
|
30
|
+
@field(String) customerId = '';
|
|
31
|
+
@field(String) @join(Named, 'customerId', 'name') customerName = '';
|
|
32
|
+
@field(String) name = '';
|
|
33
|
+
@field(String) city = '';
|
|
34
|
+
@field(String) @noAutoMap ignored = '';
|
|
35
|
+
}
|
|
36
|
+
const artifacts = { eventTypes: [Placed, Named], readModels: [Order], reducers: [], projections: [] };
|
|
37
|
+
|
|
38
|
+
// joins-automap.json captures foreign-key AutoMap, consumed-source exclusion and join AutoMap.
|
|
39
|
+
describe('when evaluating joins with standard model-bound decorators', () => {
|
|
40
|
+
it('should backfill through the automapped foreign key and avoid mapping an explicitly consumed source twice', async () => {
|
|
41
|
+
const scenario = new ReadModelScenario(Order, artifacts);
|
|
42
|
+
scenario.given.forEventSource('customer').events(new Named('before'));
|
|
43
|
+
scenario.given.forEventSource('A').events(new Placed('customer'));
|
|
44
|
+
const result = (await scenario.instance)!;
|
|
45
|
+
result.customerName.should.equal('before');
|
|
46
|
+
result.city.should.equal('Oslo');
|
|
47
|
+
(result.name === undefined).should.be.true;
|
|
48
|
+
(result.ignored === undefined).should.be.true;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should fan out subsequent join events to all matching roots', async () => {
|
|
52
|
+
const scenario = new ReadModelScenario(Order, artifacts);
|
|
53
|
+
scenario.given.forEventSource('A').events(new Placed('customer'));
|
|
54
|
+
scenario.given.forEventSource('B').events(new Placed('customer'));
|
|
55
|
+
scenario.given.forEventSource('customer').events(new Named('after'));
|
|
56
|
+
(await scenario.instanceForEventSourceId('A'))!.customerName.should.equal('after');
|
|
57
|
+
(await scenario.instanceForEventSourceId('B'))!.customerName.should.equal('after');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should reject an identifier join during scenario construction', () => {
|
|
61
|
+
@fromEvent(Placed)
|
|
62
|
+
class Unsupported {
|
|
63
|
+
@field(String) id = '';
|
|
64
|
+
@field(String) @join(Named, 'id', 'name') customerName = '';
|
|
65
|
+
}
|
|
66
|
+
(() => new ReadModelScenario(Unsupported, { ...artifacts, readModels: [Unsupported] })).should.throw(UnsupportedProjectionOperation)
|
|
67
|
+
.with.property('message').that.includes('Join[standard-join-named:1].On (@join)').and.includes('direct non-identifier');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -10,6 +10,7 @@ import { getEventTypeMapKey, type ChildrenDefinitionLike } from '../../projectio
|
|
|
10
10
|
import type { JsonSchema } from '../../schemas/JsonSchema.js';
|
|
11
11
|
import { ProjectionArithmetic } from './ProjectionArithmetic.js';
|
|
12
12
|
import { ProjectionChildrenCapabilities } from './ProjectionChildrenCapabilities.js';
|
|
13
|
+
import { ProjectionJoinsCapabilities } from './ProjectionJoinsCapabilities.js';
|
|
13
14
|
import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
|
|
14
15
|
|
|
15
16
|
/** Validates *all* subscribed operations against the bounded scenario subset, before replay. */
|
|
@@ -25,6 +26,7 @@ export class ProjectionCapabilities {
|
|
|
25
26
|
const reject = (path: string, reason: string, fallback?: string) : never => {
|
|
26
27
|
const source = compiled.provenance.get(definition)?.find(entry => entry.contractPath.startsWith('From['))?.declaration ?? '.from';
|
|
27
28
|
const declaration = compiled.provenance.get(definition)?.find(entry => entry.contractPath === path)?.declaration
|
|
29
|
+
?? compiled.provenance.get(definition)?.find(entry => entry.contractPath.startsWith('Join[') && path.startsWith(`${entry.contractPath}.`))?.declaration
|
|
28
30
|
?? fallback ?? (path.includes('.AutoMap.') ? `${source} (AutoMap)`
|
|
29
31
|
: path.startsWith('InitialModelState') ? '.withInitialValues'
|
|
30
32
|
: path.startsWith('ReadModel') ? source : 'contract');
|
|
@@ -38,11 +40,11 @@ export class ProjectionCapabilities {
|
|
|
38
40
|
if (wire.IsActive === false) reject('IsActive', 'passive projections require a kernel-backed test');
|
|
39
41
|
if (wire.SubscribesToAllEvents === true) reject('SubscribesToAllEvents', 'subscribe-to-all projections require a kernel-backed test');
|
|
40
42
|
if (wire.EventSequenceId !== EventSequenceId.eventLog.value) reject('EventSequenceId', 'non-default event sequences require a kernel-backed test');
|
|
41
|
-
for (const section of ['
|
|
43
|
+
for (const section of ['Nested', 'RemovedWithJoin'] as const) {
|
|
42
44
|
const value = wire[section];
|
|
43
45
|
const paths = Array.isArray(value) ? value.map((entry: { Key: ContractEventType }) => eventContractPath(section, entry.Key))
|
|
44
46
|
: Object.keys(value as Record<string, unknown> ?? {}).map(property => `${section}.${property}`);
|
|
45
|
-
if (paths.length) reject(paths[0], `${section === 'Nested' ? 'nested projections' :
|
|
47
|
+
if (paths.length) reject(paths[0], `${section === 'Nested' ? 'nested projections' : 'removedWithJoin'} require a kernel-backed test`);
|
|
46
48
|
}
|
|
47
49
|
if ((wire.FromEvery as unknown[] | undefined)?.length) reject('FromEvery', 'derivative projections require a kernel-backed test');
|
|
48
50
|
if (wire.FromEventProperty) reject('FromEventProperty', 'event-property subscriptions require a kernel-backed test');
|
|
@@ -89,6 +91,12 @@ export class ProjectionCapabilities {
|
|
|
89
91
|
const event = catalog.get(getEventTypeMapKey(eventType)) ?? reject(path, 'participating event schema is unavailable');
|
|
90
92
|
return event.schema;
|
|
91
93
|
};
|
|
94
|
+
ProjectionJoinsCapabilities.validate(definition, {
|
|
95
|
+
schema: schema!, requireEventSchema, reject,
|
|
96
|
+
checkProtection: (eventSchema, path) => this.checkProtection(eventSchema, path, reject),
|
|
97
|
+
checkMapping: (modelSchema, eventSchema, destination, expression, path) =>
|
|
98
|
+
this.checkMapping(modelSchema, eventSchema, destination, expression, path, reject)
|
|
99
|
+
});
|
|
92
100
|
const from = wire.From as FromRecord[] ?? [];
|
|
93
101
|
const removedWith = wire.RemovedWith as RemovedWithRecord[] ?? [];
|
|
94
102
|
for (const entry of from) {
|
|
@@ -49,7 +49,7 @@ export class ProjectionChildrenCapabilities {
|
|
|
49
49
|
const declaration = context.declarationFor(base);
|
|
50
50
|
const reject = (path: string, reason: string): never => context.reject(path, reason, declaration);
|
|
51
51
|
const wire = child as unknown as Record<string, unknown>;
|
|
52
|
-
if ((child.Join ?? []).length) reject(`${base}.Join`, 'joins require a kernel-backed test');
|
|
52
|
+
if ((child.Join ?? []).length) reject(`${base}.Join`, 'joins inside children require a kernel-backed test');
|
|
53
53
|
if (Object.keys(child.Children ?? {}).length) reject(`${base}.Children`, `nested children collections ${kernelBacked}`);
|
|
54
54
|
if (Object.keys(child.Nested ?? {}).length) reject(`${base}.Nested`, `nested projections inside children ${kernelBacked}`);
|
|
55
55
|
if ((child.RemovedWithJoin ?? []).length) reject(`${base}.RemovedWithJoin`, `removedWithJoin inside children ${kernelBacked}`);
|
|
@@ -0,0 +1,10 @@
|
|
|
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 { AutoMap } from '@cratis/chronicle.contracts';
|
|
5
|
+
import type { JoinRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
|
|
6
|
+
|
|
7
|
+
/** A compiled join entry, including the contract's optional per-join AutoMap override. */
|
|
8
|
+
export interface ProjectionJoinRecord extends JoinRecord {
|
|
9
|
+
Value: JoinRecord['Value'] & { AutoMap?: AutoMap };
|
|
10
|
+
}
|
|
@@ -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
|
+
import type { ContractEventType } from '../../projections/declarative/ProjectionBuilderCore.js';
|
|
5
|
+
import type { JsonSchema } from '../../schemas/JsonSchema.js';
|
|
6
|
+
import type { CheckMapping, RejectOperation } from './ProjectionChildrenCapabilities.js';
|
|
7
|
+
|
|
8
|
+
/** Schema checks shared with the root projection capability validator. */
|
|
9
|
+
export interface ProjectionJoinValidationContext {
|
|
10
|
+
readonly schema: JsonSchema;
|
|
11
|
+
readonly requireEventSchema: (eventType: ContractEventType, path: string) => JsonSchema;
|
|
12
|
+
readonly checkMapping: CheckMapping;
|
|
13
|
+
readonly checkProtection: (schema: JsonSchema, path: string) => void;
|
|
14
|
+
readonly reject: RejectOperation;
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { AutoMap, type ProjectionDefinition } from '@cratis/chronicle.contracts';
|
|
5
|
+
import type { JsonSchema } from '../../schemas/JsonSchema.js';
|
|
6
|
+
import type { ProjectionJoinRecord } from './ProjectionJoinRecord.js';
|
|
7
|
+
|
|
8
|
+
/** The kernel's join AutoMap differs from From: explicitly consumed sources are not mapped again. */
|
|
9
|
+
export class ProjectionJoins {
|
|
10
|
+
/** Builds the effective root join mappings, including inherited settings and exclusions. */
|
|
11
|
+
static properties(definition: ProjectionDefinition, eventSchema: JsonSchema, modelSchema: JsonSchema): Record<string, string> {
|
|
12
|
+
const join = (definition.Join[0] as ProjectionJoinRecord).Value;
|
|
13
|
+
const properties = { ...join.Properties };
|
|
14
|
+
const autoMap = (join.AutoMap ?? AutoMap.Inherit) === AutoMap.Inherit ? definition.AutoMap : join.AutoMap;
|
|
15
|
+
if (autoMap === AutoMap.Disabled) return properties;
|
|
16
|
+
const destinations = new Set(Object.keys(properties).map(name => name.toLowerCase()));
|
|
17
|
+
const sources = new Set(Object.values(properties).map(name => name.toLowerCase()));
|
|
18
|
+
const exclusions = new Set((definition.NoAutoMapProperties ?? []).map(name => name.toLowerCase()));
|
|
19
|
+
for (const source of Object.keys(eventSchema.properties ?? {})) {
|
|
20
|
+
if (destinations.has(source.toLowerCase()) || sources.has(source.toLowerCase())) continue;
|
|
21
|
+
const destination = Object.keys(modelSchema.properties ?? {}).find(name => name.toLowerCase() === source.toLowerCase());
|
|
22
|
+
if (destination && !exclusions.has(destination.toLowerCase())) {
|
|
23
|
+
properties[destination] = source;
|
|
24
|
+
destinations.add(destination.toLowerCase());
|
|
25
|
+
sources.add(source.toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return properties;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { AutoMap, type ProjectionDefinition } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { eventContractPath } from '../../projections/eventContractPath.js';
|
|
6
|
+
import type { FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
|
|
7
|
+
import type { JsonSchema } from '../../schemas/JsonSchema.js';
|
|
8
|
+
import { ProjectionArithmetic } from './ProjectionArithmetic.js';
|
|
9
|
+
import type { ProjectionJoinRecord } from './ProjectionJoinRecord.js';
|
|
10
|
+
import type { ProjectionJoinValidationContext } from './ProjectionJoinValidationContext.js';
|
|
11
|
+
import { ProjectionJoins } from './ProjectionJoins.js';
|
|
12
|
+
|
|
13
|
+
/** The string-only, single root join proven by the joins-* packaged-kernel fixtures. */
|
|
14
|
+
export class ProjectionJoinsCapabilities {
|
|
15
|
+
/** Rejects unproven join shapes before any history is replayed. */
|
|
16
|
+
static validate(definition: ProjectionDefinition, context: ProjectionJoinValidationContext): void {
|
|
17
|
+
const joins = definition.Join as ProjectionJoinRecord[] ?? [];
|
|
18
|
+
if (!joins.length) return;
|
|
19
|
+
const { schema, reject, requireEventSchema, checkMapping } = context;
|
|
20
|
+
const join = joins[0];
|
|
21
|
+
const path = eventContractPath('Join', join.Key);
|
|
22
|
+
if (joins.length !== 1) reject(path, 'multiple join subscriptions require a kernel-backed test');
|
|
23
|
+
if (Object.keys(definition.Children ?? {}).length) reject(path, 'root joins combined with children require a kernel-backed test');
|
|
24
|
+
const from = definition.From as FromRecord[] ?? [];
|
|
25
|
+
if ([...from, ...joins].some(entry => Object.values(entry.Value.Properties ?? {}).some(ProjectionArithmetic.isArithmetic))) {
|
|
26
|
+
reject(path, 'joins combined with arithmetic require a kernel-backed test');
|
|
27
|
+
}
|
|
28
|
+
if (Object.keys(JSON.parse(definition.InitialModelState || '{}') as object).length) {
|
|
29
|
+
reject('InitialModelState', 'joins with initial model state require a kernel-backed test');
|
|
30
|
+
}
|
|
31
|
+
if (join.Value.Key !== '$eventSourceId') reject(`${path}.Key`, 'join keys other than $eventSourceId require a kernel-backed test; the kernel ignores custom root join keys');
|
|
32
|
+
if (!join.Value.On || join.Value.On === 'id' || !/^[A-Za-z_]\w*$/.test(join.Value.On) || !schema.properties?.[join.Value.On]) {
|
|
33
|
+
reject(`${path}.On`, 'joins require a direct non-identifier read-model property; other join targets require a kernel-backed test');
|
|
34
|
+
}
|
|
35
|
+
for (const [property, member] of Object.entries(schema.properties ?? {})) {
|
|
36
|
+
if (!this.plainString(member)) reject(`ReadModel.Schema.${property}`, 'joins currently require plain string read-model properties; other schemas require a kernel-backed test');
|
|
37
|
+
}
|
|
38
|
+
if ([...from, ...(definition.RemovedWith as RemovedWithRecord[] ?? [])].some(entry => entry.Key.Id === join.Key.Id)) {
|
|
39
|
+
reject(path, 'events shared between Join and From or RemovedWith require a kernel-backed test');
|
|
40
|
+
}
|
|
41
|
+
const joinSchema = requireEventSchema(join.Key, path);
|
|
42
|
+
context.checkProtection(joinSchema, `${path}.EventSchema`);
|
|
43
|
+
if (![AutoMap.Inherit, AutoMap.Enabled, AutoMap.Disabled].includes(join.Value.AutoMap ?? AutoMap.Inherit)) {
|
|
44
|
+
reject(`${path}.AutoMap`, 'unknown join AutoMap settings require a kernel-backed test');
|
|
45
|
+
}
|
|
46
|
+
for (const [index, name] of Object.keys(joinSchema.properties ?? {}).entries()) {
|
|
47
|
+
if (Object.keys(joinSchema.properties ?? {}).slice(index + 1).some(other => other.toLowerCase() === name.toLowerCase())) {
|
|
48
|
+
reject(path, 'case-insensitively colliding join event properties require a kernel-backed test');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const mappings = ProjectionJoins.properties(definition, joinSchema, schema);
|
|
52
|
+
if (!Object.keys(mappings).length) reject(path, 'joins without effective property mappings require a kernel-backed test');
|
|
53
|
+
for (const [property, expression] of Object.entries(mappings)) {
|
|
54
|
+
const mappingPath = `${path}.${Object.hasOwn(join.Value.Properties ?? {}, property) ? 'Properties' : 'AutoMap'}.${property}`;
|
|
55
|
+
if (property === join.Value.On) reject(mappingPath, 'a join cannot map its own join target in scenarios; this requires a kernel-backed test');
|
|
56
|
+
this.checkStringMapping(joinSchema, expression, mappingPath, context);
|
|
57
|
+
checkMapping(schema, joinSchema, property, expression, mappingPath);
|
|
58
|
+
}
|
|
59
|
+
let hasJoinTarget = false;
|
|
60
|
+
for (const entry of from) {
|
|
61
|
+
const fromPath = eventContractPath('From', entry.Key);
|
|
62
|
+
const eventSchema = requireEventSchema(entry.Key, fromPath);
|
|
63
|
+
const properties = { ...entry.Value.Properties };
|
|
64
|
+
if (definition.AutoMap !== AutoMap.Disabled) {
|
|
65
|
+
for (const source of Object.keys(eventSchema.properties ?? {})) {
|
|
66
|
+
const destination = Object.keys(schema.properties ?? {}).find(name => name.toLowerCase() === source.toLowerCase());
|
|
67
|
+
if (destination && !Object.keys(properties).some(name => name.toLowerCase() === destination.toLowerCase()) &&
|
|
68
|
+
!(definition.NoAutoMapProperties ?? []).some(name => name.toLowerCase() === destination.toLowerCase())) properties[destination] = source;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
hasJoinTarget ||= Object.hasOwn(properties, join.Value.On);
|
|
72
|
+
for (const [property, expression] of Object.entries(properties)) {
|
|
73
|
+
const mappingPath = `${fromPath}.${Object.hasOwn(entry.Value.Properties ?? {}, property) ? 'Properties' : 'AutoMap'}.${property}`;
|
|
74
|
+
if (Object.hasOwn(mappings, property)) reject(mappingPath, 'From and Join mapping the same property requires a kernel-backed test');
|
|
75
|
+
this.checkStringMapping(eventSchema, expression, mappingPath, context);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!hasJoinTarget) reject(`${path}.On`, 'joins require a From mapping for the join target; join-only projections require a kernel-backed test');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private static checkStringMapping(eventSchema: JsonSchema, expression: string, path: string, context: ProjectionJoinValidationContext): void {
|
|
82
|
+
if (!/^[A-Za-z_]\w*$/.test(expression) || !this.plainString(eventSchema.properties?.[expression])) {
|
|
83
|
+
context.reject(path, 'joins support direct plain string event-property mappings only; context, constants and other expressions require a kernel-backed test');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private static plainString(schema: JsonSchema | undefined): boolean {
|
|
88
|
+
return schema?.type === 'string' && !schema.format;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -38,6 +38,8 @@ 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
|
+
'joins-custom-key.json': { type: 'UnsupportedProjectionOperation', message: 'join keys other than $eventSourceId' },
|
|
42
|
+
'joins-initial-state.json': { type: 'UnsupportedProjectionOperation', message: 'joins with initial model state' },
|
|
41
43
|
'arithmetic-string-operand.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic operands must have number/double' },
|
|
42
44
|
'arithmetic-nonnumeric-operand.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic operands must have number/double' },
|
|
43
45
|
'arithmetic-uint32-overflow.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic targets must be number/double or integer/int32' },
|
|
@@ -15,6 +15,8 @@ import type { ScenarioEvent } from '../ScenarioEvent.js';
|
|
|
15
15
|
import { ProjectionArithmetic } from './ProjectionArithmetic.js';
|
|
16
16
|
import { ProjectionCapabilities } from './ProjectionCapabilities.js';
|
|
17
17
|
import { ProjectionExpressionEvaluator } from './ProjectionExpressionEvaluator.js';
|
|
18
|
+
import { ProjectionJoins } from './ProjectionJoins.js';
|
|
19
|
+
import type { ProjectionJoinRecord } from './ProjectionJoinRecord.js';
|
|
18
20
|
import { ProjectionValueConverter } from './ProjectionValueConverter.js';
|
|
19
21
|
import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
|
|
20
22
|
|
|
@@ -32,6 +34,8 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
|
|
|
32
34
|
private readonly _initial: Record<string, unknown>;
|
|
33
35
|
private readonly _from: ReadonlyMap<string, FromRecord>;
|
|
34
36
|
private readonly _removed: ReadonlySet<string>;
|
|
37
|
+
private readonly _join: ProjectionJoinRecord | undefined;
|
|
38
|
+
private readonly _joinProperties: Record<string, string>;
|
|
35
39
|
private readonly _children: ReadonlyMap<string, ChildBinding>;
|
|
36
40
|
private readonly _eventSchemas: ReadonlyMap<string, JsonSchema>;
|
|
37
41
|
private readonly _bindings: ReadonlyMap<string, { generation: number; path: string; declaration: string }>;
|
|
@@ -52,6 +56,11 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
|
|
|
52
56
|
const path = eventContractPath(section, entry.Key);
|
|
53
57
|
return [entry.Key.Id, { generation: entry.Key.Generation, path, declaration: declarationFor(path) ?? 'contract' }] as const;
|
|
54
58
|
});
|
|
59
|
+
this._join = _definition.Join?.[0] as ProjectionJoinRecord | undefined;
|
|
60
|
+
if (this._join) {
|
|
61
|
+
const path = eventContractPath('Join', this._join.Key);
|
|
62
|
+
bindings.push([this._join.Key.Id, { generation: this._join.Key.Generation, path, declaration: declarationFor(path) ?? 'contract' }]);
|
|
63
|
+
}
|
|
55
64
|
const children = new Map<string, ChildBinding>();
|
|
56
65
|
for (const [property, child] of Object.entries(wire.Children ?? {})) {
|
|
57
66
|
const items = this._schema.properties![property].items!;
|
|
@@ -68,6 +77,7 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
|
|
|
68
77
|
this._bindings = new Map(bindings);
|
|
69
78
|
this._eventSchemas = new Map([...(compiled.eventSchemas.get(_definition) ?? new Map()).values()]
|
|
70
79
|
.map(entry => [entry.eventType.Id, entry.schema]));
|
|
80
|
+
this._joinProperties = this._join ? ProjectionJoins.properties(_definition, this._eventSchemas.get(this._join.Key.Id)!, this._schema) : {};
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
/** A snapshot of the production-style in-memory sink, for kernel conformance fixtures. */
|
|
@@ -78,17 +88,34 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
|
|
|
78
88
|
async process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>> {
|
|
79
89
|
const states = new Map<string, ReadModelState<TReadModel>>();
|
|
80
90
|
const engine: Record<string, Record<string, unknown>> = Object.create(null);
|
|
91
|
+
// Replay-local event-sequence lookup. Removal deletes a root, never its joined source history.
|
|
92
|
+
const joinedEvents = new Map<string, { event: ScenarioEvent; content: unknown }>();
|
|
81
93
|
for (const event of events) {
|
|
82
94
|
const typeId = event.context.eventType.id.value;
|
|
83
95
|
const from = this._from.get(typeId);
|
|
84
96
|
const removed = this._removed.has(typeId);
|
|
85
97
|
const child = this._children.get(typeId);
|
|
86
|
-
|
|
98
|
+
const joining = this._join?.Key.Id === typeId;
|
|
99
|
+
if (!from && !removed && !child && !joining) continue;
|
|
87
100
|
const binding = this._bindings.get(typeId)!;
|
|
88
101
|
if (event.context.eventType.generation.value !== binding.generation) {
|
|
89
102
|
throw new UnsupportedProjectionOperation(String(this._definition.ReadModel), binding.path, binding.declaration,
|
|
90
103
|
`seeded event generation ${event.context.eventType.generation.value} differs from subscribed generation ${binding.generation}; multi-generation history requires a kernel-backed test`);
|
|
91
104
|
}
|
|
105
|
+
if (joining) {
|
|
106
|
+
const content = ProjectionValueConverter.eventContent(event.content, this._eventSchemas.get(typeId)!);
|
|
107
|
+
joinedEvents.set(event.sourceId, { event, content });
|
|
108
|
+
// SetInitialState skips join events: changes are computed against an empty state.
|
|
109
|
+
// Consequently a missing/null string produces no change, even on a populated row.
|
|
110
|
+
const changes: Record<string, unknown> = Object.create(null);
|
|
111
|
+
this.apply(this._joinProperties, content, event, this._schema, changes);
|
|
112
|
+
for (const [key, state] of Object.entries(engine)) {
|
|
113
|
+
if (state[this._join!.Value.On] !== event.sourceId) continue;
|
|
114
|
+
Object.assign(state, changes);
|
|
115
|
+
states.set(key, { instance: this.materialize(state), deleted: false });
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
92
119
|
const key = this.keyFor(event.sourceId);
|
|
93
120
|
if (removed) {
|
|
94
121
|
delete engine[key];
|
|
@@ -111,6 +138,12 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
|
|
|
111
138
|
const properties = this._definition.AutoMap !== AutoMap.Disabled && !ProjectionArithmetic.suppressesAutoMap(explicit)
|
|
112
139
|
? this.withAutoMap(explicit, schema, this._schema, excluded) : explicit;
|
|
113
140
|
this.apply(properties, content, event, this._schema, state);
|
|
141
|
+
// ResolveJoin is wired only to From clauses that map the join target (including AutoMap).
|
|
142
|
+
if (this._join && Object.hasOwn(properties, this._join.Value.On)) {
|
|
143
|
+
const onValue = state[this._join.Value.On];
|
|
144
|
+
const joined = typeof onValue === 'string' ? joinedEvents.get(onValue) : undefined;
|
|
145
|
+
if (joined) this.apply(this._joinProperties, joined.content, joined.event, this._schema, state);
|
|
146
|
+
}
|
|
114
147
|
}
|
|
115
148
|
if (child) this.applyChild(child, content, event, schema, state, binding);
|
|
116
149
|
// InMemorySink.ApplyChanges always restores lowercase id after applying mappings.
|
|
@@ -73,7 +73,7 @@ describe('when rejecting unsupported children operations before any event is see
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
const cases: Array<{ name: string; change: (child: ChildrenDefinitionLike, wire: Wire) => void; path: string; reason: string }> = [
|
|
76
|
-
{ name: 'a join inside children', change: child => { child.Join = [{}]; }, path: 'Children.labels.Join', reason: 'joins require a kernel-backed test' },
|
|
76
|
+
{ name: 'a join inside children', change: child => { child.Join = [{}]; }, path: 'Children.labels.Join', reason: 'joins inside children require a kernel-backed test' },
|
|
77
77
|
{ name: 'nested children collections', change: child => { child.Children = { inner: child }; }, path: 'Children.labels.Children', reason: 'nested children collections' },
|
|
78
78
|
{ name: 'nested projections inside children', change: child => { child.Nested = { inner: child }; }, path: 'Children.labels.Nested', reason: 'nested projections inside children' },
|
|
79
79
|
{ name: 'removedWithJoin inside children', change: child => { child.RemovedWithJoin = [child.RemovedWithJoin[0] ?? { Key: child.From[0].Key, Value: { Key: '' } }]; }, path: 'Children.labels.RemovedWithJoin', reason: 'removedWithJoin inside children' },
|
package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts
CHANGED
|
@@ -30,7 +30,7 @@ chai.should();
|
|
|
30
30
|
describe('when rejecting unsupported operations before any event is seeded', () => {
|
|
31
31
|
const modelBound = [
|
|
32
32
|
{ name: 'nested', configure: (model: Function) => nested(model.prototype, 'name'), path: 'Nested.name (@nested)', reason: 'nested projections' },
|
|
33
|
-
{ name: 'join', configure: (model: Function) => join(Removed, '
|
|
33
|
+
{ name: 'join on identifier', configure: (model: Function) => join(Removed, 'id')(model.prototype, 'name'), path: 'Join[capability-removed:1].On (@join)', reason: 'direct non-identifier' },
|
|
34
34
|
{ name: 'removedWithJoin', configure: (model: Function) => removedWithJoin(Removed)(model), path: 'RemovedWithJoin[capability-removed:1] (@removedWithJoin)', reason: 'removedWithJoin' },
|
|
35
35
|
{ name: 'fromEvery', configure: (model: Function) => fromEvery('name')(model.prototype, 'name'), path: 'All (@fromEvery)', reason: 'fromEvery/all' },
|
|
36
36
|
{ name: 'fromAll', configure: (model: Function) => fromAll('name')(model.prototype, 'name'), path: 'All (@fromAll)', reason: 'fromEvery/all' },
|
|
@@ -74,7 +74,7 @@ describe('when rejecting unsupported operations before any event is seeded', ()
|
|
|
74
74
|
{ name: 'context parent key', define: builder => { builder.from(Changed, from => from.usingParentKeyFromContext('eventSourceId')); }, path: 'From[capability-changed:1].ParentKey (.from().usingParentKeyFromContext)' },
|
|
75
75
|
{ name: 'composite key', define: builder => { builder.from(Changed, from => from.usingCompositeKey<{ name: string }>(key => key.set(target => target.name, event => event.name))); }, path: 'From[capability-changed:1].Key (.from().usingCompositeKey)' },
|
|
76
76
|
{ name: 'parent key', define: builder => { builder.from(Changed, from => from.usingParentKey(event => event.name)); }, path: 'From[capability-changed:1].ParentKey (.from().usingParentKey)' },
|
|
77
|
-
{ name: 'join', define: builder => { builder.from(Changed).join(Removed, join => join.on(model => model.id)); }, path: 'Join[capability-removed:1] (.join)' },
|
|
77
|
+
{ name: 'join on identifier', define: builder => { builder.from(Changed).join(Removed, join => join.on(model => model.id)); }, path: 'Join[capability-removed:1].On (.join)' },
|
|
78
78
|
{ name: 'nested', define: builder => { builder.from(Changed).nested(model => model.details, child => child.clearWith(Removed)); }, path: 'Nested.details (.nested)' },
|
|
79
79
|
{ name: 'removedWithJoin', define: builder => { builder.from(Changed).removedWithJoin(Removed); }, path: 'RemovedWithJoin[capability-removed:1] (.removedWithJoin)' },
|
|
80
80
|
{ name: 'removal key', define: builder => { builder.from(Removed).removedWith(Changed, removal => removal.usingKey(event => event.name)); }, path: 'RemovedWith[capability-changed:1].Key (.removedWith)' },
|
|
@@ -0,0 +1,118 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
5
|
+
import { AutoMap, ReadModelObserverType, type ProjectionDefinition } from '@cratis/chronicle.contracts';
|
|
6
|
+
import { chai, describe, it } from 'vitest';
|
|
7
|
+
import { EventType } from '../../../events/EventType.js';
|
|
8
|
+
import type { EventContext } from '../../../events/EventContext.js';
|
|
9
|
+
import { buildReadModelDefinition } from '../../../readModels/buildReadModelDefinition.js';
|
|
10
|
+
import type { CompiledProjectionDefinitions } from '../../../projections/CompiledProjectionDefinitions.js';
|
|
11
|
+
import type { JsonSchema } from '../../../schemas/JsonSchema.js';
|
|
12
|
+
import { ProjectionReadModelProcessor } from '../ProjectionReadModelProcessor.js';
|
|
13
|
+
import { UnsupportedProjectionOperation } from '../UnsupportedProjectionOperation.js';
|
|
14
|
+
|
|
15
|
+
chai.should();
|
|
16
|
+
declare global {
|
|
17
|
+
interface Object { should: Chai.Assertion; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fixture() {
|
|
21
|
+
return JSON.parse(readFileSync(new URL('../fixtures/joins-lifecycle.json', import.meta.url), 'utf8')) as {
|
|
22
|
+
wireDefinition: Omit<ProjectionDefinition, 'Join' | 'From' | 'RemovedWith' | 'All'> & {
|
|
23
|
+
All: NonNullable<ProjectionDefinition['All']>;
|
|
24
|
+
Join: { Key: NonNullable<ProjectionDefinition['Join'][number]['Key']>; Value: NonNullable<ProjectionDefinition['Join'][number]['Value']> }[];
|
|
25
|
+
From: { Key: NonNullable<ProjectionDefinition['From'][number]['Key']>; Value: NonNullable<ProjectionDefinition['From'][number]['Value']> }[];
|
|
26
|
+
RemovedWith: { Key: NonNullable<ProjectionDefinition['RemovedWith'][number]['Key']>; Value: NonNullable<ProjectionDefinition['RemovedWith'][number]['Value']> }[];
|
|
27
|
+
};
|
|
28
|
+
readModel: { schema: JsonSchema };
|
|
29
|
+
eventSchemas: { eventType: { Id: string; Generation: number }; schema: JsonSchema }[];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function create(value: ReturnType<typeof fixture>) {
|
|
34
|
+
const definition = value.wireDefinition;
|
|
35
|
+
const compiled: CompiledProjectionDefinitions = {
|
|
36
|
+
definitions: [definition],
|
|
37
|
+
readModels: [buildReadModelDefinition({ identifier: definition.ReadModel, schema: JSON.stringify(value.readModel.schema),
|
|
38
|
+
sinkTypeId: 'test', observerType: ReadModelObserverType.Projection, observerIdentifier: definition.Identifier })],
|
|
39
|
+
provenance: new Map([[definition, [{ contractPath: 'Join[CustomerNamed:1]', declaration: '.join' }]]]),
|
|
40
|
+
eventSchemas: new Map([[definition, new Map(value.eventSchemas.map(entry => [`${entry.eventType.Id}:${entry.eventType.Generation}:0`,
|
|
41
|
+
{ eventType: { ...entry.eventType, Tombstone: false }, schema: entry.schema }]))]])
|
|
42
|
+
};
|
|
43
|
+
return new ProjectionReadModelProcessor(class Order {}, compiled, definition);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('when validating joins before replay', () => {
|
|
47
|
+
it('should accept the captured root join without replaying any events', () => {
|
|
48
|
+
(() => create(fixture())).should.not.throw();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
for (const key of ['name', '$value(customer)', '$eventContext(EventSourceId)', '$context.eventSourceId', '']) {
|
|
52
|
+
it(`should reject the unproven join key ${key}`, () => {
|
|
53
|
+
const value = fixture();
|
|
54
|
+
value.wireDefinition.Join[0].Value.Key = key;
|
|
55
|
+
(() => create(value)).should.throw(UnsupportedProjectionOperation).with.property('message')
|
|
56
|
+
.that.includes('Join[CustomerNamed:1].Key (.join)').and.includes('join keys other than $eventSourceId');
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cases: { name: string; change: (value: ReturnType<typeof fixture>) => void; reason: string }[] = [
|
|
61
|
+
{ name: 'empty joins', change: value => { value.wireDefinition.Join[0].Value.Properties = {}; }, reason: 'joins without effective property mappings' },
|
|
62
|
+
{ name: 'unknown join AutoMap', change: value => { value.wireDefinition.Join[0].Value.AutoMap = 99 as AutoMap; }, reason: 'unknown join AutoMap settings' },
|
|
63
|
+
{ name: 'unmapped protected join fields', change: value => { value.eventSchemas[1].schema.properties!.ignored.security = [{ metadataType: 'EncryptedSubject', details: '' }]; }, reason: 'protected fields' },
|
|
64
|
+
{ name: 'multiple joins', change: value => value.wireDefinition.Join.push(structuredClone(value.wireDefinition.Join[0])), reason: 'multiple join subscriptions' },
|
|
65
|
+
{ name: 'join-only projections', change: value => { value.wireDefinition.From = []; }, reason: 'joins require a From mapping' },
|
|
66
|
+
{ name: 'initial values', change: value => { value.wireDefinition.InitialModelState = '{"title":"seed"}'; }, reason: 'joins with initial model state' },
|
|
67
|
+
{ name: 'identifier joins', change: value => { value.wireDefinition.Join[0].Value.On = 'id'; }, reason: 'direct non-identifier' },
|
|
68
|
+
{ name: 'nested joins', change: value => { value.wireDefinition.Join[0].Value.On = 'customer.id'; }, reason: 'direct non-identifier' },
|
|
69
|
+
{ name: 'unknown join targets', change: value => { value.wireDefinition.Join[0].Value.On = 'missing'; }, reason: 'direct non-identifier' },
|
|
70
|
+
{ name: 'GUID roots', change: value => { value.readModel.schema.properties!.id.format = 'guid'; }, reason: 'plain string read-model properties' },
|
|
71
|
+
{ name: 'numeric targets', change: value => { value.readModel.schema.properties!.customerName = { type: 'number' }; }, reason: 'plain string read-model properties' },
|
|
72
|
+
{ name: 'numeric sources', change: value => { value.eventSchemas[1].schema.properties!.name = { type: 'number' }; }, reason: 'direct plain string event-property mappings' },
|
|
73
|
+
{ name: 'nullable targets', change: value => { value.readModel.schema.properties!.customerName.type = ['string', 'null'] as unknown as JsonSchema['type']; }, reason: 'plain string read-model properties' },
|
|
74
|
+
{ name: 'case-colliding sources', change: value => { value.eventSchemas[1].schema.properties!.Name = { type: 'string' }; }, reason: 'colliding join event properties' },
|
|
75
|
+
{ name: 'protected sources', change: value => { value.eventSchemas[1].schema.properties!.name.security = [{ metadataType: 'EncryptedSubject', details: '' }]; }, reason: 'protected fields' },
|
|
76
|
+
{ name: 'overlapping From and Join events', change: value => { value.wireDefinition.Join[0].Key = value.wireDefinition.From[0].Key; }, reason: 'events shared between Join' },
|
|
77
|
+
{ name: 'overlapping removal and Join events', change: value => { value.wireDefinition.Join[0].Key = value.wireDefinition.RemovedWith[0].Key; }, reason: 'events shared between Join' },
|
|
78
|
+
{ name: 'overlapping target mappings', change: value => { value.wireDefinition.From[0].Value.Properties.customerName = 'title'; }, reason: 'From and Join mapping the same property' },
|
|
79
|
+
{ name: 'overlapping AutoMap targets', change: value => { value.wireDefinition.AutoMap = AutoMap.Enabled; value.eventSchemas[0].schema.properties!.customerName = { type: 'string' }; }, reason: 'From and Join mapping the same property' },
|
|
80
|
+
{ name: 'join target mutation', change: value => { value.wireDefinition.Join[0].Value.Properties.customerId = 'name'; }, reason: 'cannot map its own join target' },
|
|
81
|
+
{ name: 'root arithmetic', change: value => { value.wireDefinition.From[0].Value.Properties.title = '$count'; }, reason: 'joins combined with arithmetic' },
|
|
82
|
+
{ name: 'join arithmetic', change: value => { value.wireDefinition.Join[0].Value.Properties.customerName = '$add(amount)'; }, reason: 'joins combined with arithmetic' },
|
|
83
|
+
{ name: 'removedWithJoin', change: value => { value.wireDefinition.RemovedWithJoin = [{ Key: value.wireDefinition.Join[0].Key, Value: { Key: '$eventSourceId' } }]; }, reason: 'removedWithJoin require' },
|
|
84
|
+
{ name: 'children combined with joins', change: value => { value.wireDefinition.Children = { items: {} as ProjectionDefinition['Children'][string] }; }, reason: 'root joins combined with children' },
|
|
85
|
+
{ name: 'fromEvery combined with joins', change: value => { value.wireDefinition.All.Properties.title = 'title'; }, reason: 'fromEvery/all' },
|
|
86
|
+
{ name: 'constant root keys', change: value => { value.wireDefinition.From[0].Value.Key = '$value(all)'; }, reason: 'only $eventSourceId keys' },
|
|
87
|
+
{ name: 'root parent keys', change: value => { value.wireDefinition.From[0].Value.ParentKey = 'title'; }, reason: 'parent keys require' }
|
|
88
|
+
];
|
|
89
|
+
for (const testCase of cases) {
|
|
90
|
+
it(`should reject ${testCase.name} without history`, () => {
|
|
91
|
+
const value = fixture();
|
|
92
|
+
testCase.change(value);
|
|
93
|
+
(() => create(value)).should.throw(UnsupportedProjectionOperation).with.property('message').that.includes(testCase.reason);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it('should reject a seeded join event from another generation even before a root exists', async () => {
|
|
98
|
+
const processor = create(fixture());
|
|
99
|
+
await processor.process([{ sourceId: 'customer', content: { name: 'unsupported' }, context: {
|
|
100
|
+
eventType: EventType.parse('CustomerNamed+2'), sequenceNumber: 0n, eventSourceId: 'customer'
|
|
101
|
+
} as EventContext }]).then(
|
|
102
|
+
() => { throw new Error('Expected rejection'); },
|
|
103
|
+
error => {
|
|
104
|
+
(error as Error).should.be.instanceOf(UnsupportedProjectionOperation);
|
|
105
|
+
(error as Error).message.should.include('Join[CustomerNamed:1] (.join)').and.include('seeded event generation 2');
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
for (const expression of ['$eventSourceId', '$eventContext(Subject)', '$value(fixed)', '$null', 'nested.name', 'missing']) {
|
|
111
|
+
it(`should reject unproven join mapping ${expression}`, () => {
|
|
112
|
+
const value = fixture();
|
|
113
|
+
value.wireDefinition.Join[0].Value.Properties.customerName = expression;
|
|
114
|
+
(() => create(value)).should.throw(UnsupportedProjectionOperation).with.property('message')
|
|
115
|
+
.that.includes('Join[CustomerNamed:1].Properties.customerName (.join)').and.includes('direct plain string');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|