@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.
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +2 -27
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/createAppendResult.d.ts +13 -0
- package/dist/eventSequences/createAppendResult.d.ts.map +1 -0
- package/dist/eventSequences/createAppendResult.js +31 -0
- package/dist/eventSequences/createAppendResult.js.map +1 -0
- package/dist/events/constraints/Constraints.d.ts +8 -3
- package/dist/events/constraints/Constraints.d.ts.map +1 -1
- package/dist/events/constraints/Constraints.js +135 -125
- package/dist/events/constraints/Constraints.js.map +1 -1
- package/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +70 -10
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/InProcessConstraints.d.ts +17 -0
- package/dist/testing/InProcessConstraints.d.ts.map +1 -0
- package/dist/testing/InProcessConstraints.js +142 -0
- package/dist/testing/InProcessConstraints.js.map +1 -0
- package/dist/testing/InProcessEventSequence.d.ts +5 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +43 -26
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/testing/ReactorScenario.d.ts.map +1 -1
- package/dist/testing/ReactorScenario.js +3 -2
- package/dist/testing/ReactorScenario.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventSequence.ts +3 -32
- package/eventSequences/createAppendResult.ts +44 -0
- package/events/constraints/Constraints.ts +57 -47
- package/events/constraints/for_Constraints/when_resolving_violation_messages.spec.ts +28 -0
- package/package.json +1 -1
- package/testing/EventScenario.constraint-discovery.spec.ts +69 -0
- package/testing/EventScenario.constraint-values.spec.ts +199 -0
- package/testing/EventScenario.constraints.spec.ts +415 -0
- package/testing/EventScenario.fixtures.spec.ts +5 -3
- package/testing/EventScenario.rejections.spec.ts +10 -3
- package/testing/EventScenario.ts +72 -11
- package/testing/InProcessConstraints.ts +148 -0
- package/testing/InProcessEventSequence.ts +42 -25
- package/testing/ReactorScenario.ts +3 -1
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { ConstraintType } from '@cratis/chronicle.contracts';
|
|
5
5
|
import type { ConstraintViolation } from '../../eventSequences/ConstraintViolation.js';
|
|
6
|
-
import { IClientArtifactsProvider } from '../../artifacts/index.js';
|
|
7
|
-
import { ChronicleConnection } from '../../connection/index.js';
|
|
6
|
+
import type { IClientArtifactsProvider } from '../../artifacts/index.js';
|
|
7
|
+
import type { ChronicleConnection } from '../../connection/index.js';
|
|
8
8
|
import { ConstraintId } from './ConstraintId.js';
|
|
9
9
|
import { IConstraint } from './IConstraint.js';
|
|
10
10
|
import { IConstraints } from './IConstraints.js';
|
|
@@ -35,38 +35,22 @@ function assertMatchingScope(name: string, left: ConstraintScopeCapture, right:
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
* @param _eventStore - The name of the event store these constraints belong to.
|
|
45
|
-
* @param _connection - The connection used to communicate with the Kernel.
|
|
46
|
-
* @param _clientArtifacts - Provider for discovered client artifact types.
|
|
47
|
-
*/
|
|
48
|
-
constructor(
|
|
49
|
-
private readonly _eventStore: string,
|
|
50
|
-
private readonly _connection: ChronicleConnection,
|
|
51
|
-
private readonly _clientArtifacts: IClientArtifactsProvider
|
|
52
|
-
) {}
|
|
53
|
-
|
|
54
|
-
/** @inheritdoc */
|
|
55
|
-
async discover(): Promise<void> {
|
|
56
|
-
this._captures.clear();
|
|
57
|
-
const fluentIds = new Set<string>();
|
|
58
|
-
for (const type of this._clientArtifacts.constraints) {
|
|
59
|
-
const metadata = getConstraintMetadata(type);
|
|
38
|
+
/** Compile decorator and fluent constraints using the production registration rules. */
|
|
39
|
+
export function compileConstraints(provider: Pick<IClientArtifactsProvider, 'eventTypes' | 'constraints'>): Map<string, ConstraintCapture> {
|
|
40
|
+
const captures = new Map<string, ConstraintCapture>();
|
|
41
|
+
const fluentIds = new Set<string>();
|
|
42
|
+
for (const definition of provider.constraints) {
|
|
43
|
+
const metadata = getConstraintMetadata(definition);
|
|
60
44
|
if (!metadata) continue;
|
|
61
|
-
|
|
62
|
-
fluentIds.
|
|
63
|
-
|
|
64
|
-
const builder = new ConstraintBuilder(
|
|
65
|
-
const instance = new (
|
|
45
|
+
const id = metadata.id.value;
|
|
46
|
+
if (fluentIds.has(id)) throw new Error(`Duplicate constraint id '${id}'.`);
|
|
47
|
+
fluentIds.add(id);
|
|
48
|
+
const builder = new ConstraintBuilder(id);
|
|
49
|
+
const instance = new (definition as new () => IConstraint)();
|
|
66
50
|
instance.define(builder);
|
|
67
51
|
const capture = builder.capture;
|
|
68
52
|
const name = wireNameOf(capture);
|
|
69
|
-
const existing =
|
|
53
|
+
const existing = captures.get(name);
|
|
70
54
|
if (existing?.uniqueEventType && capture.uniqueEventType) {
|
|
71
55
|
assertMatchingScope(name, existing.scope, capture.scope);
|
|
72
56
|
const merged = existing.uniqueEventType;
|
|
@@ -82,12 +66,12 @@ export class Constraints implements IConstraints {
|
|
|
82
66
|
} else if (existing) {
|
|
83
67
|
throw new Error(`Duplicate constraint name '${name}'.`);
|
|
84
68
|
} else {
|
|
85
|
-
|
|
69
|
+
captures.set(name, capture);
|
|
86
70
|
}
|
|
87
71
|
}
|
|
88
72
|
|
|
89
73
|
const removalEvents = new Map<string, Function[]>();
|
|
90
|
-
for (const eventType of
|
|
74
|
+
for (const eventType of provider.eventTypes) {
|
|
91
75
|
for (const name of getRemovedConstraintNames(eventType)) {
|
|
92
76
|
const events = removalEvents.get(name) ?? [];
|
|
93
77
|
events.push(eventType);
|
|
@@ -95,16 +79,16 @@ export class Constraints implements IConstraints {
|
|
|
95
79
|
}
|
|
96
80
|
}
|
|
97
81
|
|
|
98
|
-
for (const eventType of
|
|
82
|
+
for (const eventType of provider.eventTypes) {
|
|
99
83
|
const eventMetadata = getUniqueEventMetadata(eventType);
|
|
100
84
|
if (eventMetadata) {
|
|
101
85
|
const name = eventMetadata.name ?? eventType.name;
|
|
102
|
-
let capture =
|
|
86
|
+
let capture = captures.get(name);
|
|
103
87
|
if (!capture) {
|
|
104
88
|
const builder = new ConstraintBuilder(name);
|
|
105
89
|
builder.uniqueFor(eventType, eventMetadata.message, name);
|
|
106
90
|
capture = builder.capture;
|
|
107
|
-
|
|
91
|
+
captures.set(name, capture);
|
|
108
92
|
} else if (!capture.uniqueEventType) {
|
|
109
93
|
throw new Error(`Constraint '${name}' is not a unique event type constraint.`);
|
|
110
94
|
} else {
|
|
@@ -120,12 +104,12 @@ export class Constraints implements IConstraints {
|
|
|
120
104
|
const metadata = getUniquePropertyMetadata(eventType, property);
|
|
121
105
|
if (!metadata) continue;
|
|
122
106
|
const name = metadata.name ?? property;
|
|
123
|
-
let capture =
|
|
107
|
+
let capture = captures.get(name);
|
|
124
108
|
if (!capture) {
|
|
125
109
|
const builder = new ConstraintBuilder(name);
|
|
126
110
|
builder.unique(() => {});
|
|
127
111
|
capture = builder.capture;
|
|
128
|
-
|
|
112
|
+
captures.set(name, capture);
|
|
129
113
|
}
|
|
130
114
|
if (!capture.uniqueConstraint) throw new Error(`Constraint '${name}' is not a unique property constraint.`);
|
|
131
115
|
assertMatchingScope(name, capture.scope, decoratorScope);
|
|
@@ -142,7 +126,7 @@ export class Constraints implements IConstraints {
|
|
|
142
126
|
}
|
|
143
127
|
|
|
144
128
|
for (const [name, eventTypes] of removalEvents) {
|
|
145
|
-
const capture =
|
|
129
|
+
const capture = captures.get(name);
|
|
146
130
|
if (capture) {
|
|
147
131
|
if (capture.uniqueConstraint) {
|
|
148
132
|
const unique = new UniqueConstraintBuilder(capture.uniqueConstraint);
|
|
@@ -156,6 +140,40 @@ export class Constraints implements IConstraints {
|
|
|
156
140
|
}
|
|
157
141
|
}
|
|
158
142
|
}
|
|
143
|
+
return captures;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Resolve the registered message and detail substitutions without a connection. */
|
|
147
|
+
export function resolveConstraintMessage(captures: ReadonlyMap<string, ConstraintCapture>, violation: ConstraintViolation): ConstraintViolation {
|
|
148
|
+
const capture = captures.get(violation.constraintId);
|
|
149
|
+
const message = capture?.uniqueConstraint?.message ?? capture?.uniqueEventType?.message;
|
|
150
|
+
if (!message) return violation;
|
|
151
|
+
let resolved = message;
|
|
152
|
+
for (const [key, value] of Object.entries(violation.details)) {
|
|
153
|
+
resolved = resolved.replaceAll(`{${key}}`, () => value);
|
|
154
|
+
}
|
|
155
|
+
return { ...violation, message: resolved };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Manages discovery and registration of constraints with the Chronicle Kernel. */
|
|
159
|
+
export class Constraints implements IConstraints {
|
|
160
|
+
private _captures = new Map<string, ConstraintCapture>();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Creates a new {@link Constraints} instance.
|
|
164
|
+
* @param _eventStore - The name of the event store these constraints belong to.
|
|
165
|
+
* @param _connection - The connection used to communicate with the Kernel.
|
|
166
|
+
* @param _clientArtifacts - Provider for discovered client artifact types.
|
|
167
|
+
*/
|
|
168
|
+
constructor(
|
|
169
|
+
private readonly _eventStore: string,
|
|
170
|
+
private readonly _connection: ChronicleConnection,
|
|
171
|
+
private readonly _clientArtifacts: IClientArtifactsProvider
|
|
172
|
+
) {}
|
|
173
|
+
|
|
174
|
+
/** @inheritdoc */
|
|
175
|
+
async discover(): Promise<void> {
|
|
176
|
+
this._captures = compileConstraints(this._clientArtifacts);
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
/** @inheritdoc */
|
|
@@ -242,14 +260,6 @@ export class Constraints implements IConstraints {
|
|
|
242
260
|
* @returns The violation with its configured message and substituted details, if available.
|
|
243
261
|
*/
|
|
244
262
|
resolveMessageFor(violation: ConstraintViolation): ConstraintViolation {
|
|
245
|
-
|
|
246
|
-
const message = capture?.uniqueConstraint?.message ?? capture?.uniqueEventType?.message;
|
|
247
|
-
if (!message) return violation;
|
|
248
|
-
|
|
249
|
-
let resolved = message;
|
|
250
|
-
for (const [key, value] of Object.entries(violation.details)) {
|
|
251
|
-
resolved = resolved.replaceAll(`{${key}}`, () => value);
|
|
252
|
-
}
|
|
253
|
-
return { ...violation, message: resolved };
|
|
263
|
+
return resolveConstraintMessage(this._captures, violation);
|
|
254
264
|
}
|
|
255
265
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { field } from '@cratis/fundamentals';
|
|
4
6
|
import { ConstraintType, type ConstraintViolation as ContractsConstraintViolation } from '@cratis/chronicle.contracts';
|
|
5
7
|
import { chai, describe, it, vi } from 'vitest';
|
|
6
8
|
import type { ChronicleConnection } from '../../../connection/ChronicleConnection.js';
|
|
@@ -70,6 +72,32 @@ describe('when resolving violation messages', () => {
|
|
|
70
72
|
result.constraintViolations[0].message.should.equal('Address a$b already registered');
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
it('should map a kernel wire violation name and resolve its configured message on a connected append', async () => {
|
|
76
|
+
class OracleKeyClaimed { @field(String) @unique('OracleKey', 'Already claimed: {PropertyName}={PropertyValue}') key = 'Alpha'; }
|
|
77
|
+
eventType('OracleKeyClaimed')(OracleKeyClaimed);
|
|
78
|
+
const discovered = constraints([OracleKeyClaimed]);
|
|
79
|
+
await discovered.discover();
|
|
80
|
+
const fixture = JSON.parse(readFileSync(new URL('../../../testing/fixtures/constraints.json', import.meta.url), 'utf8')) as {
|
|
81
|
+
expected: { outcomes: Array<{ wireViolations: Array<{ SequenceNumber: string; ConstraintName: string;
|
|
82
|
+
EventTypeId: string; ConstraintType: number; Message: string; Details: Record<string, string> }>;
|
|
83
|
+
violations: Array<{ message: string }> }> }
|
|
84
|
+
};
|
|
85
|
+
const wire = fixture.expected.outcomes[2].wireViolations[0];
|
|
86
|
+
const connection = {
|
|
87
|
+
eventSequences: { append: vi.fn().mockResolvedValue({ Response: {
|
|
88
|
+
SequenceNumber: 18446744073709551615n, Errors: [],
|
|
89
|
+
ConstraintViolations: [{ ...wire, SequenceNumber: BigInt(wire.SequenceNumber) }]
|
|
90
|
+
} }) }
|
|
91
|
+
} as unknown as ChronicleConnection;
|
|
92
|
+
const sequence = new EventSequence(EventSequenceId.eventLog, 'store', 'namespace', connection,
|
|
93
|
+
{} as IUnitOfWorkManager, discovered.resolveMessageFor.bind(discovered));
|
|
94
|
+
const result = await sequence.append('B', new OracleKeyClaimed());
|
|
95
|
+
result.isSuccess.should.be.false;
|
|
96
|
+
result.constraintViolations[0].constraintId.should.equal(wire.ConstraintName);
|
|
97
|
+
result.constraintViolations[0].message.should.equal(fixture.expected.outcomes[2].violations[0].message);
|
|
98
|
+
result.constraintViolations[0].message.should.not.equal(wire.Message);
|
|
99
|
+
});
|
|
100
|
+
|
|
73
101
|
it('should resolve a named fluent event type with a different class id', async () => {
|
|
74
102
|
const discovered = constraints([], [FluentRegistration]);
|
|
75
103
|
await discovered.discover();
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import { chai, describe, it } from 'vitest';
|
|
6
|
+
import { field } from '@cratis/fundamentals';
|
|
7
|
+
import { eventType } from '../events/eventTypeDecorator.js';
|
|
8
|
+
import { unique } from '../events/constraints/unique.js';
|
|
9
|
+
import { TypeDiscoverer } from '../types/TypeDiscoverer.js';
|
|
10
|
+
import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
|
|
11
|
+
|
|
12
|
+
chai.should();
|
|
13
|
+
|
|
14
|
+
describe('selected constraint discovery', () => {
|
|
15
|
+
it('enforces selected decorators when global discovery has been cleared', async () => {
|
|
16
|
+
class SelectedClaim {
|
|
17
|
+
@field(String) @unique('ClearedRegistryKey') key: string;
|
|
18
|
+
constructor(key: string) { this.key = key; }
|
|
19
|
+
}
|
|
20
|
+
eventType('ClearedRegistryClaim')(SelectedClaim);
|
|
21
|
+
TypeDiscoverer.default.clear();
|
|
22
|
+
|
|
23
|
+
const subject = new EventScenario({ artifacts: { eventTypes: [SelectedClaim] } });
|
|
24
|
+
(await subject.append('A', new SelectedClaim('Alpha'))).isSuccess.should.be.true;
|
|
25
|
+
const result = await subject.append('B', new SelectedClaim('Alpha'));
|
|
26
|
+
result.isSuccess.should.be.false;
|
|
27
|
+
result.constraintViolations[0].constraintId.should.equal('ClearedRegistryKey');
|
|
28
|
+
subject.appendedEvents.length.should.equal(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('appends a constraint-free selected class shadowed by a later registration with the same ID', async () => {
|
|
32
|
+
class SelectedItemAdded {
|
|
33
|
+
@field(String) label: string;
|
|
34
|
+
constructor(label: string) { this.label = label; }
|
|
35
|
+
}
|
|
36
|
+
eventType('ShadowedPlainItemAdded')(SelectedItemAdded);
|
|
37
|
+
class ShadowItemAdded {
|
|
38
|
+
@field(String) name: string;
|
|
39
|
+
constructor(name: string) { this.name = name; }
|
|
40
|
+
}
|
|
41
|
+
eventType('ShadowedPlainItemAdded')(ShadowItemAdded);
|
|
42
|
+
|
|
43
|
+
const subject = new EventScenario({ artifacts: { eventTypes: [SelectedItemAdded] } });
|
|
44
|
+
(await subject.append('A', new SelectedItemAdded('Alpha'))).isSuccess.should.be.true;
|
|
45
|
+
subject.appendedEvents.length.should.equal(1);
|
|
46
|
+
subject.appendedEvents[0].content.should.deep.equal({ label: 'Alpha' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects a globally registered constructor shadowing the selected event type ID', () => {
|
|
50
|
+
class SelectedClaim {
|
|
51
|
+
@field(String) @unique('ShadowedRegistryKey') key = 'Alpha';
|
|
52
|
+
}
|
|
53
|
+
eventType('ShadowedRegistryClaim')(SelectedClaim);
|
|
54
|
+
class ShadowClaim {
|
|
55
|
+
@field(String) key = 'Alpha';
|
|
56
|
+
}
|
|
57
|
+
eventType('ShadowedRegistryClaim')(ShadowClaim);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
new EventScenario({ artifacts: { eventTypes: [SelectedClaim] } });
|
|
61
|
+
throw new Error('Expected conflicting constructors to be rejected');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
(error instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
64
|
+
(error as Error).message.should.include('artifacts.eventTypes');
|
|
65
|
+
(error as Error).message.should.include('ShadowedRegistryClaim');
|
|
66
|
+
(error as Error).message.should.include('Conflicting constructors share an event type ID');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -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
|
+
});
|