@cratis/chronicle 6.24.0 → 6.26.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.
@@ -0,0 +1,236 @@
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 { readFileSync } from 'node:fs';
6
+ import { chai, describe, it, vi } from 'vitest';
7
+ import { field } from '@cratis/fundamentals';
8
+ import { eventType, getEventTypeMetadata } from '../events/eventTypeDecorator.js';
9
+ import { constraint } from '../events/constraints/constraint.js';
10
+ import { compileConstraints } from '../events/constraints/Constraints.js';
11
+ import type { IConstraint } from '../events/constraints/IConstraint.js';
12
+ import type { IConstraintBuilder } from '../events/constraints/IConstraintBuilder.js';
13
+ import { removeConstraint } from '../events/constraints/removeConstraint.js';
14
+ import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
15
+ import { InProcessConstraints } from './InProcessConstraints.js';
16
+ import { ReactorScenario } from './ReactorScenario.js';
17
+ import { reactor } from '../reactors/reactor.js';
18
+
19
+ chai.should();
20
+ class OracleCycleFirst { @field(String) label: string; constructor(label: string) { this.label = label; } }
21
+ eventType('OracleCycleFirst')(OracleCycleFirst);
22
+ class OracleCycleSibling { @field(String) label: string; constructor(label: string) { this.label = label; } }
23
+ eventType('OracleCycleSibling')(OracleCycleSibling);
24
+ class OracleCycleRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
25
+ eventType('OracleCycleRemoved')(OracleCycleRemoved);
26
+ removeConstraint('OracleCycle')(OracleCycleRemoved);
27
+ class OracleCycleExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
28
+ eventType('OracleCycleExpired')(OracleCycleExpired);
29
+ removeConstraint('OracleCycle')(OracleCycleExpired);
30
+ class OracleCycleRenewed { @field(String) label: string; constructor(label: string) { this.label = label; } }
31
+ eventType('OracleCycleRenewed')(OracleCycleRenewed);
32
+ removeConstraint('OracleCycle')(OracleCycleRenewed);
33
+ // Same-named uniqueFor declarations merge into one definition covering the whole set.
34
+ class CycleFirst implements IConstraint {
35
+ define(builder: IConstraintBuilder) { builder.uniqueFor(OracleCycleFirst, 'Cycle occupied', 'OracleCycle'); }
36
+ }
37
+ constraint('CycleFirst')(CycleFirst);
38
+ class CycleSibling implements IConstraint {
39
+ define(builder: IConstraintBuilder) { builder.uniqueFor(OracleCycleSibling, undefined, 'OracleCycle'); }
40
+ }
41
+ constraint('CycleSibling')(CycleSibling);
42
+ class CycleRenewed implements IConstraint {
43
+ define(builder: IConstraintBuilder) { builder.uniqueFor(OracleCycleRenewed, undefined, 'OracleCycle'); }
44
+ }
45
+ constraint('CycleRenewed')(CycleRenewed);
46
+
47
+ const aliases = { first: OracleCycleFirst, sibling: OracleCycleSibling, cycleRemoved: OracleCycleRemoved,
48
+ cycleExpired: OracleCycleExpired, renewed: OracleCycleRenewed };
49
+ type Alias = keyof typeof aliases;
50
+ type Input = { source: string; type: Alias; value: string };
51
+ type History = { sequence: string; source: string; sourceType: string; streamType: string; streamId: string;
52
+ type: string; content: Record<string, string>; hash: string };
53
+ type Violation = { EventTypeId: string; SequenceNumber: string; ConstraintType: number; ConstraintName: string;
54
+ Message: string; Details: Record<string, string> };
55
+ type Outcome = { success: boolean; sequences: string[]; violations: Array<{ id: string; message: string; details: Record<string, string> }>;
56
+ wireViolations: Violation[]; errors: string[]; history: History[]; next: string };
57
+ type Fixture = { eventSchemas: Record<string, { eventTypeId: string; properties: Record<string, string> }>;
58
+ constraintDefinitions: Array<{ kind: string; name: string; eventTypes: string[]; removedWithEventTypeIds: string[]; message: string }>;
59
+ isolatedConstraintOperations: Array<{ mode: 'single' | 'batch'; events: Input[] }>;
60
+ expected: { outcomes: Outcome[] } };
61
+ const aliasById = Object.fromEntries(Object.entries(aliases).map(([alias, type]) => [getEventTypeMetadata(type)!.eventType.id.value, alias]));
62
+ const makeEvent = ({ type, value }: Input): object => new aliases[type](value);
63
+ const historyOf = (scenario: EventScenario): History[] => scenario.appendedEvents.map(entry => ({
64
+ sequence: entry.context.sequenceNumber.toString(), source: entry.context.eventSourceId,
65
+ sourceType: entry.context.eventSourceType, streamType: entry.context.eventStreamType, streamId: entry.context.eventStreamId,
66
+ type: entry.eventType.id.value, content: entry.content as Record<string, string>, hash: entry.context.hash
67
+ }));
68
+ const unsupported = async (action: () => unknown, operation: string, reason: string) => {
69
+ await Promise.resolve().then(action).then(() => { throw new Error('Expected rejection'); }, error => {
70
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
71
+ (error as Error).message.should.include(`UnsupportedEventSequenceOperation: ${operation}:`);
72
+ (error as Error).message.should.include(reason);
73
+ (error as Error).message.should.include('Use a kernel-backed test.');
74
+ });
75
+ };
76
+ const siblingTypes = [OracleCycleFirst, OracleCycleSibling, OracleCycleRemoved, OracleCycleExpired];
77
+ const cycleTypes = [...siblingTypes, OracleCycleRenewed];
78
+ const shapeReason = 'This unique event type set and removal combination is not fixture-backed.';
79
+
80
+ describe('fixture-backed unique event type cycles', () => {
81
+ for (const [name, selected, constraints] of [
82
+ ['constraints-event-type-siblings', siblingTypes, [CycleFirst, CycleSibling]],
83
+ ['constraints-event-type-cycles', cycleTypes, [CycleFirst, CycleSibling, CycleRenewed]]
84
+ ] as const) {
85
+ it(`matches the pinned kernel ${name}: raw and mapped violations, cycles, hashes and rollback`, async () => {
86
+ const fixture = JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), 'utf8')) as Fixture;
87
+ Object.fromEntries(selected.map(type => {
88
+ const metadata = getEventTypeMetadata(type)!;
89
+ return [aliasById[metadata.eventType.id.value], { eventTypeId: metadata.eventType.id.value,
90
+ properties: Object.fromEntries(Object.entries(metadata.schema.properties ?? {}).map(([key, property]) => [key, property.type])) }];
91
+ })).should.deep.equal(fixture.eventSchemas);
92
+ const compiled = compileConstraints({ eventTypes: [...selected], constraints: [...constraints] });
93
+ [...compiled].map(([key, capture]) => ({ kind: 'uniqueEventType', name: key,
94
+ eventTypes: capture.uniqueEventType!.eventTypeIds!.map(id => aliasById[id]),
95
+ removedWithEventTypeIds: capture.uniqueEventType!.removedWithEventTypeIds!.map(id => aliasById[id]),
96
+ message: capture.uniqueEventType!.message ?? ''
97
+ })).should.deep.equal(fixture.constraintDefinitions);
98
+ fixture.isolatedConstraintOperations.length.should.be.greaterThan(0);
99
+ fixture.expected.outcomes.length.should.equal(fixture.isolatedConstraintOperations.length);
100
+ fixture.expected.outcomes.some(outcome => !outcome.success).should.be.true;
101
+ const scenario = new EventScenario({ artifacts: { eventTypes: [...selected], constraints: [...constraints] } });
102
+ const notifications = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
103
+ const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
104
+ let count = 0;
105
+ try {
106
+ for (const [index, operation] of fixture.isolatedConstraintOperations.entries()) {
107
+ const expected = fixture.expected.outcomes[index];
108
+ const before = historyOf(scenario);
109
+ const calls = validate.mock.calls.length;
110
+ const nextNotification = notifications.next();
111
+ const results = operation.mode === 'single'
112
+ ? [await scenario.append(operation.events[0].source, makeEvent(operation.events[0]))]
113
+ : await scenario.appendMany(operation.events.map(entry => ({ eventSourceId: entry.source, event: makeEvent(entry) })));
114
+ const notification = (await nextNotification).value!;
115
+ validate.mock.calls.length.should.equal(calls + 1);
116
+ const raw = validate.mock.results.at(-1)?.value as ReturnType<InProcessConstraints['validate']>;
117
+ raw.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() }))
118
+ .should.deep.equal(expected.wireViolations);
119
+ notification.length.should.equal(operation.events.length);
120
+ notification.every(item => item.result.isSuccess === expected.success).should.be.true;
121
+ count += operation.events.length;
122
+ scenario.results.length.should.equal(count);
123
+ results.length.should.equal(operation.events.length);
124
+ for (const [position, result] of results.entries()) {
125
+ result.isSuccess.should.equal(expected.success);
126
+ result.sequenceNumber.value.toString().should.equal(expected.success ? expected.sequences[position] : '0');
127
+ result.constraintViolations.map(item => ({ id: item.constraintId, message: item.message, details: item.details }))
128
+ .should.deep.equal(expected.violations);
129
+ result.errors.map(item => item.message).should.deep.equal(expected.errors);
130
+ }
131
+ if (!expected.success) historyOf(scenario).should.deep.equal(before);
132
+ historyOf(scenario).should.deep.equal(expected.history);
133
+ (await scenario.eventSequence.getNextSequenceNumber()).value.toString().should.equal(expected.next);
134
+ }
135
+ } finally {
136
+ validate.mockRestore();
137
+ await notifications.return?.();
138
+ }
139
+ });
140
+ }
141
+
142
+ it('rolls back a release and reclaim in a failing setup call without results or notifications', async () => {
143
+ const scenario = new EventScenario({ artifacts: { eventTypes: siblingTypes, constraints: [CycleFirst, CycleSibling] } });
144
+ const notifications = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
145
+ try {
146
+ const first = notifications.next();
147
+ await scenario.given.forEventSource('A').events(new OracleCycleFirst('claim'));
148
+ (await first).value!.length.should.equal(1);
149
+ const before = historyOf(scenario);
150
+ await scenario.given.forEventSource('A').events(new OracleCycleRemoved('release'), new OracleCycleFirst('reopen'),
151
+ new OracleCycleSibling('blocked'))
152
+ .then(() => { throw new Error('Invalid setup succeeded'); }, error => {
153
+ (error as Error).message.should.include('EventScenario given setup failed');
154
+ });
155
+ historyOf(scenario).should.deep.equal(before);
156
+ scenario.results.length.should.equal(0);
157
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
158
+ const next = notifications.next();
159
+ const blocked = await scenario.append('A', new OracleCycleSibling('still held'));
160
+ blocked.constraintViolations.map(item => item.constraintId).should.deep.equal(['OracleCycle']);
161
+ (await next).value!.map(item => item.event.eventType.id.value).should.deep.equal(['OracleCycleSibling']);
162
+ } finally { await notifications.return?.(); }
163
+ });
164
+
165
+ it('delivers only committed claim and release events to a reactor', async () => {
166
+ const calls: string[] = [];
167
+ @reactor('cycle-reactor')
168
+ class CycleReactor {
169
+ oracleCycleFirst(event: OracleCycleFirst) { calls.push(event.label); }
170
+ oracleCycleSibling(event: OracleCycleSibling) { calls.push(event.label); }
171
+ oracleCycleExpired(event: OracleCycleExpired) { calls.push(event.label); }
172
+ }
173
+ const subject = new ReactorScenario(CycleReactor,
174
+ { artifacts: { eventTypes: siblingTypes, constraints: [CycleFirst, CycleSibling] } });
175
+ await subject.when.forEventSource('A').events(new OracleCycleFirst('claim'));
176
+ await subject.when.forEventSource('A').events(new OracleCycleSibling('blocked'))
177
+ .then(() => { throw new Error('Occupied cycle delivered'); }, error => {
178
+ (error as Error).message.should.include('ReactorScenario action append failed');
179
+ });
180
+ calls.should.deep.equal(['claim']);
181
+ subject.results.length.should.equal(1);
182
+ await subject.when.forEventSource('A').events(new OracleCycleExpired('release'), new OracleCycleSibling('reclaim'));
183
+ calls.should.deep.equal(['claim', 'release', 'reclaim']);
184
+ subject.results.length.should.equal(2);
185
+ });
186
+
187
+ it('rejects unsupported input in a batch and leaves history and results unchanged', async () => {
188
+ const scenario = new EventScenario({ artifacts: { eventTypes: siblingTypes, constraints: [CycleFirst, CycleSibling] } });
189
+ (await scenario.append('A', new OracleCycleFirst('claim'))).isSuccess.should.be.true;
190
+ const before = historyOf(scenario);
191
+ const results = scenario.results;
192
+ await unsupported(() => scenario.appendMany([{ eventSourceId: 'A', event: new OracleCycleExpired('release') },
193
+ { eventSourceId: 'B!', event: new OracleCycleFirst('claim') }]), 'appendMany.source (B!)',
194
+ 'Only simple source identifiers are fixture-backed.');
195
+ historyOf(scenario).should.deep.equal(before);
196
+ scenario.results.should.deep.equal(results);
197
+ const blocked = await scenario.append('A', new OracleCycleSibling('still held'));
198
+ blocked.isSuccess.should.be.false;
199
+ blocked.constraintViolations.map(item => item.message).should.deep.equal(['Cycle occupied']);
200
+ });
201
+
202
+ it('rejects covered and removal type shapes that no fixture installs, at the shape check', async () => {
203
+ await unsupported(() => new EventScenario({ artifacts: {
204
+ eventTypes: [OracleCycleFirst, OracleCycleSibling, OracleCycleRemoved], constraints: [CycleFirst, CycleSibling]
205
+ } }), 'artifacts.constraints (OracleCycle)', shapeReason);
206
+ await unsupported(() => new EventScenario({ artifacts: {
207
+ eventTypes: [OracleCycleFirst, OracleCycleRemoved, OracleCycleRenewed], constraints: [CycleFirst, CycleRenewed]
208
+ } }), 'artifacts.constraints (OracleCycle)', shapeReason);
209
+ await unsupported(() => new EventScenario({ artifacts: {
210
+ eventTypes: [OracleCycleFirst, OracleCycleSibling, OracleCycleRemoved, OracleCycleRenewed],
211
+ constraints: [CycleFirst, CycleSibling, CycleRenewed]
212
+ } }), 'artifacts.constraints (OracleCycle)', shapeReason);
213
+ });
214
+
215
+ it('rejects a fixture-shaped cycle installed alongside another definition', async () => {
216
+ class OtherOnce { @field(String) label = 'other'; }
217
+ eventType('OracleCycleOtherOnce')(OtherOnce);
218
+ class OtherOnceConstraint implements IConstraint {
219
+ define(builder: IConstraintBuilder) { builder.uniqueFor(OtherOnce, undefined, 'OracleCycleOther'); }
220
+ }
221
+ constraint('OracleCycleOther')(OtherOnceConstraint);
222
+ await unsupported(() => new EventScenario({ artifacts: {
223
+ eventTypes: [...siblingTypes, OtherOnce], constraints: [CycleFirst, CycleSibling, OtherOnceConstraint]
224
+ } }), 'artifacts.constraints (OracleCycle)', 'Unique event cycles alongside other definitions are not fixture-backed.');
225
+ });
226
+
227
+ it('rejects a fieldless event-type remover at the schema check', async () => {
228
+ class FieldlessRemoval {}
229
+ eventType('OracleCycleFieldless')(FieldlessRemoval);
230
+ removeConstraint('OracleCycle')(FieldlessRemoval);
231
+ await unsupported(() => new EventScenario({ artifacts: {
232
+ eventTypes: [OracleCycleFirst, OracleCycleSibling, OracleCycleRemoved, FieldlessRemoval],
233
+ constraints: [CycleFirst, CycleSibling]
234
+ } }), 'artifacts.eventTypes.schema (FieldlessRemoval)', 'Only flat, unclassified string/boolean fields are fixture-backed.');
235
+ });
236
+ });
@@ -0,0 +1,231 @@
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 { readFileSync } from 'node:fs';
6
+ import { chai, describe, it, vi } from 'vitest';
7
+ import { field } from '@cratis/fundamentals';
8
+ import { eventType, getEventTypeMetadata } from '../events/eventTypeDecorator.js';
9
+ import { constraint } from '../events/constraints/constraint.js';
10
+ import { compileConstraints } from '../events/constraints/Constraints.js';
11
+ import type { IConstraint } from '../events/constraints/IConstraint.js';
12
+ import type { IConstraintBuilder } from '../events/constraints/IConstraintBuilder.js';
13
+ import { removeConstraint } from '../events/constraints/removeConstraint.js';
14
+ import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
15
+ import { InProcessConstraints } from './InProcessConstraints.js';
16
+ import { ReactorScenario } from './ReactorScenario.js';
17
+ import { reactor } from '../reactors/reactor.js';
18
+
19
+ chai.should();
20
+ class OracleDomainText { @field(String) key: string; constructor(key: string) { this.key = key; } }
21
+ eventType('OracleDomainText')(OracleDomainText);
22
+ class OracleDomainShared { @field(String) key: string; constructor(key: string) { this.key = key; } }
23
+ eventType('OracleDomainShared')(OracleDomainShared);
24
+ class OracleDomainRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
25
+ eventType('OracleDomainRemoved')(OracleDomainRemoved);
26
+ removeConstraint('OracleLifecycleKey')(OracleDomainRemoved);
27
+ class OracleDomainExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
28
+ eventType('OracleDomainExpired')(OracleDomainExpired);
29
+ class OracleDomainCleared {}
30
+ eventType('OracleDomainCleared')(OracleDomainCleared);
31
+ class LifecycleKey implements IConstraint {
32
+ define(builder: IConstraintBuilder) {
33
+ builder.unique(key => key.on(OracleDomainText, event => event.key).on(OracleDomainShared, event => event.key)
34
+ .removedWith(OracleDomainExpired).removedWith(OracleDomainCleared).withMessage('Taken: {PropertyValue}'));
35
+ }
36
+ }
37
+ constraint('OracleLifecycleKey')(LifecycleKey);
38
+ class CoveredRemoval implements IConstraint {
39
+ define(builder: IConstraintBuilder) {
40
+ builder.unique(key => key.on(OracleDomainShared, event => event.key).on(OracleDomainText, event => event.key)
41
+ .removedWith(OracleDomainText).withMessage('Taken: {PropertyValue}'));
42
+ }
43
+ }
44
+ constraint('OracleCoveredRemoval')(CoveredRemoval);
45
+
46
+ type Alias = 'string' | 'shared' | 'removed' | 'expired' | 'cleared';
47
+ type Input = { source: string; type: Alias; value?: string };
48
+ type History = { sequence: string; source: string; sourceType: string; streamType: string; streamId: string;
49
+ type: string; content: Record<string, string>; hash: string };
50
+ type Violation = { EventTypeId: string; SequenceNumber: string; ConstraintType: number; ConstraintName: string;
51
+ Message: string; Details: Record<string, string> };
52
+ type Outcome = { success: boolean; sequences: string[]; violations: Array<{ id: string; message: string; details: Record<string, string> }>;
53
+ wireViolations: Violation[]; errors: string[]; history: History[]; next: string };
54
+ type Fixture = { eventSchemas: Record<string, { eventTypeId: string; properties: Record<string, string> }>;
55
+ constraintDefinitions: Array<{ kind: string; name: string; events: Array<{ type: string; properties: string[] }>;
56
+ ignoreCasing: boolean; removedWithEventTypeIds: string[]; message: string }>;
57
+ isolatedConstraintOperations: Array<{ mode: 'single' | 'batch'; events: Input[] }>;
58
+ expected: { outcomes: Outcome[] } };
59
+ const aliases = { string: OracleDomainText, shared: OracleDomainShared, removed: OracleDomainRemoved,
60
+ expired: OracleDomainExpired, cleared: OracleDomainCleared };
61
+ const aliasById = Object.fromEntries(Object.entries(aliases).map(([alias, type]) => [getEventTypeMetadata(type)!.eventType.id.value, alias]));
62
+ const makeEvent = ({ type, value }: Input): object => type === 'string' ? new OracleDomainText(value!) :
63
+ type === 'shared' ? new OracleDomainShared(value!) : type === 'removed' ? new OracleDomainRemoved(value!) :
64
+ type === 'expired' ? new OracleDomainExpired(value!) : new OracleDomainCleared();
65
+ const historyOf = (scenario: EventScenario): History[] => scenario.appendedEvents.map(entry => ({
66
+ sequence: entry.context.sequenceNumber.toString(), source: entry.context.eventSourceId,
67
+ sourceType: entry.context.eventSourceType, streamType: entry.context.eventStreamType, streamId: entry.context.eventStreamId,
68
+ type: entry.eventType.id.value, content: entry.content as Record<string, string>, hash: entry.context.hash
69
+ }));
70
+ const unsupported = async (action: () => unknown, reason: string) => {
71
+ await Promise.resolve().then(action).then(() => { throw new Error('Expected rejection'); }, error => {
72
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
73
+ (error as Error).message.should.include(reason);
74
+ (error as Error).message.should.include('Use a kernel-backed test.');
75
+ });
76
+ };
77
+
78
+ describe('fixture-backed unique-property lifecycle', () => {
79
+ for (const [name, constraints, selected] of [
80
+ ['constraints-property-lifecycle', [LifecycleKey], Object.values(aliases)],
81
+ ['constraints-property-covered-removal', [CoveredRemoval], [OracleDomainText, OracleDomainShared]]
82
+ ] as const) {
83
+ it(`matches the pinned kernel ${name}: raw and mapped violations, ownership, hashes and rollback`, async () => {
84
+ const fixture = JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), 'utf8')) as Fixture;
85
+ const ids = new Set(selected.map(type => type.name));
86
+ Object.fromEntries(Object.entries(aliases).filter(([, type]) => ids.has(type.name)).map(([alias, type]) => {
87
+ const metadata = getEventTypeMetadata(type)!;
88
+ return [alias, { eventTypeId: metadata.eventType.id.value,
89
+ properties: Object.fromEntries(Object.entries(metadata.schema.properties ?? {}).map(([key, property]) => [key, property.type])) }];
90
+ })).should.deep.equal(fixture.eventSchemas);
91
+ const compiled = compileConstraints({ eventTypes: [...selected], constraints: [...constraints] });
92
+ [...compiled].map(([key, capture]) => ({ kind: 'uniqueProperty', name: key,
93
+ events: capture.uniqueConstraint!.eventDefinitions.map(entry => ({ type: aliasById[entry.eventTypeId], properties: entry.properties })),
94
+ ignoreCasing: capture.uniqueConstraint!.ignoreCasing,
95
+ removedWithEventTypeIds: (capture.uniqueConstraint!.removedWithEventTypeIds ?? [])
96
+ .map(id => aliasById[id]), message: capture.uniqueConstraint!.message ?? ''
97
+ })).should.deep.equal(fixture.constraintDefinitions);
98
+ fixture.isolatedConstraintOperations.length.should.be.greaterThan(0);
99
+ fixture.expected.outcomes.length.should.equal(fixture.isolatedConstraintOperations.length);
100
+ const scenario = new EventScenario({ artifacts: { eventTypes: [...selected], constraints: [...constraints] } });
101
+ const notifications = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
102
+ const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
103
+ let count = 0;
104
+ try {
105
+ for (const [index, operation] of fixture.isolatedConstraintOperations.entries()) {
106
+ const expected = fixture.expected.outcomes[index];
107
+ const before = historyOf(scenario);
108
+ const nextNotification = notifications.next();
109
+ const results = operation.mode === 'single'
110
+ ? [await scenario.append(operation.events[0].source, makeEvent(operation.events[0]))]
111
+ : await scenario.appendMany(operation.events.map(entry => ({ eventSourceId: entry.source, event: makeEvent(entry) })));
112
+ const notification = (await nextNotification).value!;
113
+ const raw = validate.mock.results.at(-1)?.value as ReturnType<InProcessConstraints['validate']>;
114
+ raw.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() }))
115
+ .should.deep.equal(expected.wireViolations);
116
+ notification.length.should.equal(operation.events.length);
117
+ notification.every(item => item.result.isSuccess === expected.success).should.be.true;
118
+ count += operation.events.length;
119
+ scenario.results.length.should.equal(count);
120
+ results.length.should.equal(operation.events.length);
121
+ for (const [position, result] of results.entries()) {
122
+ result.isSuccess.should.equal(expected.success);
123
+ result.sequenceNumber.value.toString().should.equal(expected.success ? expected.sequences[position] : '0');
124
+ result.constraintViolations.map(item => ({ id: item.constraintId, message: item.message, details: item.details }))
125
+ .should.deep.equal(expected.violations);
126
+ result.errors.map(item => item.message).should.deep.equal(expected.errors);
127
+ }
128
+ if (!expected.success) historyOf(scenario).should.deep.equal(before);
129
+ historyOf(scenario).should.deep.equal(expected.history);
130
+ (await scenario.eventSequence.getNextSequenceNumber()).value.toString().should.equal(expected.next);
131
+ }
132
+ } finally {
133
+ validate.mockRestore();
134
+ await notifications.return?.();
135
+ }
136
+ });
137
+ }
138
+
139
+ it('rolls back removal in a failing setup call without results or notifications', async () => {
140
+ const scenario = new EventScenario({ artifacts: { eventTypes: Object.values(aliases), constraints: [LifecycleKey] } });
141
+ const notifications = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
142
+ try {
143
+ const first = notifications.next();
144
+ await scenario.given.forEventSource('A').events(new OracleDomainText('Alpha'));
145
+ (await first).value!.length.should.equal(1);
146
+ const second = notifications.next();
147
+ await scenario.given.forEventSource('B').events(new OracleDomainShared('Beta'));
148
+ (await second).value!.length.should.equal(1);
149
+ const before = historyOf(scenario);
150
+ await scenario.given.forEventSource('A').events(new OracleDomainRemoved('end'), new OracleDomainShared('Beta'))
151
+ .then(() => { throw new Error('Invalid setup succeeded'); }, error => {
152
+ (error as Error).message.should.include('EventScenario given setup failed');
153
+ });
154
+ historyOf(scenario).should.deep.equal(before);
155
+ scenario.results.length.should.equal(0);
156
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(2n);
157
+ const next = notifications.next();
158
+ (await scenario.append('C', new OracleDomainShared('Alpha'))).constraintViolations.length.should.equal(1);
159
+ (await next).value!.map(item => item.event.eventType.id.value).should.deep.equal(['OracleDomainShared']);
160
+ } finally { await notifications.return?.(); }
161
+ });
162
+
163
+ it('delivers only committed claim/removal events to a reactor', async () => {
164
+ const calls: string[] = [];
165
+ @reactor('lifecycle-reactor')
166
+ class LifecycleReactor {
167
+ oracleDomainText(event: OracleDomainText) { calls.push(event.key); }
168
+ oracleDomainRemoved(event: OracleDomainRemoved) { calls.push(event.label); }
169
+ }
170
+ const subject = new ReactorScenario(LifecycleReactor,
171
+ { artifacts: { eventTypes: Object.values(aliases), constraints: [LifecycleKey] } });
172
+ await subject.when.forEventSource('A').events(new OracleDomainText('Alpha'));
173
+ await subject.when.forEventSource('B').events(new OracleDomainText('Alpha'))
174
+ .then(() => { throw new Error('Duplicate claim delivered'); }, error => {
175
+ (error as Error).message.should.include('ReactorScenario action append failed');
176
+ });
177
+ calls.should.deep.equal(['Alpha']);
178
+ subject.results.length.should.equal(1);
179
+ await subject.when.forEventSource('A').events(new OracleDomainRemoved('released'));
180
+ await subject.when.forEventSource('B').events(new OracleDomainText('Alpha'));
181
+ calls.should.deep.equal(['Alpha', 'released', 'Alpha']);
182
+ subject.results.length.should.equal(3);
183
+ });
184
+
185
+ it('rejects a removal type shared by two definitions without claiming kernel semantics', async () => {
186
+ class FirstClaim { @field(String) key = 'Alpha'; }
187
+ eventType('FirstRemovalClaim')(FirstClaim);
188
+ class SecondClaim { @field(String) key = 'Beta'; }
189
+ eventType('SecondRemovalClaim')(SecondClaim);
190
+ class SharedRemoval { @field(String) label = 'end'; }
191
+ eventType('SharedRemoval')(SharedRemoval);
192
+ class FirstKey implements IConstraint {
193
+ define(builder: IConstraintBuilder) {
194
+ builder.unique(key => key.on(FirstClaim, event => event.key).removedWith(SharedRemoval));
195
+ }
196
+ }
197
+ constraint('FirstRemovalKey')(FirstKey);
198
+ class SecondKey implements IConstraint {
199
+ define(builder: IConstraintBuilder) {
200
+ builder.unique(key => key.on(SecondClaim, event => event.key).removedWith(SharedRemoval));
201
+ }
202
+ }
203
+ constraint('SecondRemovalKey')(SecondKey);
204
+ await unsupported(() => new EventScenario({ artifacts: {
205
+ eventTypes: [FirstClaim, SecondClaim, SharedRemoval], constraints: [FirstKey, SecondKey]
206
+ } }), 'Removal type shared by several definitions is not fixture-backed.');
207
+ });
208
+
209
+ it('rejects unresolved removal names and unproven cross-definition interactions before mutation', async () => {
210
+ class Missing { @field(String) label = 'missing'; }
211
+ eventType('MissingLifecycleRemoval')(Missing);
212
+ removeConstraint('NotDefined')(Missing);
213
+ await unsupported(() => new EventScenario({ artifacts: { eventTypes: [Missing, OracleDomainText], constraints: [LifecycleKey] } }),
214
+ 'Unresolved removal constraint name');
215
+ class Other implements IConstraint {
216
+ define(builder: IConstraintBuilder) { builder.unique(key => key.on(OracleDomainRemoved, event => event.label)); }
217
+ }
218
+ constraint('OtherLifecycleKey')(Other);
219
+ await unsupported(() => new EventScenario({ artifacts: { eventTypes: Object.values(aliases), constraints: [LifecycleKey, Other] } }),
220
+ 'Removal overlapping another validating definition');
221
+ const scenario = new EventScenario({ artifacts: { eventTypes: Object.values(aliases), constraints: [LifecycleKey] } });
222
+ (await scenario.append('A', new OracleDomainText('Alpha'))).isSuccess.should.be.true;
223
+ const before = historyOf(scenario);
224
+ const results = scenario.results;
225
+ await unsupported(() => scenario.appendMany([{ eventSourceId: 'A', event: new OracleDomainRemoved('end') },
226
+ { eventSourceId: 'B', event: new OracleDomainText('unproven!') }]), 'artifacts.constraints');
227
+ historyOf(scenario).should.deep.equal(before);
228
+ scenario.results.should.deep.equal(results);
229
+ (await scenario.append('B', new OracleDomainText('Alpha'))).constraintViolations.length.should.equal(1);
230
+ });
231
+ });
@@ -172,7 +172,19 @@ describe('fixture-backed unscoped constraints', () => {
172
172
  const production = compileConstraints({ eventTypes: [GlobalOnce, SelectedOnce], constraints: [] });
173
173
  production.get('ScenarioSharedOnce')!.uniqueEventType!.eventTypeIds.should.deep.equal(['GlobalOnce', 'SelectedOnce']);
174
174
  unsupported(() => new EventScenario({ artifacts: { eventTypes: [SelectedOnce] } }), 'artifacts.constraints')
175
- .message.should.include('multi-type unique event cycles');
175
+ .message.should.include('This unique event type set and removal combination is not fixture-backed.');
176
+ });
177
+
178
+ it('rejects removal of a selected unique-event-type constraint at construction', () => {
179
+ class Once { @field(String) label = 'recorded'; }
180
+ eventType('OnceWithRemoval')(Once);
181
+ unique('OnceName')(Once);
182
+ class Removal { @field(String) label = 'removed'; }
183
+ eventType('OnceNameRemoval')(Removal);
184
+ removeConstraint('OnceName')(Removal);
185
+ const message = unsupported(() => new EventScenario({ artifacts: { eventTypes: [Once, Removal] } }), 'artifacts.constraints').message;
186
+ message.should.include('OnceName');
187
+ message.should.include('This unique event type set and removal combination is not fixture-backed.');
176
188
  });
