@cratis/chronicle 6.23.0 → 6.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +10 -3
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/InProcessConstraints.d.ts +5 -0
- package/dist/testing/InProcessConstraints.d.ts.map +1 -1
- package/dist/testing/InProcessConstraints.js +90 -41
- package/dist/testing/InProcessConstraints.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +13 -13
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/package.json +1 -1
- package/testing/EventScenario.constraint-lifecycle.spec.ts +231 -0
- package/testing/EventScenario.constraint-values.spec.ts +199 -0
- package/testing/EventScenario.constraints.spec.ts +19 -29
- package/testing/EventScenario.fixtures.spec.ts +2 -1
- package/testing/EventScenario.rejections.spec.ts +1 -1
- package/testing/EventScenario.ts +11 -3
- package/testing/InProcessConstraints.ts +90 -41
- package/testing/InProcessEventSequence.ts +14 -13
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
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 { ConceptAs, field } from '@cratis/fundamentals';
|
|
8
|
+
import { eventType, getEventTypeMetadata } from '../events/eventTypeDecorator.js';
|
|
9
|
+
import { compileConstraints } from '../events/constraints/Constraints.js';
|
|
10
|
+
import { unique } from '../events/constraints/unique.js';
|
|
11
|
+
import { constraint } from '../events/constraints/constraint.js';
|
|
12
|
+
import type { IConstraint } from '../events/constraints/IConstraint.js';
|
|
13
|
+
import type { IConstraintBuilder } from '../events/constraints/IConstraintBuilder.js';
|
|
14
|
+
import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
|
|
15
|
+
import { InProcessConstraints } from './InProcessConstraints.js';
|
|
16
|
+
|
|
17
|
+
chai.should();
|
|
18
|
+
class OracleDomainText {
|
|
19
|
+
@field(String) key: string;
|
|
20
|
+
constructor(key: string) { this.key = key; }
|
|
21
|
+
}
|
|
22
|
+
eventType('OracleDomainText')(OracleDomainText);
|
|
23
|
+
class OracleDomainShared {
|
|
24
|
+
@field(String) key: string;
|
|
25
|
+
constructor(key: string) { this.key = key; }
|
|
26
|
+
}
|
|
27
|
+
eventType('OracleDomainShared')(OracleDomainShared);
|
|
28
|
+
class OracleDomainFlag {
|
|
29
|
+
@field(Boolean) key: boolean;
|
|
30
|
+
constructor(key: boolean) { this.key = key; }
|
|
31
|
+
}
|
|
32
|
+
eventType('OracleDomainFlag')(OracleDomainFlag);
|
|
33
|
+
class TextKey implements IConstraint {
|
|
34
|
+
define(builder: IConstraintBuilder) {
|
|
35
|
+
builder.unique(key => key.on(OracleDomainText, event => event.key).on(OracleDomainShared, event => event.key)
|
|
36
|
+
.withMessage('Taken: {PropertyValue}'));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
constraint('OracleDomainKey')(TextKey);
|
|
40
|
+
class ScalarKey implements IConstraint {
|
|
41
|
+
define(builder: IConstraintBuilder) {
|
|
42
|
+
builder.unique(key => key.on(OracleDomainText, event => event.key).on(OracleDomainShared, event => event.key)
|
|
43
|
+
.on(OracleDomainFlag, event => event.key).withMessage('Taken: {PropertyValue}'));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
constraint('OracleDomainKey')(ScalarKey);
|
|
47
|
+
|
|
48
|
+
type Input = { source: string; type: 'string' | 'shared' | 'boolean'; value: string | boolean;
|
|
49
|
+
sourceType?: string; streamType?: string; streamId?: string };
|
|
50
|
+
type Routing = { sourceType?: string; streamType?: string; streamId?: string };
|
|
51
|
+
type History = { sequence: string; source: string; sourceType: string; streamType: string; streamId: string;
|
|
52
|
+
type: string; content: { key: string | boolean }; hash: string };
|
|
53
|
+
type Outcome = { success: boolean; sequences: string[]; violations: Array<{ id: string; message: string; details: Record<string, string> }>;
|
|
54
|
+
wireViolations: Array<{ EventTypeId: string; SequenceNumber: string; ConstraintType: number;
|
|
55
|
+
ConstraintName: string; Message: string; Details: Record<string, string> }>;
|
|
56
|
+
errors: string[]; history: History[]; next: string };
|
|
57
|
+
type Fixture = { isolatedConstraintOperations: Array<{ mode: 'single' | 'batch'; events: Input[]; options?: Routing }>;
|
|
58
|
+
eventSchemas: Record<string, { eventTypeId: string; properties: Record<string, string> }>;
|
|
59
|
+
constraintDefinitions: Array<{ kind: string; name: string; events: Array<{ type: string; properties: string[] }>;
|
|
60
|
+
ignoreCasing: boolean; removedWithEventTypeIds: string[]; message: string }>;
|
|
61
|
+
expected: { outcomes: Outcome[] } };
|
|
62
|
+
const eventTypes = [OracleDomainText, OracleDomainShared, OracleDomainFlag];
|
|
63
|
+
const aliases = { string: OracleDomainText, shared: OracleDomainShared, boolean: OracleDomainFlag };
|
|
64
|
+
const aliasesById = Object.fromEntries(Object.entries(aliases).map(([alias, type]) => [getEventTypeMetadata(type)!.eventType.id.value, alias]));
|
|
65
|
+
const load = (name: string): Fixture => JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), 'utf8')) as Fixture;
|
|
66
|
+
const event = (entry: Input) => entry.type === 'string' ? new OracleDomainText(entry.value as string) :
|
|
67
|
+
entry.type === 'shared' ? new OracleDomainShared(entry.value as string) : new OracleDomainFlag(entry.value as boolean);
|
|
68
|
+
const historyOf = (scenario: EventScenario): History[] => scenario.appendedEvents.map(entry => ({
|
|
69
|
+
sequence: entry.context.sequenceNumber.toString(), source: entry.context.eventSourceId,
|
|
70
|
+
sourceType: entry.context.eventSourceType, streamType: entry.context.eventStreamType, streamId: entry.context.eventStreamId,
|
|
71
|
+
type: entry.eventType.id.value, content: entry.content as { key: string | boolean }, hash: entry.context.hash
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
describe('fixture-backed unique scalar values', () => {
|
|
75
|
+
for (const name of ['constraints-isolation', 'constraints-key-domain']) {
|
|
76
|
+
it(`matches the packaged kernel ${name} after every operation`, async () => {
|
|
77
|
+
const fixture = load(name);
|
|
78
|
+
const constraints = [name === 'constraints-isolation' ? TextKey : ScalarKey];
|
|
79
|
+
const compiled = compileConstraints({ eventTypes, constraints });
|
|
80
|
+
Object.fromEntries(Object.entries(aliases).map(([alias, type]) => {
|
|
81
|
+
const metadata = getEventTypeMetadata(type)!;
|
|
82
|
+
return [alias, { eventTypeId: metadata.eventType.id.value,
|
|
83
|
+
properties: Object.fromEntries(Object.entries(metadata.schema.properties!).map(([key, property]) => [key, property.type])) }];
|
|
84
|
+
})).should.deep.equal(fixture.eventSchemas);
|
|
85
|
+
[...compiled].map(([key, capture]) => ({
|
|
86
|
+
kind: 'uniqueProperty', name: key,
|
|
87
|
+
events: capture.uniqueConstraint!.eventDefinitions.map(entry => ({ type: aliasesById[entry.eventTypeId], properties: entry.properties })),
|
|
88
|
+
ignoreCasing: capture.uniqueConstraint!.ignoreCasing,
|
|
89
|
+
removedWithEventTypeIds: capture.uniqueConstraint!.removedWithEventTypeIds ?? [],
|
|
90
|
+
message: capture.uniqueConstraint!.message ?? ''
|
|
91
|
+
})).should.deep.equal(fixture.constraintDefinitions);
|
|
92
|
+
fixture.isolatedConstraintOperations.length.should.be.greaterThan(0);
|
|
93
|
+
fixture.expected.outcomes.length.should.equal(fixture.isolatedConstraintOperations.length);
|
|
94
|
+
const scenario = new EventScenario({ artifacts: { eventTypes, constraints } });
|
|
95
|
+
const notifications = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
|
|
96
|
+
const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
|
|
97
|
+
let count = 0;
|
|
98
|
+
try {
|
|
99
|
+
for (const [index, operation] of fixture.isolatedConstraintOperations.entries()) {
|
|
100
|
+
const expected = fixture.expected.outcomes[index];
|
|
101
|
+
const before = historyOf(scenario);
|
|
102
|
+
const resultCount = scenario.results.length;
|
|
103
|
+
const nextNotification = notifications.next();
|
|
104
|
+
const results = operation.mode === 'single'
|
|
105
|
+
? [await scenario.append(operation.events[0].source, event(operation.events[0]))]
|
|
106
|
+
: await scenario.appendMany(operation.events.map(entry => ({ eventSourceId: entry.source, event: event(entry),
|
|
107
|
+
eventSourceType: entry.sourceType ?? operation.options?.sourceType,
|
|
108
|
+
eventStreamType: entry.streamType ?? operation.options?.streamType,
|
|
109
|
+
eventStreamId: entry.streamId ?? operation.options?.streamId })));
|
|
110
|
+
const notification = (await nextNotification).value!;
|
|
111
|
+
const raw = validate.mock.results.at(-1)?.value as ReturnType<InProcessConstraints['validate']>;
|
|
112
|
+
raw.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() }))
|
|
113
|
+
.should.deep.equal(expected.wireViolations);
|
|
114
|
+
notification.length.should.equal(operation.events.length);
|
|
115
|
+
notification.every(item => item.result.isSuccess === expected.success).should.be.true;
|
|
116
|
+
count += operation.events.length;
|
|
117
|
+
scenario.results.length.should.equal(count);
|
|
118
|
+
results.length.should.equal(operation.events.length);
|
|
119
|
+
for (const [position, result] of results.entries()) {
|
|
120
|
+
result.isSuccess.should.equal(expected.success);
|
|
121
|
+
result.sequenceNumber.value.toString().should.equal(expected.success ? expected.sequences[position] : '0');
|
|
122
|
+
result.constraintViolations.map(item => ({ id: item.constraintId, message: item.message, details: item.details }))
|
|
123
|
+
.should.deep.equal(expected.violations);
|
|
124
|
+
result.errors.map(item => item.message).should.deep.equal(expected.errors);
|
|
125
|
+
}
|
|
126
|
+
if (!expected.success) {
|
|
127
|
+
historyOf(scenario).should.deep.equal(before);
|
|
128
|
+
scenario.results.length.should.equal(resultCount + operation.events.length);
|
|
129
|
+
}
|
|
130
|
+
historyOf(scenario).should.deep.equal(expected.history);
|
|
131
|
+
(await scenario.eventSequence.getNextSequenceNumber()).value.toString().should.equal(expected.next);
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
validate.mockRestore();
|
|
135
|
+
await notifications.return?.();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it('accepts serialized unconstrained concepts and dates but rejects a constrained date', async () => {
|
|
141
|
+
class ScenarioLabel extends ConceptAs<string> { static readonly valueType = String; }
|
|
142
|
+
class UnconstrainedValue {
|
|
143
|
+
@field(ScenarioLabel) concept = new ScenarioLabel('example');
|
|
144
|
+
@field(String) date = new Date('2025-01-02T03:04:05.000Z');
|
|
145
|
+
}
|
|
146
|
+
eventType('OracleUnconstrainedValue')(UnconstrainedValue);
|
|
147
|
+
class ConstrainedValue {
|
|
148
|
+
@field(String) @unique('OracleConstrainedValue') key = new Date('2025-01-02T03:04:05.000Z');
|
|
149
|
+
}
|
|
150
|
+
eventType('OracleConstrainedValue')(ConstrainedValue);
|
|
151
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [UnconstrainedValue, ConstrainedValue] } });
|
|
152
|
+
const date = new Date('2025-01-02T03:04:05.000Z');
|
|
153
|
+
(await scenario.append('A', new UnconstrainedValue())).isSuccess.should.be.true;
|
|
154
|
+
scenario.appendedEvents[0].content.should.deep.equal({ concept: 'example', date: date.toISOString() });
|
|
155
|
+
await scenario.append('B', new ConstrainedValue()).then(() => { throw new Error('Constrained date accepted'); }, error => {
|
|
156
|
+
(error instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
157
|
+
(error as Error).message.should.include('append.content');
|
|
158
|
+
(error as Error).message.should.include('Use a kernel-backed test.');
|
|
159
|
+
});
|
|
160
|
+
scenario.appendedEvents.length.should.equal(1);
|
|
161
|
+
scenario.results.length.should.equal(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('rejects conversions and strings outside the captured domain without changing state', async () => {
|
|
165
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [OracleDomainText, OracleDomainShared, OracleDomainFlag], constraints: [ScalarKey] } });
|
|
166
|
+
(await scenario.append('A', new OracleDomainText('0'))).isSuccess.should.be.true;
|
|
167
|
+
const before = historyOf(scenario);
|
|
168
|
+
const results = scenario.results;
|
|
169
|
+
const unprovenKeys = ['!', '#', '%', '&', "'", '(', ')', '*', '+', ',', '/', ';', '<', '=', '>', '?', '[', ']', '^', '`', '~'];
|
|
170
|
+
const invalidContent: unknown[] = [1, 0, 1.5, undefined, [], {}, new Date(), 'É', 'e\u0301', '"', '\\', '\n'];
|
|
171
|
+
// The production serializer throws on null before checkedContent can run.
|
|
172
|
+
for (const [value, operation] of [
|
|
173
|
+
...unprovenKeys.map(value => [value, 'artifacts.constraints']),
|
|
174
|
+
...invalidContent.map(value => [value, 'append.content']),
|
|
175
|
+
[null, 'append.serialization']
|
|
176
|
+
] as Array<[unknown, string]>) {
|
|
177
|
+
const bad = new OracleDomainText('valid');
|
|
178
|
+
Object.assign(bad, { key: value });
|
|
179
|
+
await scenario.append('B', bad).then(() => { throw new Error('Unsupported value accepted'); }, error => {
|
|
180
|
+
(error instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
181
|
+
(error as Error).message.should.include(operation, `value: ${JSON.stringify(value)}; error: ${(error as Error).message}`);
|
|
182
|
+
(error as Error).message.should.include('Use a kernel-backed test.');
|
|
183
|
+
});
|
|
184
|
+
historyOf(scenario).should.deep.equal(before);
|
|
185
|
+
scenario.results.should.deep.equal(results);
|
|
186
|
+
(await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
|
|
187
|
+
}
|
|
188
|
+
const wrong = new OracleDomainFlag(true);
|
|
189
|
+
Object.assign(wrong, { key: 'true' });
|
|
190
|
+
await scenario.appendMany([{ eventSourceId: 'C', event: new OracleDomainText('new') },
|
|
191
|
+
{ eventSourceId: 'D', event: wrong }]).then(() => { throw new Error('Mismatched batch accepted'); }, error => {
|
|
192
|
+
(error instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
193
|
+
(error as Error).message.should.include('appendMany.content');
|
|
194
|
+
(error as Error).message.should.include('Use a kernel-backed test.');
|
|
195
|
+
});
|
|
196
|
+
historyOf(scenario).should.deep.equal(before);
|
|
197
|
+
scenario.results.should.deep.equal(results);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -175,6 +175,18 @@ describe('fixture-backed unscoped constraints', () => {
|
|
|
175
175
|
.message.should.include('multi-type unique event cycles');
|
|
176
176
|
});
|
|
177
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('Removal');
|
|
187
|
+
message.should.include('Use a kernel-backed test.');
|
|
188
|
+
});
|
|
189
|
+
|
|
178
190
|
it('rejects a global removal decorator targeting a selected constraint', () => {
|
|
179
191
|
class SelectedClaim { @field(String) @unique('ScenarioRemovedGlobally') key = 'Alpha'; }
|
|
180
192
|
eventType('SelectedClaim')(SelectedClaim);
|
|
@@ -276,10 +288,10 @@ describe('fixture-backed unscoped constraints', () => {
|
|
|
276
288
|
(await subject.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
|
|
277
289
|
});
|
|
278
290
|
|
|
279
|
-
it('rejects
|
|
280
|
-
class
|
|
281
|
-
eventType('
|
|
282
|
-
unsupported(() => new EventScenario({ artifacts: { eventTypes: [
|
|
291
|
+
it('rejects unproven numeric constrained schemas at construction', () => {
|
|
292
|
+
class NumericConstrained { @field(Number) @unique('NumberKey') key = 1; }
|
|
293
|
+
eventType('NumericConstrained')(NumericConstrained);
|
|
294
|
+
unsupported(() => new EventScenario({ artifacts: { eventTypes: [NumericConstrained] } }), 'artifacts.constraints');
|
|
283
295
|
});
|
|
284
296
|
|
|
285
297
|
it('rejects a selected constraint covering an event outside the selected catalog', () => {
|
|
@@ -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
|
|
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,11 +370,6 @@ 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);
|
|
@@ -387,11 +378,10 @@ describe('fixture-backed unscoped constraints', () => {
|
|
|
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('
|
|
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;
|
|
384
|
+
(error as Error).message.should.include('artifacts.constraints');
|
|
395
385
|
(error as Error).message.should.include('Use a kernel-backed test.');
|
|
396
386
|
});
|
|
397
387
|
subject.appendedEvents.length.should.equal(2);
|
|
@@ -56,7 +56,8 @@ 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'
|
|
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'].includes(name)).map(name => ({
|
|
60
61
|
name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
|
|
61
62
|
}));
|
|
62
63
|
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.
|
|
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']
|
package/testing/EventScenario.ts
CHANGED
|
@@ -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,13 +94,13 @@ 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)!;
|
|
93
|
-
if (getEventTypeMetadata(type)?.schema.properties?.[entry.properties[0]]?.type
|
|
101
|
+
if (!['string', 'boolean'].includes(getEventTypeMetadata(type)?.schema.properties?.[entry.properties[0]]?.type ?? '')) {
|
|
94
102
|
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
95
|
-
'The constrained property must be a schema-backed string.');
|
|
103
|
+
'The constrained property must be a schema-backed string or boolean.');
|
|
96
104
|
}
|
|
97
105
|
}
|
|
98
106
|
}
|