@cratis/chronicle 6.22.1 → 6.24.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 (40) hide show
  1. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  2. package/dist/eventSequences/EventSequence.js +2 -27
  3. package/dist/eventSequences/EventSequence.js.map +1 -1
  4. package/dist/eventSequences/createAppendResult.d.ts +13 -0
  5. package/dist/eventSequences/createAppendResult.d.ts.map +1 -0
  6. package/dist/eventSequences/createAppendResult.js +31 -0
  7. package/dist/eventSequences/createAppendResult.js.map +1 -0
  8. package/dist/events/constraints/Constraints.d.ts +8 -3
  9. package/dist/events/constraints/Constraints.d.ts.map +1 -1
  10. package/dist/events/constraints/Constraints.js +135 -125
  11. package/dist/events/constraints/Constraints.js.map +1 -1
  12. package/dist/testing/EventScenario.d.ts.map +1 -1
  13. package/dist/testing/EventScenario.js +70 -10
  14. package/dist/testing/EventScenario.js.map +1 -1
  15. package/dist/testing/InProcessConstraints.d.ts +17 -0
  16. package/dist/testing/InProcessConstraints.d.ts.map +1 -0
  17. package/dist/testing/InProcessConstraints.js +142 -0
  18. package/dist/testing/InProcessConstraints.js.map +1 -0
  19. package/dist/testing/InProcessEventSequence.d.ts +5 -1
  20. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  21. package/dist/testing/InProcessEventSequence.js +43 -26
  22. package/dist/testing/InProcessEventSequence.js.map +1 -1
  23. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  24. package/dist/testing/ReactorScenario.js +3 -2
  25. package/dist/testing/ReactorScenario.js.map +1 -1
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/eventSequences/EventSequence.ts +3 -32
  28. package/eventSequences/createAppendResult.ts +44 -0
  29. package/events/constraints/Constraints.ts +57 -47
  30. package/events/constraints/for_Constraints/when_resolving_violation_messages.spec.ts +28 -0
  31. package/package.json +1 -1
  32. package/testing/EventScenario.constraint-discovery.spec.ts +69 -0
  33. package/testing/EventScenario.constraint-values.spec.ts +199 -0
  34. package/testing/EventScenario.constraints.spec.ts +415 -0
  35. package/testing/EventScenario.fixtures.spec.ts +5 -3
  36. package/testing/EventScenario.rejections.spec.ts +10 -3
  37. package/testing/EventScenario.ts +72 -11
  38. package/testing/InProcessConstraints.ts +148 -0
  39. package/testing/InProcessEventSequence.ts +42 -25
  40. package/testing/ReactorScenario.ts +3 -1
