@cratis/chronicle 6.37.0 → 6.39.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 (74) hide show
  1. package/dist/testing/EventFieldSchema.d.ts +6 -0
  2. package/dist/testing/EventFieldSchema.d.ts.map +1 -0
  3. package/dist/testing/EventFieldSchema.js +78 -0
  4. package/dist/testing/EventFieldSchema.js.map +1 -0
  5. package/dist/testing/EventScenario.d.ts.map +1 -1
  6. package/dist/testing/EventScenario.js +17 -7
  7. package/dist/testing/EventScenario.js.map +1 -1
  8. package/dist/testing/InProcessConstraints.d.ts +3 -1
  9. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  10. package/dist/testing/InProcessConstraints.js +20 -9
  11. package/dist/testing/InProcessConstraints.js.map +1 -1
  12. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  13. package/dist/testing/InProcessEventSequence.js +9 -10
  14. package/dist/testing/InProcessEventSequence.js.map +1 -1
  15. package/dist/testing/ReadModelScenario.js +1 -1
  16. package/dist/testing/ReadModelScenario.js.map +1 -1
  17. package/dist/testing/ScenarioEvent.d.ts +1 -0
  18. package/dist/testing/ScenarioEvent.d.ts.map +1 -1
  19. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  20. package/dist/testing/projections/ProjectionCapabilities.js +9 -2
  21. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  22. package/dist/testing/projections/ProjectionChildrenCapabilities.js +1 -1
  23. package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -1
  24. package/dist/testing/projections/ProjectionJoinRecord.d.ts +9 -0
  25. package/dist/testing/projections/ProjectionJoinRecord.d.ts.map +1 -0
  26. package/dist/testing/projections/ProjectionJoinRecord.js +4 -0
  27. package/dist/testing/projections/ProjectionJoinRecord.js.map +1 -0
  28. package/dist/testing/projections/ProjectionJoinValidationContext.d.ts +12 -0
  29. package/dist/testing/projections/ProjectionJoinValidationContext.d.ts.map +1 -0
  30. package/dist/testing/projections/ProjectionJoinValidationContext.js +4 -0
  31. package/dist/testing/projections/ProjectionJoinValidationContext.js.map +1 -0
  32. package/dist/testing/projections/ProjectionJoins.d.ts +8 -0
  33. package/dist/testing/projections/ProjectionJoins.d.ts.map +1 -0
  34. package/dist/testing/projections/ProjectionJoins.js +29 -0
  35. package/dist/testing/projections/ProjectionJoins.js.map +1 -0
  36. package/dist/testing/projections/ProjectionJoinsCapabilities.d.ts +10 -0
  37. package/dist/testing/projections/ProjectionJoinsCapabilities.d.ts.map +1 -0
  38. package/dist/testing/projections/ProjectionJoinsCapabilities.js +93 -0
  39. package/dist/testing/projections/ProjectionJoinsCapabilities.js.map +1 -0
  40. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +2 -0
  41. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  42. package/dist/testing/projections/ProjectionReadModelProcessor.js +42 -1
  43. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +30 -9
  46. package/package.json +2 -2
  47. package/testing/EventFieldSchema.ts +71 -0
  48. package/testing/EventScenario.constraint-cycles.spec.ts +1 -1
  49. package/testing/EventScenario.constraints.spec.ts +4 -2
  50. package/testing/EventScenario.fixtures.spec.ts +5 -5
  51. package/testing/EventScenario.ts +18 -7
  52. package/testing/InProcessConstraints.ts +19 -9
  53. package/testing/InProcessEventSequence.ts +9 -10
  54. package/testing/ReadModelScenario.ts +1 -1
  55. package/testing/ScenarioEvent.ts +1 -1
  56. package/testing/for_EventScenario/when_appending_field_types/field_scenario_behaviors.fixture.ts +219 -0
  57. package/testing/for_EventScenario/when_appending_field_types/with_invalid_formatted_content.legacy.spec.ts +123 -0
  58. package/testing/for_EventScenario/when_appending_field_types/with_legacy_decorators.legacy.spec.ts +124 -0
  59. package/testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts +123 -0
  60. package/testing/for_EventScenario/when_appending_field_types/with_unproven_schemas.legacy.spec.ts +82 -0
  61. package/testing/for_ReadModelScenario/when_evaluating_joins/with_a_fluent_projection.spec.ts +111 -0
  62. package/testing/for_ReadModelScenario/when_evaluating_joins/with_legacy_decorators.legacy.spec.ts +68 -0
  63. package/testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts +69 -0
  64. package/testing/projections/ProjectionCapabilities.ts +10 -2
  65. package/testing/projections/ProjectionChildrenCapabilities.ts +1 -1
  66. package/testing/projections/ProjectionJoinRecord.ts +10 -0
  67. package/testing/projections/ProjectionJoinValidationContext.ts +15 -0
  68. package/testing/projections/ProjectionJoins.ts +30 -0
  69. package/testing/projections/ProjectionJoinsCapabilities.ts +90 -0
  70. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +2 -0
  71. package/testing/projections/ProjectionReadModelProcessor.ts +44 -1
  72. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +1 -1
  73. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +2 -2
  74. package/testing/projections/for_ProjectionCapabilities/when_validating_joins.spec.ts +118 -0
