@cratis/chronicle 6.31.3 → 6.32.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 (34) hide show
  1. package/dist/testing/ConstraintScope.d.ts +9 -0
  2. package/dist/testing/ConstraintScope.d.ts.map +1 -0
  3. package/dist/testing/ConstraintScope.js +36 -0
  4. package/dist/testing/ConstraintScope.js.map +1 -0
  5. package/dist/testing/EventScenario.d.ts.map +1 -1
  6. package/dist/testing/EventScenario.js +10 -0
  7. package/dist/testing/EventScenario.js.map +1 -1
  8. package/dist/testing/InProcessConstraints.d.ts +1 -1
  9. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  10. package/dist/testing/InProcessConstraints.js +23 -13
  11. package/dist/testing/InProcessConstraints.js.map +1 -1
  12. package/dist/testing/InProcessEventSequence.d.ts +1 -0
  13. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  14. package/dist/testing/InProcessEventSequence.js +33 -10
  15. package/dist/testing/InProcessEventSequence.js.map +1 -1
  16. package/dist/tsconfig.tsbuildinfo +1 -1
  17. package/package.json +2 -2
  18. package/testing/ConstraintScope.ts +32 -0
  19. package/testing/EventScenario.batches.spec.ts +2 -5
  20. package/testing/EventScenario.constraints.spec.ts +1 -4
  21. package/testing/EventScenario.fixtures.spec.ts +1 -1
  22. package/testing/EventScenario.ts +12 -0
  23. package/testing/InProcessConstraints.ts +29 -16
  24. package/testing/InProcessEventSequence.ts +35 -10
  25. package/testing/for_EventScenario/when_appending_concept_fields/concept_scenario_behaviors.fixture.ts +178 -0
  26. package/testing/for_EventScenario/when_appending_concept_fields/with_legacy_decorators.legacy.spec.ts +74 -0
  27. package/testing/for_EventScenario/when_appending_concept_fields/with_standard_decorators.standard.spec.ts +74 -0
  28. package/testing/for_EventScenario/when_appending_scoped_constraints/scope_scenario_behaviors.fixture.ts +139 -0
  29. package/testing/for_EventScenario/when_appending_scoped_constraints/with_legacy_decorators.legacy.spec.ts +57 -0
  30. package/testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts +56 -0
  31. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_cycles.spec.ts +48 -0
  32. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_operations.spec.ts +119 -0
  33. package/testing/for_ReactorScenario/when_enforcing_scoped_constraints/with_default_input_routes.spec.ts +70 -0
  34. package/testing/for_ReactorScenario/when_enforcing_scoped_constraints/with_routed_service_appends.spec.ts +65 -0