@@ -0,0 +1,415 @@
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 } from '../events/eventTypeDecorator.js';
9
+ import { unique } from '../events/constraints/unique.js';
10
+ import { constraint } from '../events/constraints/constraint.js';
11
+ import { compileConstraints } from '../events/constraints/Constraints.js';
12
+ import type { IConstraint } from '../events/constraints/IConstraint.js';
13
+ import type { IConstraintBuilder } from '../events/constraints/IConstraintBuilder.js';
14
+ import { removeConstraint } from '../events/constraints/removeConstraint.js';
15
+ import { ReactorScenario } from './ReactorScenario.js';
16
+ import { InProcessConstraints } from './InProcessConstraints.js';
17
+ import { reactor } from '../reactors/reactor.js';
18
+ import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
19
+
20
+ chai.should();
21
+ class OracleKeyClaimed {
22
+ @field(String) @unique('OracleKey', 'Already claimed: {PropertyName}={PropertyValue}') key: string;
23
+ constructor(key: string) { this.key = key; }
24
+ }
25
+ eventType('OracleKeyClaimed')(OracleKeyClaimed);
26
+ class OracleKeyShared {
27
+ @field(String) @unique('OracleKey') key: string;
28
+ constructor(key: string) { this.key = key; }
29
+ }
30
+ eventType('OracleKeyShared')(OracleKeyShared);
31
+ class OracleOnceRecorded {
32
+ @field(String) label: string;
33
+ constructor(label: string) { this.label = label; }
34
+ }
35
+ eventType('OracleOnceRecorded')(OracleOnceRecorded);
36
+ unique('OracleOnce')(OracleOnceRecorded);
37
+ class BatchUniqueRecorded {
38
+ @field(String) @unique('BatchUniqueKey') key: string;
39
+ constructor(key: string) { this.key = key; }
40
+ }
41
+ eventType('BatchUniqueRecorded')(BatchUniqueRecorded);
42
+ class OracleFluentOnce {
43
+ @field(String) label: string;
44
+ constructor(label: string) { this.label = label; }
45
+ }
46
+ eventType('OracleFluentOnce')(OracleFluentOnce);
47
+ class OracleNamedOnce {
48
+ @field(String) label: string;
49
+ constructor(label: string) { this.label = label; }
50
+ }
51
+ eventType('OracleNamedOnce')(OracleNamedOnce);
52
+ unique('OracleNamedOnce', 'Already recorded this kind of event')(OracleNamedOnce);
53
+ class OracleFluentOnceConstraint implements IConstraint {
54
+ define(builder: IConstraintBuilder) { builder.uniqueFor(OracleFluentOnce, undefined, 'OracleFluentOnceConstraint'); }
55
+ }
56
+ constraint('OracleFluentOnceConstraint')(OracleFluentOnceConstraint);
57
+ const types = [OracleKeyClaimed, OracleKeyShared, OracleOnceRecorded, BatchUniqueRecorded, OracleFluentOnce, OracleNamedOnce];
58
+ const scenario = () => new EventScenario({ artifacts: { eventTypes: types, constraints: [OracleFluentOnceConstraint] } });
59
+ type Input = { source: string; type: 'key' | 'shared' | 'once' | 'plain' | 'fluentOnce' | 'namedOnce'; value: string };
60
+ type Outcome = { success: boolean; sequences: string[];
61
+ wireViolations: Array<{ EventTypeId: string; SequenceNumber: string; ConstraintType: number;
62
+ ConstraintName: string; Message: string; Details: Record<string, string> }>;
63
+ violations: Array<{ id: string; message: string; details: Record<string, string> }>;
64
+ errors: string[]; next: string; history: Array<{ sequence: string; source: string; type: string; value: string }> };
65
+ type Fixture = { constraintOperations: Array<{ mode: 'single' | 'batch'; events: Input[] }>;
66
+ expected: { outcomes: Outcome[] } };
67
+ const fixture = JSON.parse(readFileSync(new URL('./fixtures/constraints.json', import.meta.url), 'utf8')) as Fixture;
68
+ const value = (input: Input) => input.type === 'key' ? new OracleKeyClaimed(input.value) :
69
+ input.type === 'shared' ? new OracleKeyShared(input.value) :
70
+ input.type === 'once' ? new OracleOnceRecorded(input.value) :
71
+ input.type === 'fluentOnce' ? new OracleFluentOnce(input.value) :
72
+ input.type === 'namedOnce' ? new OracleNamedOnce(input.value) : new BatchUniqueRecorded(input.value);
73
+
74
+ const unsupported = (action: () => unknown, operation: string): UnsupportedEventSequenceOperation => {
75
+ try { action(); throw new Error('Expected rejection'); }
76
+ catch (error) {
77
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
78
+ (error as Error).message.should.include(operation);
79
+ (error as Error).message.should.include('Use a kernel-backed test.');
80
+ return error as UnsupportedEventSequenceOperation;
81
+ }
82
+ };
83
+
84
+ describe('fixture-backed unscoped constraints', () => {
85
+ it('matches kernel violation messages, details, sequence normalization and batch rollback', async () => {
86
+ const subject = scenario();
87
+ const notifications = subject.eventSequence.appendOperations[Symbol.asyncIterator]();
88
+ const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
89
+ let resultCount = 0;
90
+ try {
91
+ for (const [index, operation] of fixture.constraintOperations.entries()) {
92
+ const expected = fixture.expected.outcomes[index];
93
+ const nextNotification = notifications.next();
94
+ const results = operation.mode === 'single'
95
+ ? [await subject.append(operation.events[0].source, value(operation.events[0]))]
96
+ : await subject.appendMany(operation.events.map(entry => ({ eventSourceId: entry.source, event: value(entry) })));
97
+ const notification = (await nextNotification).value!;
98
+ const wireViolations = validate.mock.results.at(-1)?.value as ReturnType<InProcessConstraints['validate']>;
99
+ wireViolations.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() }))
100
+ .should.deep.equal(expected.wireViolations);
101
+ notification.length.should.equal(operation.events.length);
102
+ notification.every(item => item.result.isSuccess === expected.success).should.be.true;
103
+ resultCount += operation.events.length;
104
+ subject.results.length.should.equal(resultCount);
105
+ results.length.should.equal(operation.events.length);
106
+ for (const result of results) {
107
+ result.isSuccess.should.equal(expected.success);
108
+ result.constraintViolations.map(item => ({ id: item.constraintId, message: item.message, details: item.details }))
109
+ .should.deep.equal(expected.violations);
110
+ result.errors.map(item => item.message).should.deep.equal(expected.errors);
111
+ result.sequenceNumber.value.toString().should.equal(expected.success ?
112
+ expected.sequences[results.indexOf(result)] : '0');
113
+ if (!expected.success) (await result.waitForCompletion()).should.deep.equal({ isSuccess: true, failedPartitions: [] });
114
+ }
115
+ subject.appendedEvents.map(item => ({ sequence: item.context.sequenceNumber.toString(),
116
+ source: item.context.eventSourceId, type: item.eventType.id.value,
117
+ value: item.content.key ?? item.content.label })).should.deep.equal(expected.history);
118
+ (await subject.eventSequence.getNextSequenceNumber()).value.toString().should.equal(expected.next);
119
+ }
120
+ subject.then.results.should.deep.equal(subject.results);
121
+ } finally {
122
+ validate.mockRestore();
123
+ await notifications.return?.();
124
+ }
125
+ });
126
+
127
+ it('discovers fluent constraints by default when the selected catalog omits constraints', async () => {
128
+ const subject = new EventScenario({ artifacts: { eventTypes: types } });
129
+ (await subject.append('A', new OracleFluentOnce('first'))).isSuccess.should.be.true;
130
+ (await subject.append('A', new OracleFluentOnce('second'))).constraintViolations[0]
131
+ .constraintId.should.equal('OracleFluentOnceConstraint');
132
+ });
133
+
134
+ it('rejects a globally discovered fluent contribution sharing a selected decorator name', () => {
135
+ class SelectedShared { @field(String) @unique('ScenarioSharedFromGlobal') key = 'Alpha'; }
136
+ eventType('SelectedShared')(SelectedShared);
137
+ class GlobalShared { @field(String) key = 'Alpha'; }
138
+ eventType('GlobalShared')(GlobalShared);
139
+ class GlobalSharedConstraint implements IConstraint {
140
+ define(builder: IConstraintBuilder) {
141
+ builder.unique(key => key.on(GlobalShared, event => event.key)
142
+ .withMessage('Global configured: {PropertyValue}'));
143
+ }
144
+ }
145
+ constraint('ScenarioSharedFromGlobal')(GlobalSharedConstraint);
146
+ const production = compileConstraints({ eventTypes: [SelectedShared], constraints: [GlobalSharedConstraint] });
147
+ production.get('ScenarioSharedFromGlobal')!.uniqueConstraint!.message.should.equal('Global configured: {PropertyValue}');
148
+ production.get('ScenarioSharedFromGlobal')!.uniqueConstraint!.eventDefinitions.length.should.equal(2);
149
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [SelectedShared] } }), 'artifacts.constraints')
150
+ .message.should.include('Every constrained event type must be in the selected catalog.');
151
+ });
152
+
153
+ it('rejects a global property decorator sharing a selected name instead of losing its message', () => {
154
+ class GlobalDecorated { @field(String) @unique('ScenarioSharedDecorators', 'Global message: {PropertyValue}') key = 'Alpha'; }
155
+ eventType('GlobalDecorated')(GlobalDecorated);
156
+ class SelectedDecorated { @field(String) @unique('ScenarioSharedDecorators') key = 'Alpha'; }
157
+ eventType('SelectedDecorated')(SelectedDecorated);
158
+ const production = compileConstraints({ eventTypes: [GlobalDecorated, SelectedDecorated], constraints: [] });
159
+ production.get('ScenarioSharedDecorators')!.uniqueConstraint!.message.should.equal('Global message: {PropertyValue}');
160
+ production.get('ScenarioSharedDecorators')!.uniqueConstraint!.eventDefinitions.length.should.equal(2);
161
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [SelectedDecorated] } }), 'artifacts.constraints')
162
+ .message.should.include('Every constrained event type must be in the selected catalog.');
163
+ });
164
+
165
+ it('rejects a same-named global class decorator as a multi-type unique event constraint', () => {
166
+ class GlobalOnce { @field(String) label = 'global'; }
167
+ eventType('GlobalOnce')(GlobalOnce);
168
+ unique('ScenarioSharedOnce')(GlobalOnce);
169
+ class SelectedOnce { @field(String) label = 'selected'; }
170
+ eventType('SelectedOnce')(SelectedOnce);
171
+ unique('ScenarioSharedOnce')(SelectedOnce);
172
+ const production = compileConstraints({ eventTypes: [GlobalOnce, SelectedOnce], constraints: [] });
173
+ production.get('ScenarioSharedOnce')!.uniqueEventType!.eventTypeIds.should.deep.equal(['GlobalOnce', 'SelectedOnce']);
174
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [SelectedOnce] } }), 'artifacts.constraints')
175
+ .message.should.include('multi-type unique event cycles');
176
+ });
177
+
178
+ it('rejects a global removal decorator targeting a selected constraint', () => {
179
+ class SelectedClaim { @field(String) @unique('ScenarioRemovedGlobally') key = 'Alpha'; }
180
+ eventType('SelectedClaim')(SelectedClaim);
181
+ class GlobalRemoval { @field(String) label = 'removed'; }
182
+ eventType('GlobalRemoval')(GlobalRemoval);
183
+ removeConstraint('ScenarioRemovedGlobally')(GlobalRemoval);
184
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [SelectedClaim] } }), 'artifacts.constraints')
185
+ .message.should.include('removal');
186
+ });
187
+
188
+ it('rejects a globally discovered fluent constraint referencing a selected removal event', () => {
189
+ class GlobalClaim { @field(String) key = 'Alpha'; }
190
+ eventType('GlobalClaim')(GlobalClaim);
191
+ class SelectedRemoval { @field(String) label = 'removed'; }
192
+ eventType('SelectedRemoval')(SelectedRemoval);
193
+ class GlobalRemovalConstraint implements IConstraint {
194
+ define(builder: IConstraintBuilder) {
195
+ builder.unique(key => key.on(GlobalClaim, event => event.key).removedWith(SelectedRemoval));
196
+ }
197
+ }
198
+ constraint('ScenarioGlobalRemoval')(GlobalRemovalConstraint);
199
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [SelectedRemoval] } }), 'artifacts.constraints')
200
+ .message.should.include('removal');
201
+ });
202
+
203
+ it('rejects a globally discovered constraint named by a selected removal decorator', () => {
204
+ class GlobalDecoratedClaim { @field(String) key = 'Alpha'; }
205
+ eventType('GlobalDecoratedClaim')(GlobalDecoratedClaim);
206
+ class SelectedDecoratedRemoval { @field(String) label = 'removed'; }
207
+ eventType('SelectedDecoratedRemoval')(SelectedDecoratedRemoval);
208
+ removeConstraint('ScenarioDecoratedGlobalRemoval')(SelectedDecoratedRemoval);
209
+ class DecoratedGlobalRemovalConstraint implements IConstraint {
210
+ define(builder: IConstraintBuilder) {
211
+ builder.unique(key => key.on(GlobalDecoratedClaim, event => event.key));
212
+ }
213
+ }
214
+ constraint('ScenarioDecoratedGlobalRemoval')(DecoratedGlobalRemovalConstraint);
215
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [SelectedDecoratedRemoval] } }), 'artifacts.constraints')
216
+ .message.should.include('removal');
217
+ });
218
+
219
+ it('isolates a selected catalog from unrelated globally discovered constraints', async () => {
220
+ class Unrelated { @field(String) label = 'unrelated'; }
221
+ eventType('Unrelated')(Unrelated);
222
+ class UnrelatedConstraint implements IConstraint {
223
+ define(builder: IConstraintBuilder) {
224
+ builder.unique(key => key.on(Unrelated, event => event.label).ignoreCasing());
225
+ }
226
+ }
227
+ constraint('UnrelatedConstraint')(UnrelatedConstraint);
228
+ const subject = new EventScenario({ artifacts: { eventTypes: types } });
229
+ (await subject.append('A', new OracleFluentOnce('first'))).isSuccess.should.be.true;
230
+ (await subject.append('A', new OracleFluentOnce('second'))).constraintViolations.length.should.equal(1);
231
+ });
232
+
233
+ it('uses the same compiler for an unscoped fluent constraint', async () => {
234
+ class FluentKey implements IConstraint {
235
+ define(builder: IConstraintBuilder) {
236
+ builder.unique(key => key.on(OracleKeyClaimed, event => event.key)
237
+ .on(OracleKeyShared, event => event.key)
238
+ .withMessage('Already claimed: {PropertyName}={PropertyValue}'));
239
+ }
240
+ }
241
+ constraint('OracleKey')(FluentKey);
242
+ const subject = new EventScenario({ artifacts: { eventTypes: types, constraints: [FluentKey] } });
243
+ (await subject.append('A', new OracleKeyClaimed('Alpha'))).isSuccess.should.be.true;
244
+ (await subject.append('B', new OracleKeyShared('Alpha'))).constraintViolations[0]
245
+ .message.should.equal(fixture.expected.outcomes[2].violations[0].message);
246
+ });
247
+
248
+ it('passes constraints through ReactorScenario and does not deliver rejected events', async () => {
249
+ const calls: string[] = [];
250
+ @reactor('constrained-reactor')
251
+ class ConstrainedReactor {
252
+ oracleKeyClaimed(event: OracleKeyClaimed) { calls.push(event.key); }
253
+ }
254
+ const subject = new ReactorScenario(ConstrainedReactor, { artifacts: { eventTypes: types } });
255
+ await subject.when.forEventSource('A').events(new OracleKeyClaimed('Alpha'));
256
+ await subject.when.forEventSource('B').events(new OracleKeyClaimed('Alpha'))
257
+ .then(() => { throw new Error('Constraint violation was delivered'); }, error => {
258
+ (error as Error).message.should.include('ReactorScenario action append failed');
259
+ (error as Error).message.should.include('OracleKey');
260
+ (error as Error).message.should.include('Already claimed: key=Alpha');
261
+ });
262
+ calls.should.deep.equal(['Alpha']);
263
+ subject.results.length.should.equal(1);
264
+ });
265
+
266
+ it('rolls back a violating given call without publishing setup results', async () => {
267
+ const subject = scenario();
268
+ await subject.given.forEventSource('A').events(new OracleKeyClaimed('Alpha'));
269
+ await subject.given.forEventSource('B').events(new OracleOnceRecorded('one'), new OracleKeyShared('Alpha'))
270
+ .then(() => { throw new Error('Violated setup accepted'); }, error => {
271
+ (error as Error).message.should.include('EventScenario given setup failed');
272
+ (error as Error).message.should.include('Already claimed: key=Alpha');
273
+ });
274
+ subject.appendedEvents.length.should.equal(1);
275
+ subject.results.length.should.equal(0);
276
+ (await subject.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
277
+ });
278
+
279
+ it('rejects unproven numeric constrained schemas at construction', () => {
280
+ class NumericConstrained { @field(Number) @unique('NumberKey') key = 1; }
281
+ eventType('NumericConstrained')(NumericConstrained);
282
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [NumericConstrained] } }), 'artifacts.constraints');
283
+ });
284
+
285
+ it('rejects a selected constraint covering an event outside the selected catalog', () => {
286
+ class OutOfCatalog implements IConstraint {
287
+ define(builder: IConstraintBuilder) {
288
+ builder.unique(key => key.on(OracleFluentOnce, event => event.label).on(OracleKeyShared, event => event.key));
289
+ }
290
+ }
291
+ constraint('OutOfCatalog')(OutOfCatalog);
292
+ const subject = scenario();
293
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [OracleFluentOnce], constraints: [OutOfCatalog] } }),
294
+ 'artifacts.constraints').message.should.include('Every constrained event type must be in the selected catalog.');
295
+ subject.results.length.should.equal(0);
296
+ subject.appendedEvents.length.should.equal(0);
297
+ });
298
+
299
+ it('reads a getter-backed selected constraint catalog only once', () => {
300
+ class OutOfCatalog implements IConstraint {
301
+ define(builder: IConstraintBuilder) {
302
+ builder.unique(key => key.on(OracleFluentOnce, event => event.label).on(OracleKeyShared, event => event.key));
303
+ }
304
+ }
305
+ constraint('GetterOutOfCatalog')(OutOfCatalog);
306
+ let reads = 0;
307
+ const artifacts = { eventTypes: [OracleFluentOnce], get constraints() { reads++; return reads === 1 ? [OutOfCatalog] : undefined; } };
308
+ unsupported(() => new EventScenario({ artifacts }), 'artifacts.constraints');
309
+ reads.should.equal(1);
310
+ });
311
+
312
+ it('rejects a selected constraint without @constraint metadata', () => {
313
+ class MissingMetadata implements IConstraint { define(_builder: IConstraintBuilder) {} }
314
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: types, constraints: [MissingMetadata] } }),
315
+ 'artifacts.constraints').message.should.include('Every selected constraint must have @constraint metadata.');
316
+ });
317
+
318
+ it('rejects overlapping property constraints and property plus event-type constraints at construction', () => {
319
+ class TwoProperties {
320
+ @field(String) @unique('FirstProperty') first = 'Alpha';
321
+ @field(String) @unique('SecondProperty') second = 'Beta';
322
+ }
323
+ eventType('TwoProperties')(TwoProperties);
324
+ class PropertyAndType { @field(String) @unique('PropertyName') name = 'Alpha'; }
325
+ eventType('PropertyAndType')(PropertyAndType);
326
+ unique('ClassName')(PropertyAndType);
327
+ for (const type of [TwoProperties, PropertyAndType]) {
328
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [type, OracleFluentOnce], constraints: [OracleFluentOnceConstraint] } }),
329
+ 'artifacts.constraints').message.should.include('Overlapping constraints');
330
+ }
331
+ });
332
+
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
+ it('rejects history with an unproven unique key without changing scenario state', async () => {
350
+ const subject = scenario();
351
+ await subject.append('A', new OracleKeyClaimed('Alpha'));
352
+ const history = subject.appendedEvents;
353
+ const incoming = subject.appendedEvents;
354
+ history[0].content.key = 1;
355
+ const compiled = compileConstraints({ eventTypes: types, constraints: [OracleFluentOnceConstraint] });
356
+ const checker = new InProcessConstraints(compiled);
357
+ unsupported(() => checker.validate(history, incoming), 'artifacts.constraints');
358
+ subject.appendedEvents[0].content.key.should.equal('Alpha');
359
+ subject.results.length.should.equal(1);
360
+ });
361
+
362
+ it('rejects removal, scoped, composite, case-insensitive and replacement before mutation', async () => {
363
+ const scoped = (configure: (builder: IConstraintBuilder) => void) => {
364
+ class InvalidConstraint implements IConstraint {
365
+ define(builder: IConstraintBuilder) { configure(builder); }
366
+ }
367
+ constraint('ScopedOrComposite')(InvalidConstraint);
368
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: types, constraints: [InvalidConstraint] } }), 'artifacts.constraints');
369
+ };
370
+ scoped(builder => builder.perEventStreamId().unique(key => key.on(OracleKeyClaimed, event => event.key)));
371
+ scoped(builder => builder.perEventStreamType().unique(key => key.on(OracleKeyClaimed, event => event.key)));
372
+ scoped(builder => builder.perEventSourceType().unique(key => key.on(OracleKeyClaimed, event => event.key)));
373
+ scoped(builder => builder.unique(key => key.on(OracleKeyClaimed, event => event.key, event => event.key)));
374
+ scoped(builder => builder.unique(key => key.on(OracleKeyClaimed, event => event.key).ignoreCasing()));
375
+ scoped(builder => builder.unique(key => key.on(OracleKeyClaimed, event => event.key))
376
+ .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
+ class AnotherOnce { @field(String) label = 'second'; }
383
+ eventType('AnotherOnce')(AnotherOnce);
384
+ unique('OracleOnce')(AnotherOnce);
385
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [...types, AnotherOnce], constraints: [OracleFluentOnceConstraint] } }), 'artifacts.constraints')
386
+ .message.should.include('multi-type unique event cycles');
387
+ const subject = scenario();
388
+ await subject.append('A', new OracleKeyClaimed('Alpha'));
389
+ 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('!'))]) {
393
+ await action().then(() => { throw new Error('Unsupported key accepted'); }, error => {
394
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
395
+ (error as Error).message.should.include('artifacts.constraints');
396
+ (error as Error).message.should.include('Use a kernel-backed test.');
397
+ });
398
+ subject.appendedEvents.length.should.equal(2);
399
+ subject.results.length.should.equal(2);
400
+ }
401
+ });
402
+
403
+ it('wraps throwing and duplicate fluent definitions as unsupported compiler errors', () => {
404
+ class Throws implements IConstraint { define(_builder: IConstraintBuilder) { throw new Error('Invalid definition'); } }
405
+ constraint('Throws')(Throws);
406
+ class First implements IConstraint { define(builder: IConstraintBuilder) { builder.uniqueFor(OracleOnceRecorded); } }
407
+ class Second implements IConstraint { define(builder: IConstraintBuilder) { builder.uniqueFor(OracleOnceRecorded); } }
408
+ constraint('Duplicate')(First);
409
+ constraint('Duplicate')(Second);
410
+ for (const constraints of [[Throws], [First, Second]]) {
411
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: types, constraints } }),
412
+ 'artifacts.constraints').message.should.include('compiler');
413
+ }
414
+ });
415
+ });
@@ -56,7 +56,7 @@ 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'].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'].includes(name)).map(name => ({
60
60
  name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
61
61
  }));