177
189
 
178
190
  it('rejects a global removal decorator targeting a selected constraint', () => {
@@ -330,22 +342,6 @@ describe('fixture-backed unscoped constraints', () => {
330
342
  }
331
343
  });
332
344
 
333
- it('rejects a fresh-source key replacement within one batch without changing results or history', async () => {
334
- const subject = scenario();
335
- await subject.append('A', new OracleKeyClaimed('Alpha'));
336
- const history = subject.appendedEvents;
337
- const results = subject.results;
338
- await subject.appendMany('C', [new OracleKeyClaimed('First'), new OracleKeyShared('Second')])
339
- .then(() => { throw new Error('In-batch replacement accepted'); }, error => {
340
- (error instanceof UnsupportedEventSequenceOperation).should.be.true;
341
- (error as Error).message.should.include('artifacts.constraints');
342
- (error as Error).message.should.include('Replacing a key within one batch');
343
- (error as Error).message.should.include('Use a kernel-backed test.');
344
- });
345
- subject.appendedEvents.should.deep.equal(history);
346
- subject.results.should.deep.equal(results);
347
- });
348
-
349
345
  it('rejects history with an unproven unique key without changing scenario state', async () => {
350
346
  const subject = scenario();
351
347
  await subject.append('A', new OracleKeyClaimed('Alpha'));
@@ -359,7 +355,7 @@ describe('fixture-backed unscoped constraints', () => {
359
355
  subject.results.length.should.equal(1);
360
356
  });
361
357
 
362
- it('rejects removal, scoped, composite, case-insensitive and replacement before mutation', async () => {
358
+ it('rejects scoped, composite and case-insensitive configurations and unsupported keys before mutation', async () => {
363
359
  const scoped = (configure: (builder: IConstraintBuilder) => void) => {
364
360
  class InvalidConstraint implements IConstraint {
365
361
  define(builder: IConstraintBuilder) { configure(builder); }
@@ -374,22 +370,15 @@ describe('fixture-backed unscoped constraints', () => {
374
370
  scoped(builder => builder.unique(key => key.on(OracleKeyClaimed, event => event.key).ignoreCasing()));
375
371
  scoped(builder => builder.unique(key => key.on(OracleKeyClaimed, event => event.key))
376
372
  .uniqueFor(OracleOnceRecorded));
377
- class Removal { @field(String) label = 'removed'; }
378
- eventType('Removal')(Removal);
379
- removeConstraint('OracleKey')(Removal);
380
- unsupported(() => new EventScenario({ artifacts: { eventTypes: [...types, Removal], constraints: [OracleFluentOnceConstraint] } }), 'artifacts.constraints')
381
- .message.should.include('removal');
382
373
  class AnotherOnce { @field(String) label = 'second'; }
383
374
  eventType('AnotherOnce')(AnotherOnce);
384
375
  unique('OracleOnce')(AnotherOnce);
385
376
  unsupported(() => new EventScenario({ artifacts: { eventTypes: [...types, AnotherOnce], constraints: [OracleFluentOnceConstraint] } }), 'artifacts.constraints')
386
- .message.should.include('multi-type unique event cycles');
377
+ .message.should.include('This unique event type set and removal combination is not fixture-backed.');
387
378
  const subject = scenario();
388
379
  await subject.append('A', new OracleKeyClaimed('Alpha'));
389
380
  await subject.append('B', new OracleKeyClaimed('beta'));
390
- for (const action of [() => subject.append('A', new OracleKeyClaimed('different')),
391
- () => subject.appendMany('A', [new OracleKeyClaimed('Alpha'), new OracleKeyClaimed('changed')]),
392
- () => subject.append('C', new OracleKeyClaimed('!'))]) {
381
+ for (const action of [() => subject.append('C', new OracleKeyClaimed('!'))]) {
393
382
  await action().then(() => { throw new Error('Unsupported key accepted'); }, error => {
394
383
  (error instanceof UnsupportedEventSequenceOperation).should.be.true;
395
384
  (error as Error).message.should.include('artifacts.constraints');
@@ -56,7 +56,9 @@ interface Fixture {
56
56
  }
57
57
  const directory = new URL('./fixtures/', import.meta.url);
58
58
  const fixtures = readdirSync(directory).filter(name => name.endsWith('.json') &&
59
- !['batches.json', 'batch-omitted-routes.json', 'batch-rollback.json', 'builders.json', 'constraints.json', 'constraints-isolation.json', 'constraints-key-domain.json'].includes(name)).map(name => ({
59
+ !['batches.json', 'batch-omitted-routes.json', 'batch-rollback.json', 'builders.json', 'constraints.json', 'constraints-isolation.json', 'constraints-key-domain.json',
60
+ 'constraints-property-lifecycle.json', 'constraints-property-covered-removal.json', 'constraints-event-type-cycles.json',
61
+ 'constraints-event-type-siblings.json'].includes(name)).map(name => ({
60
62
  name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
61
63
  }));
62
64
  const artifacts = { eventTypes: [OracleEventRecorded, AlternateRecorded, BoundaryRecorded], constraints: [] };
@@ -85,7 +85,7 @@ async function rejects(action: () => Promise<unknown>, operation: string): Promi
85
85
 
86
86
  describe('when the fixture-bounded event sequence encounters unproven operations', () => {
87
87
  for (const [type, operation] of [
88
- [RemovesConstraint, 'artifacts.eventTypes.constraints'], [Tagged, 'tags'],
88
+ [RemovesConstraint, 'artifacts.constraints'], [Tagged, 'tags'],
89
89
  [LaterGeneration, 'artifacts.eventTypes'], [Tombstone, 'artifacts.eventTypes'],
90
90
  [CommaId, 'artifacts.eventTypes.id'], [PaddedId, 'artifacts.eventTypes.id'],
91
91
  [NonAsciiPaddedId, 'artifacts.eventTypes.id']
@@ -77,6 +77,14 @@ export class EventScenario {
77
77
  ...(capture.uniqueEventType?.removedWithEventTypeIds ?? [])];
78
78
  return [...constrained, ...removedWith].some(id => id !== undefined && selectedIds.has(id));
79
79
  })) : compiled;
80
+ for (const type of eventTypes) {
81
+ for (const name of getRemovedConstraintNames(type)) {
82
+ if (!definitions.has(name)) {
83
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
84
+ 'Unresolved removal constraint name in the selected catalog.');
85
+ }
86
+ }
87
+ }
80
88
  constraints = new InProcessConstraints(definitions);
81
89
  for (const [name, capture] of definitions) {
82
90
  const ids = capture.uniqueConstraint?.eventDefinitions.map(entry => entry.eventTypeId) ??
@@ -86,7 +94,7 @@ export class EventScenario {
86
94
  ...(capture.uniqueEventType?.removedWithEventTypeIds ?? [])];
87
95
  if ([...ids, ...removedWith].some(id => id !== undefined && !selectedIds.has(id))) {
88
96
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
89
- 'Every constrained event type must be in the selected catalog.');
97
+ 'Every constrained event type must be in the selected catalog. Claiming and removal references must be complete.');
90
98
  }
91
99
  for (const entry of capture.uniqueConstraint?.eventDefinitions ?? []) {
92
100
  const type = eventTypes.find(type => getEventTypeFor(type).id.value === entry.eventTypeId)!;