@@ -0,0 +1,178 @@
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, type Constructor } from '@cratis/fundamentals';
5
+ import { beforeEach, chai, describe, it, vi, type Assertion } from 'vitest';
6
+ import type { ChronicleConnection } from '../../../connection/index.js';
7
+ import { EventSequence } from '../../../eventSequences/EventSequence.js';
8
+ import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
9
+ import type { AppendResult } from '../../../eventSequences/AppendResult.js';
10
+ import type { IUnitOfWorkManager } from '../../../transactions/IUnitOfWorkManager.js';
11
+ import { EventScenario, ReactorScenario, ReadModelScenario, UnsupportedEventSequenceOperation } from '../../index.js';
12
+
13
+ chai.should();
14
+
15
+ // Keep fluent should assertions typed without adding global Object declarations.
16
+ function should(value: unknown): Assertion {
17
+ return (value as { should: Assertion }).should;
18
+ }
19
+
20
+ class InvalidKey extends ConceptAs<unknown> {}
21
+
22
+ export function conceptScenarioBehaviors(fixtures: {
23
+ eventTypes: Constructor[];
24
+ constraints: Constructor[];
25
+ registered: (name: string) => object;
26
+ flag: (value: boolean) => object;
27
+ composite: (first: string, last: string) => object;
28
+ model: Constructor<{ id: string; name: string }>;
29
+ reactor: Constructor;
30
+ }): void {
31
+ const artifacts = { eventTypes: fixtures.eventTypes, constraints: fixtures.constraints, reducers: [], projections: [] };
32
+ let scenario: EventScenario;
33
+ beforeEach(() => { scenario = new EventScenario({ artifacts }); });
34
+
35
+ describe('when appending a concept-valued unique property', () => {
36
+ let result: AppendResult;
37
+ let connectedContent: string;
38
+ beforeEach(async () => {
39
+ const append = vi.fn().mockResolvedValue({ Response: { SequenceNumber: 0n, ConstraintViolations: [], Errors: [] } });
40
+ const connected = new EventSequence(EventSequenceId.eventLog, 'store', 'Default',
41
+ { eventSequences: { append } } as unknown as ChronicleConnection, {} as IUnitOfWorkManager);
42
+ const event = fixtures.registered('Ada');
43
+ await connected.append('author-1', event);
44
+ connectedContent = append.mock.calls[0][0].Content as string;
45
+ result = await scenario.append('author-1', event);
46
+ });
47
+ it('should accept the same event as the connected client', () => { should(result.isSuccess).be.true; });
48
+ it('should store the same primitive content as the connected client', () => {
49
+ should(scenario.appendedEvents[0].content).deep.equal(JSON.parse(connectedContent));
50
+ should(scenario.appendedEvents[0].content).deep.equal({ name: 'Ada' });
51
+ });
52
+ });
53
+
54
+ describe('when appending an empty string concept key', () => {
55
+ let result: AppendResult;
56
+ beforeEach(async () => { result = await scenario.append('author-1', fixtures.registered('')); });
57
+ it('should preserve the empty primitive value', () => {
58
+ should(result.isSuccess).be.true;
59
+ should(scenario.appendedEvents[0].content).deep.equal({ name: '' });
60
+ });
61
+ });
62
+
63
+ for (const value of [new InvalidKey(42), new InvalidKey('É'), new Date('2025-01-02T00:00:00.000Z')]) {
64
+ describe(`when a constrained string field contains an unsupported ${value.constructor.name} value`, () => {
65
+ let error: unknown;
66
+ beforeEach(async () => {
67
+ const event = fixtures.registered('Ada');
68
+ Object.assign(event, { name: value });
69
+ error = await scenario.append('author-1', event).then(() => undefined, reason => reason);
70
+ });
71
+ it('should reject before changing scenario state', () => {
72
+ should(error instanceof UnsupportedEventSequenceOperation).be.true;
73
+ should(scenario.appendedEvents).have.lengthOf(0);
74
+ should(scenario.results).have.lengthOf(0);
75
+ });
76
+ });
77
+ }
78
+
79
+ describe('when claiming a seeded concept key from another source', () => {
80
+ let result: AppendResult;
81
+ beforeEach(async () => {
82
+ await scenario.given.forEventSource('author-1').events(fixtures.registered('Ada'));
83
+ result = await scenario.append('author-2', fixtures.registered('Ada'));
84
+ });
85
+ it('should reject the duplicate primitive key', () => {
86
+ should(result.isSuccess).be.false;
87
+ should(result.constraintViolations[0].details).deep.equal({ PropertyName: 'name', PropertyValue: 'Ada' });
88
+ });
89
+ it('should preserve the seeded history', () => { should(scenario.appendedEvents).have.lengthOf(1); });
90
+ });
91
+
92
+ for (const mixed of [false, true]) {
93
+ describe(`when appending concept fields in a ${mixed ? 'mixed-source' : 'single-source'} batch`, () => {
94
+ let results: AppendResult[];
95
+ beforeEach(async () => {
96
+ const events = [fixtures.registered('Ada'), fixtures.registered('Grace')];
97
+ results = mixed
98
+ ? await scenario.appendMany(events.map((event, index) => ({ eventSourceId: `author-${index}`, event })))
99
+ : await scenario.appendMany('author-1', events);
100
+ });
101
+ it('should accept both serialized concept values', () => {
102
+ should(results.map(result => result.isSuccess)).deep.equal([true, true]);
103
+ should(scenario.appendedEvents.map(event => event.content)).deep.equal([{ name: 'Ada' }, { name: 'Grace' }]);
104
+ });
105
+ });
106
+ }
107
+
108
+ describe('when a batch claims the same concept key twice', () => {
109
+ let results: AppendResult[];
110
+ beforeEach(async () => {
111
+ results = await scenario.appendMany([
112
+ { eventSourceId: 'author-1', event: fixtures.registered('Ada') },
113
+ { eventSourceId: 'author-2', event: fixtures.registered('Ada') }
114
+ ]);
115
+ });
116
+ it('should reject the entire batch without committing events', () => {
117
+ should(results.map(result => result.isSuccess)).deep.equal([false, false]);
118
+ should(scenario.appendedEvents).have.lengthOf(0);
119
+ });
120
+ });
121
+
122
+ for (const value of [false, true]) {
123
+ describe(`when claiming the boolean concept key ${value} twice`, () => {
124
+ let accepted: AppendResult;
125
+ let duplicate: AppendResult;
126
+ beforeEach(async () => {
127
+ accepted = await scenario.append('flag-1', fixtures.flag(value));
128
+ duplicate = await scenario.append('flag-2', fixtures.flag(value));
129
+ });
130
+ it('should accept and serialize the boolean concept', () => {
131
+ should(accepted.isSuccess).be.true;
132
+ should(scenario.appendedEvents[0].content).deep.equal({ active: value });
133
+ });
134
+ it('should report the duplicate boolean key', () => {
135
+ should(duplicate.isSuccess).be.false;
136
+ should(duplicate.constraintViolations[0].details).deep.equal({ PropertyName: 'active', PropertyValue: value ? 'True' : 'False' });
137
+ });
138
+ });
139
+ }
140
+
141
+ describe('when a composite concept key differs only in casing', () => {
142
+ let duplicate: AppendResult;
143
+ beforeEach(async () => {
144
+ await scenario.append('name-1', fixtures.composite('Ada', 'Lovelace'));
145
+ duplicate = await scenario.append('name-2', fixtures.composite('ADA', 'LOVELACE'));
146
+ });
147
+ it('should compare serialized components and retain original values in violations', () => {
148
+ should(duplicate.isSuccess).be.false;
149
+ should(duplicate.constraintViolations.map(violation => violation.details.PropertyValue)).deep.equal(['ADA', 'LOVELACE']);
150
+ });
151
+ });
152
+
153
+ describe('when observing accepted concept fields in a read model', () => {
154
+ let name: string;
155
+ beforeEach(async () => {
156
+ const models = new ReadModelScenario(fixtures.model, artifacts).observe(scenario);
157
+ await scenario.append('author-1', fixtures.registered('Ada'));
158
+ name = (await models.instanceForEventSourceId('author-1'))!.name;
159
+ });
160
+ it('should project the serialized primitive', () => { should(name).equal('Ada'); });
161
+ });
162
+
163
+ describe('when delivering concept fields through a reactor and observing its explicit append', () => {
164
+ let reactorScenario: ReactorScenario;
165
+ let name: string;
166
+ beforeEach(async () => {
167
+ reactorScenario = new ReactorScenario(fixtures.reactor, { artifacts });
168
+ const models = new ReadModelScenario(fixtures.model, artifacts).observe(reactorScenario);
169
+ await reactorScenario.when.forEventSource('author-1').events(fixtures.registered('Ada'));
170
+ name = (await models.instanceForEventSourceId('author-1'))!.name;
171
+ });
172
+ it('should complete the delivery and explicit concept append', () => {
173
+ should(reactorScenario.results[0].completed).be.true;
174
+ should(reactorScenario.appendedEvents.map(event => event.content)).deep.equal([{ name: 'Ada' }, { name: 'Ada' }]);
175
+ });
176
+ it('should expose the serialized primitive to the observing read model', () => { should(name).equal('Ada'); });
177
+ });
178
+ }
@@ -0,0 +1,74 @@
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 } from '@cratis/fundamentals';
5
+ import { describe } from 'vitest';
6
+ import { eventType } 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 { reactor } from '../../../reactors/reactor.js';
12
+ import type { ReactorServices } from '../../../reactors/ReactorServices.js';
13
+ import { conceptScenarioBehaviors } from './concept_scenario_behaviors.fixture.js';
14
+
15
+ class AuthorName extends ConceptAs<string> { static readonly valueType = String; }
16
+ class Active extends ConceptAs<boolean> { static readonly valueType = Boolean; }
17
+
18
+ @eventType('concept-legacy-registered')
19
+ class AuthorRegistered {
20
+ @field(AuthorName) @unique('concept-legacy-name') name: AuthorName;
21
+ constructor(name: string) { this.name = new AuthorName(name); }
22
+ }
23
+
24
+ @eventType('concept-legacy-flag')
25
+ class FlagRecorded {
26
+ @field(Active) @unique('concept-legacy-active') active: Active;
27
+ constructor(active: boolean) { this.active = new Active(active); }
28
+ }
29
+
30
+ @eventType('concept-legacy-composite')
31
+ class FullNameRegistered {
32
+ @field(AuthorName) first: AuthorName;
33
+ @field(AuthorName) last: AuthorName;
34
+ constructor(first: string, last: string) { this.first = new AuthorName(first); this.last = new AuthorName(last); }
35
+ }
36
+
37
+ @constraint('concept-legacy-full-name')
38
+ class FullName {
39
+ define(builder: IConstraintBuilder) {
40
+ builder.unique(key => key.on(FullNameRegistered, event => event.first, event => event.last).ignoreCasing());
41
+ }
42
+ }
43
+
44
+ @eventType('concept-legacy-welcomed')
45
+ class AuthorWelcomed {
46
+ @field(AuthorName) @unique('concept-legacy-welcome') name: AuthorName;
47
+ constructor(name: string) { this.name = new AuthorName(name); }
48
+ }
49
+
50
+ @fromEvent(AuthorWelcomed)
51
+ @fromEvent(AuthorRegistered)
52
+ class Author {
53
+ @field(String) id = '';
54
+ @field(String) name = '';
55
+ }
56
+
57
+ @reactor('concept-legacy-welcomer')
58
+ class Welcomer {
59
+ async authorRegistered(event: AuthorRegistered, context: { eventSourceId: string }, services: ReactorServices) {
60
+ await services.eventStore.eventLog.append(context.eventSourceId, new AuthorWelcomed(String(event.name)));
61
+ }
62
+ }
63
+
64
+ describe('when appending concept fields with legacy decorators', () => {
65
+ conceptScenarioBehaviors({
66
+ eventTypes: [AuthorRegistered, FlagRecorded, FullNameRegistered, AuthorWelcomed],
67
+ constraints: [FullName],
68
+ registered: name => new AuthorRegistered(name),
69
+ flag: value => new FlagRecorded(value),
70
+ composite: (first, last) => new FullNameRegistered(first, last),
71
+ model: Author,
72
+ reactor: Welcomer
73
+ });
74
+ });
@@ -0,0 +1,74 @@
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 } from '@cratis/fundamentals';
5
+ import { describe } from 'vitest';
6
+ import { eventType } 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 { reactor } from '../../../reactors/reactor.js';
12
+ import type { ReactorServices } from '../../../reactors/ReactorServices.js';
13
+ import { conceptScenarioBehaviors } from './concept_scenario_behaviors.fixture.js';
14
+
15
+ class AuthorName extends ConceptAs<string> { static readonly valueType = String; }
16
+ class Active extends ConceptAs<boolean> { static readonly valueType = Boolean; }
17
+
18
+ @eventType('concept-standard-registered')
19
+ class AuthorRegistered {
20
+ @field(AuthorName) @unique('concept-standard-name') name: AuthorName;
21
+ constructor(name: string) { this.name = new AuthorName(name); }
22
+ }
23
+
24
+ @eventType('concept-standard-flag')
25
+ class FlagRecorded {
26
+ @field(Active) @unique('concept-standard-active') active: Active;
27
+ constructor(active: boolean) { this.active = new Active(active); }
28
+ }
29
+
30
+ @eventType('concept-standard-composite')
31
+ class FullNameRegistered {
32
+ @field(AuthorName) first: AuthorName;
33
+ @field(AuthorName) last: AuthorName;
34
+ constructor(first: string, last: string) { this.first = new AuthorName(first); this.last = new AuthorName(last); }
35
+ }
36
+
37
+ @constraint('concept-standard-full-name')
38
+ class FullName {
39
+ define(builder: IConstraintBuilder) {
40
+ builder.unique(key => key.on(FullNameRegistered, event => event.first, event => event.last).ignoreCasing());
41
+ }
42
+ }
43
+
44
+ @eventType('concept-standard-welcomed')
45
+ class AuthorWelcomed {
46
+ @field(AuthorName) @unique('concept-standard-welcome') name: AuthorName;
47
+ constructor(name: string) { this.name = new AuthorName(name); }
48
+ }
49
+
50
+ @fromEvent(AuthorWelcomed)
51
+ @fromEvent(AuthorRegistered)
52
+ class Author {
53
+ @field(String) id = '';
54
+ @field(String) name = '';
55
+ }
56
+
57
+ @reactor('concept-standard-welcomer')
58
+ class Welcomer {
59
+ async authorRegistered(event: AuthorRegistered, context: { eventSourceId: string }, services: ReactorServices) {
60
+ await services.eventStore.eventLog.append(context.eventSourceId, new AuthorWelcomed(String(event.name)));
61
+ }
62
+ }
63
+
64
+ describe('when appending concept fields with standard decorators', () => {
65
+ conceptScenarioBehaviors({
66
+ eventTypes: [AuthorRegistered, FlagRecorded, FullNameRegistered, AuthorWelcomed],
67
+ constraints: [FullName],
68
+ registered: name => new AuthorRegistered(name),
69
+ flag: value => new FlagRecorded(value),
70
+ composite: (first, last) => new FullNameRegistered(first, last),
71
+ model: Author,
72
+ reactor: Welcomer
73
+ });
74
+ });
@@ -0,0 +1,139 @@
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 { chai, describe, it, vi, type Assertion } from 'vitest';
6
+ import type { Constructor } from '@cratis/fundamentals';
7
+ import { getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
8
+ import type { ConstraintScopeCapture } from '../../../events/constraints/ConstraintBuilder.js';
9
+ import { compileConstraints } from '../../../events/constraints/Constraints.js';
10
+ import type { AppendOptions } from '../../../eventSequences/AppendOptions.js';
11
+ import type { AppendedEventWithResult } from '../../../eventSequences/AppendedEventWithResult.js';
12
+ import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
13
+ import { InProcessConstraints } from '../../InProcessConstraints.js';
14
+
15
+ chai.should();
16
+ function should(value: unknown): Assertion { return (value as { should: Assertion }).should; }
17
+
18
+ type Entry = { source: string; type: string; value: string; sourceType?: string; streamType?: string; streamId?: string };
19
+ type Definition = { kind: string; name: string; scope: ConstraintScopeCapture; events?: Array<{ type: string; properties: string[] }>;
20
+ eventTypes?: string[]; ignoreCasing?: boolean; removedWithEventTypeIds: string[]; message: string };
21
+ type History = { sequence: string; source: string; sourceType?: string; streamType?: string; streamId?: string;
22
+ type: string; content: Record<string, unknown>; hash?: string };
23
+ type Outcome = { success: boolean; sequences: string[]; errors: string[];
24
+ wireViolations: Array<{ EventTypeId: string; SequenceNumber: string; ConstraintType: number; ConstraintName: string; Message: string; Details: Record<string, string> }>;
25
+ violations: Array<{ id: string; message: string; details: Record<string, string> }>; history: History[]; next: string };
26
+ type ScopeCase = { name: string; kind: string; eventSchemas: Record<string, { eventTypeId: string; properties: Record<string, string> }>;
27
+ constraintDefinitions: Definition[];
28
+ isolatedConstraintOperations: Array<{ label: string; mode: string; overload?: string; options?: AppendOptions; events: Entry[] }> };
29
+ type Fixture = { scopeCases: ScopeCase[]; expected: { cases: Array<{ name: string; result: { outcomes: Outcome[] } }> } };
30
+
31
+ export function scopeScenarioBehaviors(
32
+ types: Record<string, new (value: string) => object>,
33
+ constraints: (scope: ConstraintScopeCapture, kind: string) => Constructor[]
34
+ ): void {
35
+ const fixture = JSON.parse(readFileSync(new URL('../../fixtures/constraints-scopes.json', import.meta.url), 'utf8')) as Fixture;
36
+ const historyOf = (scenario: EventScenario): History[] => scenario.appendedEvents.map(event => ({
37
+ sequence: event.context.sequenceNumber.toString(), source: event.context.eventSourceId,
38
+ sourceType: event.context.eventSourceType, streamType: event.context.eventStreamType, streamId: event.context.eventStreamId,
39
+ type: event.eventType.id.value, content: event.content, hash: event.context.hash
40
+ }));
41
+ const aliases = Object.fromEntries(Object.entries(types).map(([alias, type]) => [getEventTypeMetadata(type)!.eventType.id.value, alias]));
42
+
43
+ it('should retain all seven scope combinations for both constraint kinds and the alias guards', () => {
44
+ should(fixture.scopeCases.map(test => test.name)).deep.equal([
45
+ ...Array.from({ length: 7 }, (_, index) => ['property', 'cycle', 'once'].map(kind => `${kind}-${index + 1}`)).flat(),
46
+ 'property-delimiter-alias', 'cycle-delimiter-alias'
47
+ ]);
48
+ should(fixture.expected.cases.map(test => test.name)).deep.equal(fixture.scopeCases.map(test => test.name));
49
+ });
50
+
51
+ for (const [caseIndex, test] of fixture.scopeCases.entries()) {
52
+ describe(`with ${test.name}`, () => {
53
+ it(test.kind === 'oracleGuard' ? 'should reject delimiter routes before mutation' :
54
+ 'should match kernel violations, routes, hashes, atomic history and notifications', async () => {
55
+ const definition = test.constraintDefinitions[0];
56
+ const selected = Object.keys(test.eventSchemas).map(alias => types[alias]);
57
+ const selectedConstraints = constraints(definition.scope, test.name.split('-')[0]);
58
+ const compiled = compileConstraints({ eventTypes: selected, constraints: selectedConstraints });
59
+ should(Object.fromEntries(Object.entries(test.eventSchemas).map(([alias]) => {
60
+ const metadata = getEventTypeMetadata(types[alias])!;
61
+ return [alias, { eventTypeId: metadata.eventType.id.value,
62
+ properties: Object.fromEntries(Object.entries(metadata.schema.properties!).map(([key, property]) => [key, property.type])) }];
63
+ }))).deep.equal(test.eventSchemas);
64
+ should([...compiled].map(([name, capture]) => ({
65
+ kind: capture.uniqueConstraint ? 'uniqueProperty' : 'uniqueEventType', name,
66
+ ...(capture.uniqueConstraint ? {
67
+ events: capture.uniqueConstraint.eventDefinitions.map(entry => ({ type: aliases[entry.eventTypeId], properties: entry.properties })),
68
+ ignoreCasing: capture.uniqueConstraint.ignoreCasing
69
+ } : { eventTypes: (capture.uniqueEventType!.eventTypeIds ?? [capture.uniqueEventType!.eventTypeId]).map(id => aliases[id]) }),
70
+ removedWithEventTypeIds: (capture.uniqueConstraint?.removedWithEventTypeIds ?? capture.uniqueEventType?.removedWithEventTypeIds ?? []).map(id => aliases[id]),
71
+ message: capture.uniqueConstraint?.message ?? capture.uniqueEventType?.message ?? '', scope: capture.scope
72
+ }))).deep.equal(test.constraintDefinitions);
73
+ const scenario = new EventScenario({ artifacts: { eventTypes: selected, constraints: selectedConstraints } });
74
+ const notifications = scenario.eventLog.appendOperations[Symbol.asyncIterator]();
75
+ if (test.kind === 'oracleGuard') {
76
+ const pending = notifications.next();
77
+ try {
78
+ for (const operation of test.isolatedConstraintOperations) {
79
+ const entry = operation.events[0];
80
+ await scenario.append(entry.source, new types[entry.type](entry.value), operation.options)
81
+ .then(() => { throw new Error('Unsupported route succeeded'); }, error => {
82
+ should(error instanceof UnsupportedEventSequenceOperation).be.true;
83
+ should((error as Error).message).include('Only simple routing identifiers');
84
+ should((error as Error).message).include('Use a kernel-backed test.');
85
+ });
86
+ should(scenario.appendedEvents.length).equal(0);
87
+ should(scenario.results.length).equal(0);
88
+ should((await scenario.eventLog.getNextSequenceNumber()).value).equal(0n);
89
+ }
90
+ const accepted = await scenario.append('Safe', new types[test.isolatedConstraintOperations[0].events[0].type]('Safe'));
91
+ should(accepted.isSuccess).be.true;
92
+ should(((await pending).value as AppendedEventWithResult[])[0].result).equal(accepted);
93
+ } finally { await notifications.return?.(); }
94
+ return;
95
+ }
96
+ const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
97
+ const expected = fixture.expected.cases[caseIndex].result.outcomes;
98
+ should(expected.length).equal(test.isolatedConstraintOperations.length);
99
+ let resultCount = 0;
100
+ try {
101
+ for (const [index, operation] of test.isolatedConstraintOperations.entries()) {
102
+ const before = historyOf(scenario);
103
+ const nextNotification = notifications.next();
104
+ const entries = operation.events.map(entry => ({ eventSourceId: entry.source, event: new types[entry.type](entry.value),
105
+ eventSourceType: entry.sourceType, eventStreamType: entry.streamType, eventStreamId: entry.streamId }));
106
+ const results = operation.mode === 'single'
107
+ ? [await scenario.append(entries[0].eventSourceId, entries[0].event, operation.options)]
108
+ : operation.overload === 'source'
109
+ ? await scenario.appendMany(entries[0].eventSourceId, entries.map(entry => entry.event), operation.options)
110
+ : await scenario.appendMany(entries, operation.options);
111
+ const outcome = expected[index];
112
+ const raw = validate.mock.results.at(-1)!.value as ReturnType<InProcessConstraints['validate']>;
113
+ should(raw.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() })))
114
+ .deep.equal(outcome.wireViolations, operation.label);
115
+ should(results.length).equal(operation.events.length);
116
+ for (const [position, result] of results.entries()) {
117
+ should(result.isSuccess).equal(outcome.success, operation.label);
118
+ should(result.sequenceNumber.value.toString()).equal(outcome.success ? outcome.sequences[position] : '0');
119
+ should(result.constraintViolations.map(violation => ({ id: violation.constraintId, message: violation.message, details: violation.details })))
120
+ .deep.equal(outcome.violations, operation.label);
121
+ should(result.errors.map(error => error.message)).deep.equal(outcome.errors);
122
+ }
123
+ const notification = (await nextNotification).value as AppendedEventWithResult[];
124
+ should(notification.length).equal(results.length);
125
+ notification.forEach((entry, position) => should(entry.result).equal(results[position]));
126
+ resultCount += results.length;
127
+ should(scenario.results.length).equal(resultCount);
128
+ if (!outcome.success) should(historyOf(scenario)).deep.equal(before, operation.label);
129
+ should(historyOf(scenario)).deep.equal(outcome.history, operation.label);
130
+ should((await scenario.eventLog.getNextSequenceNumber()).value.toString()).equal(outcome.next);
131
+ }
132
+ } finally {
133
+ validate.mockRestore();
134
+ await notifications.return?.();
135
+ }
136
+ });
137
+ });
138
+ }
139
+ }
@@ -0,0 +1,57 @@
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, type Constructor } from '@cratis/fundamentals';
6
+ import { describe } from 'vitest';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { constraint } from '../../../events/constraints/constraint.js';
9
+ import { removeConstraint } from '../../../events/constraints/removeConstraint.js';
10
+ import type { ConstraintScopeCapture } from '../../../events/constraints/ConstraintBuilder.js';
11
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
12
+ import { scopeScenarioBehaviors } from './scope_scenario_behaviors.fixture.js';
13
+
14
+ @eventType('OracleDomainText')
15
+ class Text { @field(String) key: string; constructor(key: string) { this.key = key; } }
16
+ @eventType('OracleDomainShared')
17
+ class Shared { @field(String) key: string; constructor(key: string) { this.key = key; } }
18
+ @eventType('OracleDomainRemoved')
19
+ class Removed { @field(String) label: string; constructor(label: string) { this.label = label; } }
20
+ @eventType('OracleDomainExpired')
21
+ class Expired { @field(String) label: string; constructor(label: string) { this.label = label; } }
22
+ @eventType('OracleCycleFirst')
23
+ class First { @field(String) label: string; constructor(label: string) { this.label = label; } }
24
+ @eventType('OracleCycleSibling')
25
+ class Sibling { @field(String) label: string; constructor(label: string) { this.label = label; } }
26
+ @eventType('OracleCycleRemoved')
27
+ @removeConstraint('OracleScopedCycle')
28
+ class CycleRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
29
+ @eventType('OracleCycleExpired')
30
+ @removeConstraint('OracleScopedCycle')
31
+ class CycleExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
32
+
33
+ function constraints(scope: ConstraintScopeCapture, kind: string): Constructor[] {
34
+ function apply(builder: IConstraintBuilder): IConstraintBuilder {
35
+ if (scope.perEventSourceType) builder.perEventSourceType();
36
+ if (scope.perEventStreamType) builder.perEventStreamType();
37
+ if (scope.perEventStreamId) builder.perEventStreamId();
38
+ return builder;
39
+ }
40
+ @constraint('OracleScopedKey')
41
+ class Key {
42
+ define(builder: IConstraintBuilder) {
43
+ apply(builder).unique(key => key.on(Text, event => event.key).on(Shared, event => event.key)
44
+ .removedWith(Removed).removedWith(Expired).withMessage('Taken: {PropertyValue}'));
45
+ }
46
+ }
47
+ @constraint('OracleScopedFirst')
48
+ class FirstCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(First, 'Cycle occupied', 'OracleScopedCycle'); } }
49
+ @constraint('OracleScopedSibling')
50
+ class SiblingCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(Sibling, 'Cycle occupied', 'OracleScopedCycle'); } }
51
+ return kind === 'property' ? [Key] : kind === 'once' ? [FirstCycle] : [FirstCycle, SiblingCycle];
52
+ }
53
+
54
+ describe('when appending scoped constraints with legacy decorators', () => {
55
+ scopeScenarioBehaviors({ string: Text, shared: Shared, removed: Removed, expired: Expired,
56
+ first: First, sibling: Sibling, cycleRemoved: CycleRemoved, cycleExpired: CycleExpired }, constraints);
57
+ });
@@ -0,0 +1,56 @@
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, type Constructor } from '@cratis/fundamentals';
5
+ import { describe } from 'vitest';
6
+ import { eventType } from '../../../events/eventTypeDecorator.js';
7
+ import { constraint } from '../../../events/constraints/constraint.js';
8
+ import { removeConstraint } from '../../../events/constraints/removeConstraint.js';
9
+ import type { ConstraintScopeCapture } from '../../../events/constraints/ConstraintBuilder.js';
10
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import { scopeScenarioBehaviors } from './scope_scenario_behaviors.fixture.js';
12
+
13
+ @eventType('OracleDomainText')
14
+ class Text { @field(String) key: string; constructor(key: string) { this.key = key; } }
15
+ @eventType('OracleDomainShared')
16
+ class Shared { @field(String) key: string; constructor(key: string) { this.key = key; } }
17
+ @eventType('OracleDomainRemoved')
18
+ class Removed { @field(String) label: string; constructor(label: string) { this.label = label; } }
19
+ @eventType('OracleDomainExpired')
20
+ class Expired { @field(String) label: string; constructor(label: string) { this.label = label; } }
21
+ @eventType('OracleCycleFirst')
22
+ class First { @field(String) label: string; constructor(label: string) { this.label = label; } }
23
+ @eventType('OracleCycleSibling')
24
+ class Sibling { @field(String) label: string; constructor(label: string) { this.label = label; } }
25
+ @eventType('OracleCycleRemoved')
26
+ @removeConstraint('OracleScopedCycle')
27
+ class CycleRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
28
+ @eventType('OracleCycleExpired')
29
+ @removeConstraint('OracleScopedCycle')
30
+ class CycleExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
31
+
32
+ function constraints(scope: ConstraintScopeCapture, kind: string): Constructor[] {
33
+ function apply(builder: IConstraintBuilder): IConstraintBuilder {
34
+ if (scope.perEventSourceType) builder.perEventSourceType();
35
+ if (scope.perEventStreamType) builder.perEventStreamType();
36
+ if (scope.perEventStreamId) builder.perEventStreamId();
37
+ return builder;
38
+ }
39
+ @constraint('OracleScopedKey')
40
+ class Key {
41
+ define(builder: IConstraintBuilder) {
42
+ apply(builder).unique(key => key.on(Text, event => event.key).on(Shared, event => event.key)
43
+ .removedWith(Removed).removedWith(Expired).withMessage('Taken: {PropertyValue}'));
44
+ }
45
+ }
46
+ @constraint('OracleScopedFirst')
47
+ class FirstCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(First, 'Cycle occupied', 'OracleScopedCycle'); } }
48
+ @constraint('OracleScopedSibling')
49
+ class SiblingCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(Sibling, 'Cycle occupied', 'OracleScopedCycle'); } }
50
+ return kind === 'property' ? [Key] : kind === 'once' ? [FirstCycle] : [FirstCycle, SiblingCycle];
51
+ }
52
+
53
+ describe('when appending scoped constraints with standard decorators', () => {
54
+ scopeScenarioBehaviors({ string: Text, shared: Shared, removed: Removed, expired: Expired,
55
+ first: First, sibling: Sibling, cycleRemoved: CycleRemoved, cycleExpired: CycleExpired }, constraints);
56
+ });
@@ -0,0 +1,48 @@
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 { constraint } from '../../../events/constraints/constraint.js';
9
+ import { removeConstraint } from '../../../events/constraints/removeConstraint.js';
10
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
12
+
13
+ chai.should();
14
+ @eventType('UnprovenScopedFirst')
15
+ class First { @field(String) label = 'First'; }
16
+ @eventType('UnprovenScopedSecond')
17
+ class Second { @field(String) label = 'Second'; }
18
+ @eventType('UnprovenScopedRenewed')
19
+ @removeConstraint('UnprovenScopedCycle')
20
+ class Renewed { @field(String) label = 'Renewed'; }
21
+ @eventType('UnprovenScopedRemoved')
22
+ @removeConstraint('UnprovenScopedCycle')
23
+ class Removed { @field(String) label = 'Removed'; }
24
+ @eventType('UnprovenScopedExpired')
25
+ @removeConstraint('UnprovenScopedCycle')
26
+ class Expired { @field(String) label = 'Expired'; }
27
+
28
+ function constraints(scoped: boolean): Function[] {
29
+ return [First, Second, Renewed].map((type, index) => {
30
+ @constraint(`UnprovenScopedDeclaration${index}`)
31
+ class Cycle {
32
+ define(builder: IConstraintBuilder) {
33
+ if (scoped) builder.perEventStreamId();
34
+ builder.uniqueFor(type, 'Occupied', 'UnprovenScopedCycle');
35
+ }
36
+ }
37
+ return Cycle;
38
+ });
39
+ }
40
+
41
+ describe('when configuring a scoped covered-and-removal cycle', () => {
42
+ it('should reject that scoped interaction specifically while preserving its unscoped support', () => {
43
+ const eventTypes = [First, Second, Renewed, Removed, Expired];
44
+ new EventScenario({ artifacts: { eventTypes, constraints: constraints(false) } }).should.be.instanceOf(EventScenario);
45
+ (() => new EventScenario({ artifacts: { eventTypes, constraints: constraints(true) } }))
46
+ .should.throw(UnsupportedEventSequenceOperation, 'Scoped covered-and-removal event cycles are not fixture-backed.');
47
+ });
48
+ });