62
62
  const artifacts = { eventTypes: [OracleEventRecorded, AlternateRecorded, BoundaryRecorded], constraints: [] };
@@ -188,9 +188,11 @@ describe('when appending against committed kernel event fixtures', () => {
188
188
  });
189
189
 
190
190
 
191
- it('should reject unproven schemas, event registrations and constraints before appending', async () => {
191
+ it('should reject unproven schemas and registrations but discover supported constraints', async () => {
192
192
  unsupported(() => new EventScenario({ artifacts: { eventTypes: [UnprovenNumber] }, constraints: 'disabled' }), 'schema');
193
- unsupported(() => new EventScenario({ artifacts: { eventTypes: [ConstrainedEvent] } }), 'constraints');
193
+ const constrained = new EventScenario({ artifacts: { eventTypes: [ConstrainedEvent] } });
194
+ (await constrained.append('A', new ConstrainedEvent())).isSuccess.should.be.true;
195
+ (await constrained.append('A', new ConstrainedEvent())).constraintViolations.length.should.equal(1);
194
196
  unsupported(() => new EventScenario({ artifacts }), 'empty constraint catalog');
195
197
  unsupported(() => new EventScenario({ artifacts, constraints: 'disabled', eventStore: 'other-store' }), 'eventStore');
196
198
  const scenario = makeScenario();
@@ -28,7 +28,7 @@ class RemovesConstraint {
28
28
  @field(String) name = 'value';
29
29
  }
30
30
  eventType('RemovesConstraint')(RemovesConstraint);
31
- removeConstraint('name')(RemovesConstraint);
31
+ removeConstraint('OtherName')(RemovesConstraint);
32
32
  class Tagged {
33
33
  @field(String) name = 'value';
34
34
  }
@@ -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
- [PropertyUnique, 'artifacts.constraints'], [RemovesConstraint, 'artifacts.eventTypes.constraints'], [Tagged, 'tags'],
88
+ [RemovesConstraint, 'artifacts.eventTypes.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']
@@ -93,11 +93,18 @@ describe('when the fixture-bounded event sequence encounters unproven operations
93
93
  it(`should reject ${type.name} during catalog validation`, () => {
94
94
  const subject = scenario();
95
95
  rejected(() => new EventScenario({ artifacts: { eventTypes: [type] },
96
- ...(type === PropertyUnique || type === RemovesConstraint ? {} : { constraints: 'disabled' as const }) }), operation);
96
+ ...(type === RemovesConstraint ? {} : { constraints: 'disabled' as const }) }), operation);
97
97
  clean(subject);
98
98
  });
99
99
  }
100
100
 
101
+ it('should discover property-level uniqueness instead of silently discarding it', async () => {
102
+ const subject = new EventScenario({ artifacts: { eventTypes: [PropertyUnique] } });
103
+ (await subject.append('A', new PropertyUnique())).isSuccess.should.be.true;
104
+ (await subject.append('B', new PropertyUnique())).constraintViolations[0].constraintId.should.equal('name');
105
+ subject.appendedEvents.length.should.equal(1);
106
+ });
107
+
101
108
  it('should reject duplicate event IDs in the catalog', () => {
102
109
  const subject = scenario();
103
110
  rejected(() => new EventScenario({ artifacts: { eventTypes: [Recorded, DuplicateId] }, constraints: 'disabled' }), 'artifacts.eventTypes');
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { DefaultClientArtifactsProvider } from '../artifacts/DefaultClientArtifactsProvider.js';
5
5
  import { TypeDiscoverer } from '../types/TypeDiscoverer.js';
6
+ import { TypeIntrospector } from '../types/TypeIntrospector.js';
6
7
  import type { AppendOptions } from '../eventSequences/AppendOptions.js';
7
8
  import type { AppendResult } from '../eventSequences/AppendResult.js';
8
9
  import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
@@ -10,8 +11,12 @@ import type { AppendedEvent } from '../events/AppendedEvent.js';
10
11
  import type { IEventSequence } from '../eventSequences/IEventSequence.js';
11
12
  import type { IEventLog } from '../eventSequences/IEventLog.js';
12
13
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
13
- import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
14
+ import { getEventTypeFor, getEventTypeMetadata } from '../events/eventTypeDecorator.js';
15
+ import { compileConstraints } from '../events/constraints/Constraints.js';
14
16
  import { getUniqueEventMetadata, getUniquePropertyMetadata } from '../events/constraints/unique.js';
17
+ import { getRemovedConstraintNames } from '../events/constraints/removeConstraint.js';
18
+ import { getConstraintMetadata } from '../events/constraints/constraint.js';
19
+ import { InProcessConstraints } from './InProcessConstraints.js';
15
20
  import type { EventScenarioOptions } from './EventScenarioOptions.js';
16
21
  import { EventScenarioGivenBuilder } from './EventScenarioGivenBuilder.js';
17
22
  import { EventScenarioWhenBuilder } from './EventScenarioWhenBuilder.js';
@@ -19,6 +24,11 @@ import { InProcessEventSequence } from './InProcessEventSequence.js';
19
24
  import { registerScenarioSeed } from './EventScenarioSeed.js';
20
25
  import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
21
26
 
27
+ function hasConstraintDecorators(type: Function): boolean {
28
+ return getUniqueEventMetadata(type) !== undefined || getRemovedConstraintNames(type).length > 0 ||
29
+ TypeIntrospector.getTrackedProperties(type).some(property => getUniquePropertyMetadata(type, property) !== undefined);
30
+ }
31
+
22
32
  /** A kernel-free, fixture-bounded append scenario. No observer runs automatically. */
23
33
  export class EventScenario {
24
34
  readonly eventSequence: IEventSequence;
@@ -26,25 +36,76 @@ export class EventScenario {
26
36
  readonly when: EventScenarioWhenBuilder;
27
37
 
28
38
  constructor(options: EventScenarioOptions = {}) {
29
- const artifacts = options.artifacts ?? new DefaultClientArtifactsProvider(TypeDiscoverer.default);
39
+ const selectedArtifacts = options.artifacts;
40
+ const selectedConstraints = selectedArtifacts?.constraints;
41
+ const artifacts = selectedArtifacts ?? new DefaultClientArtifactsProvider(TypeDiscoverer.default);
30
42
  const eventTypes = [...artifacts.eventTypes];
31
- if (options.artifacts?.constraints?.length === 0 && options.constraints !== 'disabled') {
43
+ if (selectedConstraints?.length === 0 && options.constraints !== 'disabled') {
32
44
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', 'empty constraint catalog',
33
45
  'An empty selected catalog cannot silently disable constraint discovery; set constraints: disabled explicitly.');
34
46
  }
35
- const discovered = options.artifacts?.constraints ?? new DefaultClientArtifactsProvider(TypeDiscoverer.default).constraints;
36
- if (options.constraints !== 'disabled' && (discovered.length || eventTypes.some(type => {
37
- const metadata = getEventTypeMetadata(type);
38
- return getUniqueEventMetadata(type) || [...(metadata?.members.keys() ?? [])].some(key => getUniquePropertyMetadata(type, key));
39
- }))) {
40
- throw new UnsupportedEventSequenceOperation('artifacts.constraints', discovered.map(type => type.name).join(', ') || 'event metadata',
41
- 'Constraint definitions are rejected until kernel-backed constraint fixtures are available.');
47
+ const discoveredArtifacts = selectedArtifacts && selectedConstraints === undefined
48
+ ? new DefaultClientArtifactsProvider(TypeDiscoverer.default) : undefined;
49
+ const discovered = selectedConstraints ?? discoveredArtifacts?.constraints ?? artifacts.constraints;
50
+ let constraints: InProcessConstraints | undefined;
51
+ if (options.constraints !== 'disabled') {
52
+ for (const type of discovered ?? []) {
53
+ if (!getConstraintMetadata(type)) throw new UnsupportedEventSequenceOperation('artifacts.constraints', type.name,
54
+ 'Every selected constraint must have @constraint metadata.');
55
+ }
56
+ try {
57
+ // Keep global decorator contributions. A shadowed ID is accepted only when neither
58
+ // constructor has constraint decorators, so either one compiles to the same constraints.
59
+ const compiledEventTypes = [...new Set([...(discoveredArtifacts?.eventTypes ?? []), ...eventTypes])];
60
+ const constructorsById = new Map<string, (typeof eventTypes)[number]>();
61
+ for (const type of compiledEventTypes) {
62
+ const id = getEventTypeFor(type).id.value;
63
+ const previous = constructorsById.get(id);
64
+ if (previous && previous !== type && (hasConstraintDecorators(previous) || hasConstraintDecorators(type))) {
65
+ throw new UnsupportedEventSequenceOperation('artifacts.eventTypes', id,
66
+ 'Conflicting constructors share an event type ID; constraint discovery cannot choose one.');
67
+ }
68
+ constructorsById.set(id, type);
69
+ }
70
+ const compiled = compileConstraints({ eventTypes: [...constructorsById.values()], constraints: discovered ?? [] });
71
+ const selectedIds = new Set(eventTypes.map(type => getEventTypeFor(type).id.value));
72
+ const definitions = discoveredArtifacts ? new Map([...compiled].filter(([, capture]) => {
73
+ const constrained = capture.uniqueConstraint?.eventDefinitions.map(entry => entry.eventTypeId) ??
74
+ capture.uniqueEventType?.eventTypeIds ?? [capture.uniqueEventType?.eventTypeId];
75
+ const removedWith = [...(capture.uniqueConstraint?.removedWithEventTypeIds ?? []),
76
+ capture.uniqueConstraint?.removedWithEventTypeId,
77
+ ...(capture.uniqueEventType?.removedWithEventTypeIds ?? [])];
78
+ return [...constrained, ...removedWith].some(id => id !== undefined && selectedIds.has(id));
79
+ })) : compiled;
80
+ constraints = new InProcessConstraints(definitions);
81
+ for (const [name, capture] of definitions) {
82
+ const ids = capture.uniqueConstraint?.eventDefinitions.map(entry => entry.eventTypeId) ??
83
+ capture.uniqueEventType?.eventTypeIds ?? [capture.uniqueEventType?.eventTypeId];
84
+ const removedWith = [...(capture.uniqueConstraint?.removedWithEventTypeIds ?? []),
85
+ capture.uniqueConstraint?.removedWithEventTypeId,
86
+ ...(capture.uniqueEventType?.removedWithEventTypeIds ?? [])];
87
+ if ([...ids, ...removedWith].some(id => id !== undefined && !selectedIds.has(id))) {
88
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
89
+ 'Every constrained event type must be in the selected catalog.');
90
+ }
91
+ for (const entry of capture.uniqueConstraint?.eventDefinitions ?? []) {
92
+ const type = eventTypes.find(type => getEventTypeFor(type).id.value === entry.eventTypeId)!;
93
+ if (!['string', 'boolean'].includes(getEventTypeMetadata(type)?.schema.properties?.[entry.properties[0]]?.type ?? '')) {
94
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
95
+ 'The constrained property must be a schema-backed string or boolean.');
96
+ }
97
+ }
98
+ }
99
+ } catch (error) {
100
+ if (error instanceof UnsupportedEventSequenceOperation) throw error;
101
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', 'compiler', `Invalid constraint definition: ${String(error)}.`);
102
+ }
42
103
  }
43
104
  if (artifacts.eventTypeMigrations?.length) {
44
105
  throw new UnsupportedEventSequenceOperation('artifacts.eventTypeMigrations', artifacts.eventTypeMigrations.map(type => type.name).join(', '),
45
106
  'Migrations are not fixture-backed.');
46
107
  }
47
- const sequence = new InProcessEventSequence(options, eventTypes);
108
+ const sequence = new InProcessEventSequence(options, eventTypes, constraints);
48
109
  this.eventSequence = sequence;
49
110
  registerScenarioSeed(this, (source, events) => sequence.seed(source, events));
50
111
  this.given = new EventScenarioGivenBuilder(this);