@@ -0,0 +1,123 @@
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 { ConceptAs, field, Guid } from '@cratis/fundamentals';
5
+ import { describe } from 'vitest';
6
+ import { eventType, getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
7
+ import { unique } from '../../../events/constraints/unique.js';
8
+ import { constraint } from '../../../events/constraints/constraint.js';
9
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
10
+ import { fromEvent } from '../../../projections/modelBound/fromEvent.js';
11
+ import { setFrom } from '../../../projections/modelBound/setFrom.js';
12
+ import { reactor } from '../../../reactors/reactor.js';
13
+ import { reducer } from '../../../reducers/reducer.js';
14
+ import { fieldScenarioBehaviors } from './field_scenario_behaviors.fixture.js';
15
+
16
+ class Identifier extends ConceptAs<Guid> { static readonly valueType = Guid; }
17
+ class Amount extends ConceptAs<number> { static readonly valueType = Number; }
18
+ class Occurred extends ConceptAs<Date> { static readonly valueType = Date; }
19
+
20
+ const identifier = () => Guid.parse('abcdef01-abcd-abcd-abcd-abcdef012345');
21
+ const occurred = () => new Date('2025-01-02T03:04:05.123Z');
22
+
23
+ class Payload {
24
+ @field(Number) amount = 1.5;
25
+ @field(Guid) identifier = identifier();
26
+ @field(Date) occurred = occurred();
27
+ }
28
+
29
+ @eventType('FieldTypesRecorded')
30
+ class FieldsRecorded {
31
+ @field(Number) amount = 1.5;
32
+ @field(Guid) identifier = identifier();
33
+ @field(Date) occurred = occurred();
34
+ @field(Amount) amountConcept = new Amount(1e-7);
35
+ @field(Identifier) identifierConcept = new Identifier(identifier());
36
+ @field(Occurred) occurredConcept = new Occurred(occurred());
37
+ @field(Payload) payload = new Payload();
38
+ @field(Object) arbitrary = { nested: { number: 9007199254740992 }, values: [true, 1.5] };
39
+ }
40
+
41
+ @reactor('FieldTypesReactor')
42
+ class Echo {
43
+ fieldsRecorded(event: FieldsRecorded) { return Object.assign(new FieldsRecorded(), event); }
44
+ }
45
+
46
+ class StoredFields {}
47
+ class StoreFields {
48
+ fieldsRecorded(event: FieldsRecorded) { return event; }
49
+ }
50
+ reducer('FieldTypesReducer', undefined, StoredFields)(StoreFields);
51
+
52
+ @fromEvent(FieldsRecorded)
53
+ class ScalarProjection {
54
+ @field(String) id = '';
55
+ @field(Number) amount = 0;
56
+ @field(Guid) identifier = Guid.empty;
57
+ @field(Date) occurred = new Date(0);
58
+ }
59
+
60
+ @fromEvent(FieldsRecorded)
61
+ class ObjectProjection {
62
+ @field(String) id = '';
63
+ @field(Object) payload = {};
64
+ }
65
+
66
+ @fromEvent(FieldsRecorded)
67
+ class HashProjection {
68
+ @field(String) id = '';
69
+ @field(String) @setFrom(FieldsRecorded, '$eventContext(Hash)') hash = '';
70
+ }
71
+
72
+ @eventType('OracleFieldGuid')
73
+ class GuidClaimed {
74
+ @field(Guid) key = identifier();
75
+ }
76
+ @eventType('OracleFieldNumber')
77
+ class NumberClaimed {
78
+ @field(Number) key = 1;
79
+ }
80
+ @eventType('OracleFieldDouble')
81
+ class DoubleClaimed {
82
+ @field(Number) key = 1;
83
+ }
84
+ getEventTypeMetadata(DoubleClaimed)!.schema.properties!.key.format = 'double';
85
+ @constraint('OracleFieldKey')
86
+ class DoubleKey {
87
+ define(builder: IConstraintBuilder) { builder.unique(key => key.on(DoubleClaimed, event => event.key)); }
88
+ }
89
+ @constraint('OracleFieldKey')
90
+ class GuidKey {
91
+ define(builder: IConstraintBuilder) { builder.unique(key => key.on(GuidClaimed, event => event.key)); }
92
+ }
93
+ @constraint('OracleFieldKey')
94
+ class NumberKey {
95
+ define(builder: IConstraintBuilder) { builder.unique(key => key.on(NumberClaimed, event => event.key)); }
96
+ }
97
+ @eventType('ConceptGuidClaimed')
98
+ class ConceptGuidClaimed {
99
+ @field(Identifier) @unique('ConceptGuidKey') key = new Identifier(identifier());
100
+ }
101
+ @eventType('ConceptNumberClaimed')
102
+ class ConceptNumberClaimed {
103
+ @field(Amount) @unique('ConceptNumberKey') key = new Amount(42);
104
+ }
105
+ @eventType('DateClaimed')
106
+ class DateClaimed {
107
+ @field(Date) @unique('DateKey') key = occurred();
108
+ }
109
+ @eventType('ConceptDateClaimed')
110
+ class ConceptDateClaimed {
111
+ @field(Occurred) @unique('ConceptDateKey') key = new Occurred(occurred());
112
+ }
113
+ @eventType('ObjectClaimed')
114
+ class ObjectClaimed {
115
+ @field(Object) @unique('ObjectKey') key = { amount: 1.5 };
116
+ }
117
+
118
+ describe('when appending field types with standard decorators', () => {
119
+ fieldScenarioBehaviors({ recorded: FieldsRecorded, reactor: Echo, model: StoredFields, reducer: StoreFields,
120
+ projection: ScalarProjection, objectProjection: ObjectProjection, hashProjection: HashProjection,
121
+ guid: GuidClaimed, number: NumberClaimed, guidConstraint: GuidKey, numberConstraint: NumberKey, guidConcept: ConceptGuidClaimed, numberConcept: ConceptNumberClaimed,
122
+ double: DoubleClaimed, doubleConstraint: DoubleKey, rejected: [DateClaimed, ConceptDateClaimed, ObjectClaimed] });
123
+ });
@@ -0,0 +1,82 @@
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, Guid } from '@cratis/fundamentals';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { eventType, getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
8
+ import { unique } from '../../../events/constraints/unique.js';
9
+ import { constraint } from '../../../events/constraints/constraint.js';
10
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import type { JsonSchema } from '../../../schemas/JsonSchema.js';
12
+ import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
13
+
14
+ chai.should();
15
+
16
+ for (const schema of [
17
+ { type: 'string', format: 'date' }, { type: 'string', format: 'date-time' },
18
+ { type: 'object' }, { type: 'integer', format: 'int64' }, { type: 'number', format: 'decimal' }
19
+ ] satisfies JsonSchema[]) {
20
+ describe(`when registering a ${schema.type}/${schema.format ?? 'unformatted'} constrained schema`, () => {
21
+ it('should reject the constraint without rejecting an unconstrained field', async () => {
22
+ class Recorded { @field(Object) @unique(`Unsupported${schema.type}${schema.format ?? ''}`) key: unknown = {}; }
23
+ eventType(`Unsupported${schema.type}${schema.format ?? ''}`)(Recorded);
24
+ getEventTypeMetadata(Recorded)!.schema.properties!.key = schema;
25
+ (() => new EventScenario({ artifacts: { eventTypes: [Recorded] } })).should.throw(UnsupportedEventSequenceOperation, 'artifacts.constraints');
26
+ const scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
27
+ const event = new Recorded();
28
+ event.key = schema.type === 'string' ? '2025-01-02' : schema.type === 'object' ? { value: 42 } : 1.5;
29
+ if (schema.format === 'date-time') event.key = '2025-01-02T03:04:05.000Z';
30
+ if (schema.type === 'integer') event.key = 42;
31
+ (await scenario.append('A', event)).isSuccess.should.equal(true);
32
+ });
33
+ });
34
+ }
35
+
36
+ for (const mode of ['composite', 'scoped', 'ignoreCasing']) {
37
+ describe(`when registering a ${mode} Guid key without fixture evidence`, () => {
38
+ it('should reject instead of inheriting plain-string constraint support', () => {
39
+ class Recorded { @field(Guid) key = Guid.empty; @field(String) label = ''; }
40
+ eventType(`UnprovenGuid${mode}`)(Recorded);
41
+ class Definition {
42
+ define(builder: IConstraintBuilder) {
43
+ if (mode === 'scoped') builder.perEventSourceType();
44
+ builder.unique(key => {
45
+ if (mode === 'composite') key.on(Recorded, event => event.key, event => event.label);
46
+ else key.on(Recorded, event => event.key);
47
+ if (mode === 'ignoreCasing') key.ignoreCasing();
48
+ });
49
+ }
50
+ }
51
+ constraint(`UnprovenGuid${mode}`)(Definition);
52
+ (() => new EventScenario({ artifacts: { eventTypes: [Recorded], constraints: [Definition] } }))
53
+ .should.throw(UnsupportedEventSequenceOperation, 'artifacts.constraints');
54
+ });
55
+ });
56
+ }
57
+
58
+ for (const protection of ['compliance', 'security'] as const) {
59
+ describe(`when registering nested ${protection} metadata`, () => {
60
+ it('should reject before serializing an object payload', () => {
61
+ class Recorded { @field(Object) payload = {}; }
62
+ eventType(`Nested${protection}`)(Recorded);
63
+ getEventTypeMetadata(Recorded)!.schema.properties!.payload = {
64
+ type: 'object', properties: { nested: { type: 'string', [protection]: [{ metadataType: 'protected', details: '' }] } }
65
+ };
66
+ (() => new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' }))
67
+ .should.throw(UnsupportedEventSequenceOperation, 'artifacts.eventTypes.schema');
68
+ });
69
+ });
70
+ }
71
+
72
+ describe('when registering a uuid constraint schema alias', () => {
73
+ it('should reject unproven constraint semantics but accept a valid unconstrained payload', async () => {
74
+ class Recorded { @field(Guid) @unique('UuidAlias') key: Guid | string = Guid.empty; }
75
+ eventType('UuidAlias')(Recorded);
76
+ getEventTypeMetadata(Recorded)!.schema.properties!.key.format = 'uuid';
77
+ (() => new EventScenario({ artifacts: { eventTypes: [Recorded] } }))
78
+ .should.throw(UnsupportedEventSequenceOperation, 'artifacts.constraints');
79
+ const scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
80
+ (await scenario.append('A', new Recorded())).isSuccess.should.equal(true);
81
+ });
82
+ });
@@ -0,0 +1,111 @@
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 { EventScenario } from '../../EventScenario.js';
10
+ import { ReadModelScenario } from '../../ReadModelScenario.js';
11
+
12
+ chai.should();
13
+
14
+ class OrderPlaced {
15
+ constructor(readonly customerId: string) {}
16
+ }
17
+ field(String)(OrderPlaced.prototype, 'customerId');
18
+ eventType('fluent-join-order-placed')(OrderPlaced);
19
+ class CustomerNamed {
20
+ constructor(readonly name: string) {}
21
+ }
22
+ field(String)(CustomerNamed.prototype, 'name');
23
+ eventType('fluent-join-customer-named')(CustomerNamed);
24
+ class OrderRemoved {}
25
+ eventType('fluent-join-order-removed')(OrderRemoved);
26
+ class Order {
27
+ id = '';
28
+ customerId = '';
29
+ customerName = '';
30
+ }
31
+ for (const property of ['id', 'customerId', 'customerName']) field(String)(Order.prototype, property);
32
+ class OrderProjection {
33
+ define(builder: IProjectionBuilderFor<Order>): void {
34
+ builder.noAutoMap()
35
+ .from(OrderPlaced, from => from.set(model => model.customerId).to(event => event.customerId))
36
+ .join(CustomerNamed, join => join.on(model => model.customerId).set(model => model.customerName).to(event => event.name))
37
+ .removedWith(OrderRemoved);
38
+ }
39
+ }
40
+ projection('fluent-join-order', Order)(OrderProjection);
41
+ const artifacts = { eventTypes: [OrderPlaced, CustomerNamed, OrderRemoved], projections: [OrderProjection], reducers: [] };
42
+
43
+ // Every ordering/lifecycle below is also captured from the packaged engine in joins-lifecycle.json.
44
+ describe('when evaluating a fluent root join', () => {
45
+ it('should store joined history without materializing a phantom row and backfill the latest prior event', async () => {
46
+ const scenario = new ReadModelScenario(Order, artifacts);
47
+ scenario.given.forEventSource('customer').events(new CustomerNamed('first'), new CustomerNamed('latest'));
48
+ (await scenario.instance === null).should.be.true;
49
+ (await scenario.wasDeletedForEventSourceId('customer')).should.be.false;
50
+ scenario.given.forEventSource('A').events(new OrderPlaced('customer'));
51
+ (await scenario.instance)!.customerName.should.equal('latest');
52
+ (await scenario.instanceForEventSourceId('customer') === null).should.be.true;
53
+ });
54
+
55
+ it('should update every referencing root and leave other customers unchanged', async () => {
56
+ const scenario = new ReadModelScenario(Order, artifacts);
57
+ scenario.given.forEventSource('A').events(new OrderPlaced('customer'));
58
+ scenario.given.forEventSource('B').events(new OrderPlaced('customer'));
59
+ scenario.given.forEventSource('C').events(new OrderPlaced('other'));
60
+ (await scenario.instanceForEventSourceId('A'))!.customerId.should.equal('customer');
61
+ scenario.given.forEventSource('customer').events(new CustomerNamed('name'));
62
+ (await scenario.instanceForEventSourceId('A'))!.customerName.should.equal('name');
63
+ (await scenario.instanceForEventSourceId('B'))!.customerName.should.equal('name');
64
+ ((await scenario.instanceForEventSourceId('C'))!.customerName === undefined).should.be.true;
65
+ });
66
+
67
+ it('should retain joined history across removal without resurrecting the removed root', async () => {
68
+ const scenario = new ReadModelScenario(Order, artifacts);
69
+ scenario.given.forEventSource('A').events(new OrderPlaced('customer'), new OrderRemoved());
70
+ scenario.given.forEventSource('customer').events(new CustomerNamed('after removal'));
71
+ (await scenario.instance === null).should.be.true;
72
+ (await scenario.wasDeletedForEventSourceId('A')).should.be.true;
73
+ scenario.given.forEventSource('A').events(new OrderPlaced('customer'));
74
+ (await scenario.instance)!.customerName.should.equal('after removal');
75
+ (await scenario.wasDeletedForEventSourceId('A')).should.be.false;
76
+ });
77
+
78
+ it('should resolve a changed foreign key against its own latest history', async () => {
79
+ const scenario = new ReadModelScenario(Order, artifacts);
80
+ scenario.given.forEventSource('one').events(new CustomerNamed('first'));
81
+ scenario.given.forEventSource('two').events(new CustomerNamed('second'));
82
+ scenario.given.forEventSource('A').events(new OrderPlaced('one'));
83
+ (await scenario.instance)!.customerName.should.equal('first');
84
+ scenario.given.forEventSource('A').events(new OrderPlaced('two'));
85
+ scenario.given.forEventSource('one').events(new CustomerNamed('no longer joined'));
86
+ (await scenario.instance)!.customerName.should.equal('second');
87
+ });
88
+
89
+ it('should rebuild join history per replay and never share it across scenarios', async () => {
90
+ const first = new ReadModelScenario(Order, artifacts);
91
+ first.given.forEventSource('customer').events(new CustomerNamed('first'));
92
+ first.given.forEventSource('A').events(new OrderPlaced('customer'));
93
+ (await first.instance)!.customerName.should.equal('first');
94
+ first.given.forEventSource('customer').events(new CustomerNamed('second'));
95
+ (await first.instance)!.customerName.should.equal('second');
96
+ const second = new ReadModelScenario(Order, artifacts);
97
+ second.given.forEventSource('A').events(new OrderPlaced('customer'));
98
+ ((await second.instance)!.customerName === undefined).should.be.true;
99
+ });
100
+
101
+ it('should update the root keys when observing a committed event sequence', async () => {
102
+ const events = new EventScenario({ artifacts: { eventTypes: [OrderPlaced, CustomerNamed] }, constraints: 'disabled' });
103
+ const scenario = new ReadModelScenario(Order, artifacts).observe(events);
104
+ await events.append('A', new OrderPlaced('customer'));
105
+ await events.append('B', new OrderPlaced('customer'));
106
+ await events.append('customer', new CustomerNamed('joined'));
107
+ (await scenario.instanceForEventSourceId('A'))!.customerName.should.equal('joined');
108
+ (await scenario.instanceForEventSourceId('B'))!.customerName.should.equal('joined');
109
+ (await scenario.instanceForEventSourceId('customer') === null).should.be.true;
110
+ });
111
+ });
@@ -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 ['Join', 'Nested', 'RemovedWithJoin'] as const) {
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' : section === 'Join' ? 'joins' : 'removedWithJoin'} require a kernel-backed test`);
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
+ }