@cratis/chronicle 6.31.4 → 6.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/testing/ConstraintScope.d.ts +9 -0
  2. package/dist/testing/ConstraintScope.d.ts.map +1 -0
  3. package/dist/testing/ConstraintScope.js +36 -0
  4. package/dist/testing/ConstraintScope.js.map +1 -0
  5. package/dist/testing/EventScenario.d.ts.map +1 -1
  6. package/dist/testing/EventScenario.js +10 -0
  7. package/dist/testing/EventScenario.js.map +1 -1
  8. package/dist/testing/InProcessConstraints.d.ts +1 -1
  9. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  10. package/dist/testing/InProcessConstraints.js +23 -13
  11. package/dist/testing/InProcessConstraints.js.map +1 -1
  12. package/dist/testing/InProcessEventSequence.d.ts +1 -0
  13. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  14. package/dist/testing/InProcessEventSequence.js +23 -8
  15. package/dist/testing/InProcessEventSequence.js.map +1 -1
  16. package/dist/tsconfig.tsbuildinfo +1 -1
  17. package/package.json +2 -2
  18. package/testing/ConstraintScope.ts +32 -0
  19. package/testing/EventScenario.batches.spec.ts +2 -5
  20. package/testing/EventScenario.constraints.spec.ts +1 -4
  21. package/testing/EventScenario.fixtures.spec.ts +1 -1
  22. package/testing/EventScenario.ts +12 -0
  23. package/testing/InProcessConstraints.ts +29 -16
  24. package/testing/InProcessEventSequence.ts +24 -8
  25. package/testing/for_EventScenario/when_appending_scoped_constraints/scope_scenario_behaviors.fixture.ts +139 -0
  26. package/testing/for_EventScenario/when_appending_scoped_constraints/with_legacy_decorators.legacy.spec.ts +57 -0
  27. package/testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts +56 -0
  28. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_cycles.spec.ts +48 -0
  29. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_operations.spec.ts +119 -0
  30. package/testing/for_ReactorScenario/when_enforcing_scoped_constraints/with_default_input_routes.spec.ts +70 -0
  31. package/testing/for_ReactorScenario/when_enforcing_scoped_constraints/with_routed_service_appends.spec.ts +65 -0
@@ -0,0 +1,139 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { readFileSync } from 'node:fs';
5
+ import { chai, describe, it, vi, type Assertion } from 'vitest';
6
+ import type { Constructor } from '@cratis/fundamentals';
7
+ import { getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
8
+ import type { ConstraintScopeCapture } from '../../../events/constraints/ConstraintBuilder.js';
9
+ import { compileConstraints } from '../../../events/constraints/Constraints.js';
10
+ import type { AppendOptions } from '../../../eventSequences/AppendOptions.js';
11
+ import type { AppendedEventWithResult } from '../../../eventSequences/AppendedEventWithResult.js';
12
+ import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
13
+ import { InProcessConstraints } from '../../InProcessConstraints.js';
14
+
15
+ chai.should();
16
+ function should(value: unknown): Assertion { return (value as { should: Assertion }).should; }
17
+
18
+ type Entry = { source: string; type: string; value: string; sourceType?: string; streamType?: string; streamId?: string };
19
+ type Definition = { kind: string; name: string; scope: ConstraintScopeCapture; events?: Array<{ type: string; properties: string[] }>;
20
+ eventTypes?: string[]; ignoreCasing?: boolean; removedWithEventTypeIds: string[]; message: string };
21
+ type History = { sequence: string; source: string; sourceType?: string; streamType?: string; streamId?: string;
22
+ type: string; content: Record<string, unknown>; hash?: string };
23
+ type Outcome = { success: boolean; sequences: string[]; errors: string[];
24
+ wireViolations: Array<{ EventTypeId: string; SequenceNumber: string; ConstraintType: number; ConstraintName: string; Message: string; Details: Record<string, string> }>;
25
+ violations: Array<{ id: string; message: string; details: Record<string, string> }>; history: History[]; next: string };
26
+ type ScopeCase = { name: string; kind: string; eventSchemas: Record<string, { eventTypeId: string; properties: Record<string, string> }>;
27
+ constraintDefinitions: Definition[];
28
+ isolatedConstraintOperations: Array<{ label: string; mode: string; overload?: string; options?: AppendOptions; events: Entry[] }> };
29
+ type Fixture = { scopeCases: ScopeCase[]; expected: { cases: Array<{ name: string; result: { outcomes: Outcome[] } }> } };
30
+
31
+ export function scopeScenarioBehaviors(
32
+ types: Record<string, new (value: string) => object>,
33
+ constraints: (scope: ConstraintScopeCapture, kind: string) => Constructor[]
34
+ ): void {
35
+ const fixture = JSON.parse(readFileSync(new URL('../../fixtures/constraints-scopes.json', import.meta.url), 'utf8')) as Fixture;
36
+ const historyOf = (scenario: EventScenario): History[] => scenario.appendedEvents.map(event => ({
37
+ sequence: event.context.sequenceNumber.toString(), source: event.context.eventSourceId,
38
+ sourceType: event.context.eventSourceType, streamType: event.context.eventStreamType, streamId: event.context.eventStreamId,
39
+ type: event.eventType.id.value, content: event.content, hash: event.context.hash
40
+ }));
41
+ const aliases = Object.fromEntries(Object.entries(types).map(([alias, type]) => [getEventTypeMetadata(type)!.eventType.id.value, alias]));
42
+
43
+ it('should retain all seven scope combinations for both constraint kinds and the alias guards', () => {
44
+ should(fixture.scopeCases.map(test => test.name)).deep.equal([
45
+ ...Array.from({ length: 7 }, (_, index) => ['property', 'cycle', 'once'].map(kind => `${kind}-${index + 1}`)).flat(),
46
+ 'property-delimiter-alias', 'cycle-delimiter-alias'
47
+ ]);
48
+ should(fixture.expected.cases.map(test => test.name)).deep.equal(fixture.scopeCases.map(test => test.name));
49
+ });
50
+
51
+ for (const [caseIndex, test] of fixture.scopeCases.entries()) {
52
+ describe(`with ${test.name}`, () => {
53
+ it(test.kind === 'oracleGuard' ? 'should reject delimiter routes before mutation' :
54
+ 'should match kernel violations, routes, hashes, atomic history and notifications', async () => {
55
+ const definition = test.constraintDefinitions[0];
56
+ const selected = Object.keys(test.eventSchemas).map(alias => types[alias]);
57
+ const selectedConstraints = constraints(definition.scope, test.name.split('-')[0]);
58
+ const compiled = compileConstraints({ eventTypes: selected, constraints: selectedConstraints });
59
+ should(Object.fromEntries(Object.entries(test.eventSchemas).map(([alias]) => {
60
+ const metadata = getEventTypeMetadata(types[alias])!;
61
+ return [alias, { eventTypeId: metadata.eventType.id.value,
62
+ properties: Object.fromEntries(Object.entries(metadata.schema.properties!).map(([key, property]) => [key, property.type])) }];
63
+ }))).deep.equal(test.eventSchemas);
64
+ should([...compiled].map(([name, capture]) => ({
65
+ kind: capture.uniqueConstraint ? 'uniqueProperty' : 'uniqueEventType', name,
66
+ ...(capture.uniqueConstraint ? {
67
+ events: capture.uniqueConstraint.eventDefinitions.map(entry => ({ type: aliases[entry.eventTypeId], properties: entry.properties })),
68
+ ignoreCasing: capture.uniqueConstraint.ignoreCasing
69
+ } : { eventTypes: (capture.uniqueEventType!.eventTypeIds ?? [capture.uniqueEventType!.eventTypeId]).map(id => aliases[id]) }),
70
+ removedWithEventTypeIds: (capture.uniqueConstraint?.removedWithEventTypeIds ?? capture.uniqueEventType?.removedWithEventTypeIds ?? []).map(id => aliases[id]),
71
+ message: capture.uniqueConstraint?.message ?? capture.uniqueEventType?.message ?? '', scope: capture.scope
72
+ }))).deep.equal(test.constraintDefinitions);
73
+ const scenario = new EventScenario({ artifacts: { eventTypes: selected, constraints: selectedConstraints } });
74
+ const notifications = scenario.eventLog.appendOperations[Symbol.asyncIterator]();
75
+ if (test.kind === 'oracleGuard') {
76
+ const pending = notifications.next();
77
+ try {
78
+ for (const operation of test.isolatedConstraintOperations) {
79
+ const entry = operation.events[0];
80
+ await scenario.append(entry.source, new types[entry.type](entry.value), operation.options)
81
+ .then(() => { throw new Error('Unsupported route succeeded'); }, error => {
82
+ should(error instanceof UnsupportedEventSequenceOperation).be.true;
83
+ should((error as Error).message).include('Only simple routing identifiers');
84
+ should((error as Error).message).include('Use a kernel-backed test.');
85
+ });
86
+ should(scenario.appendedEvents.length).equal(0);
87
+ should(scenario.results.length).equal(0);
88
+ should((await scenario.eventLog.getNextSequenceNumber()).value).equal(0n);
89
+ }
90
+ const accepted = await scenario.append('Safe', new types[test.isolatedConstraintOperations[0].events[0].type]('Safe'));
91
+ should(accepted.isSuccess).be.true;
92
+ should(((await pending).value as AppendedEventWithResult[])[0].result).equal(accepted);
93
+ } finally { await notifications.return?.(); }
94
+ return;
95
+ }
96
+ const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
97
+ const expected = fixture.expected.cases[caseIndex].result.outcomes;
98
+ should(expected.length).equal(test.isolatedConstraintOperations.length);
99
+ let resultCount = 0;
100
+ try {
101
+ for (const [index, operation] of test.isolatedConstraintOperations.entries()) {
102
+ const before = historyOf(scenario);
103
+ const nextNotification = notifications.next();
104
+ const entries = operation.events.map(entry => ({ eventSourceId: entry.source, event: new types[entry.type](entry.value),
105
+ eventSourceType: entry.sourceType, eventStreamType: entry.streamType, eventStreamId: entry.streamId }));
106
+ const results = operation.mode === 'single'
107
+ ? [await scenario.append(entries[0].eventSourceId, entries[0].event, operation.options)]
108
+ : operation.overload === 'source'
109
+ ? await scenario.appendMany(entries[0].eventSourceId, entries.map(entry => entry.event), operation.options)
110
+ : await scenario.appendMany(entries, operation.options);
111
+ const outcome = expected[index];
112
+ const raw = validate.mock.results.at(-1)!.value as ReturnType<InProcessConstraints['validate']>;
113
+ should(raw.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() })))
114
+ .deep.equal(outcome.wireViolations, operation.label);
115
+ should(results.length).equal(operation.events.length);
116
+ for (const [position, result] of results.entries()) {
117
+ should(result.isSuccess).equal(outcome.success, operation.label);
118
+ should(result.sequenceNumber.value.toString()).equal(outcome.success ? outcome.sequences[position] : '0');
119
+ should(result.constraintViolations.map(violation => ({ id: violation.constraintId, message: violation.message, details: violation.details })))
120
+ .deep.equal(outcome.violations, operation.label);
121
+ should(result.errors.map(error => error.message)).deep.equal(outcome.errors);
122
+ }
123
+ const notification = (await nextNotification).value as AppendedEventWithResult[];
124
+ should(notification.length).equal(results.length);
125
+ notification.forEach((entry, position) => should(entry.result).equal(results[position]));
126
+ resultCount += results.length;
127
+ should(scenario.results.length).equal(resultCount);
128
+ if (!outcome.success) should(historyOf(scenario)).deep.equal(before, operation.label);
129
+ should(historyOf(scenario)).deep.equal(outcome.history, operation.label);
130
+ should((await scenario.eventLog.getNextSequenceNumber()).value.toString()).equal(outcome.next);
131
+ }
132
+ } finally {
133
+ validate.mockRestore();
134
+ await notifications.return?.();
135
+ }
136
+ });
137
+ });
138
+ }
139
+ }
@@ -0,0 +1,57 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field, type Constructor } from '@cratis/fundamentals';
6
+ import { describe } from 'vitest';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { constraint } from '../../../events/constraints/constraint.js';
9
+ import { removeConstraint } from '../../../events/constraints/removeConstraint.js';
10
+ import type { ConstraintScopeCapture } from '../../../events/constraints/ConstraintBuilder.js';
11
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
12
+ import { scopeScenarioBehaviors } from './scope_scenario_behaviors.fixture.js';
13
+
14
+ @eventType('OracleDomainText')
15
+ class Text { @field(String) key: string; constructor(key: string) { this.key = key; } }
16
+ @eventType('OracleDomainShared')
17
+ class Shared { @field(String) key: string; constructor(key: string) { this.key = key; } }
18
+ @eventType('OracleDomainRemoved')
19
+ class Removed { @field(String) label: string; constructor(label: string) { this.label = label; } }
20
+ @eventType('OracleDomainExpired')
21
+ class Expired { @field(String) label: string; constructor(label: string) { this.label = label; } }
22
+ @eventType('OracleCycleFirst')
23
+ class First { @field(String) label: string; constructor(label: string) { this.label = label; } }
24
+ @eventType('OracleCycleSibling')
25
+ class Sibling { @field(String) label: string; constructor(label: string) { this.label = label; } }
26
+ @eventType('OracleCycleRemoved')
27
+ @removeConstraint('OracleScopedCycle')
28
+ class CycleRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
29
+ @eventType('OracleCycleExpired')
30
+ @removeConstraint('OracleScopedCycle')
31
+ class CycleExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
32
+
33
+ function constraints(scope: ConstraintScopeCapture, kind: string): Constructor[] {
34
+ function apply(builder: IConstraintBuilder): IConstraintBuilder {
35
+ if (scope.perEventSourceType) builder.perEventSourceType();
36
+ if (scope.perEventStreamType) builder.perEventStreamType();
37
+ if (scope.perEventStreamId) builder.perEventStreamId();
38
+ return builder;
39
+ }
40
+ @constraint('OracleScopedKey')
41
+ class Key {
42
+ define(builder: IConstraintBuilder) {
43
+ apply(builder).unique(key => key.on(Text, event => event.key).on(Shared, event => event.key)
44
+ .removedWith(Removed).removedWith(Expired).withMessage('Taken: {PropertyValue}'));
45
+ }
46
+ }
47
+ @constraint('OracleScopedFirst')
48
+ class FirstCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(First, 'Cycle occupied', 'OracleScopedCycle'); } }
49
+ @constraint('OracleScopedSibling')
50
+ class SiblingCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(Sibling, 'Cycle occupied', 'OracleScopedCycle'); } }
51
+ return kind === 'property' ? [Key] : kind === 'once' ? [FirstCycle] : [FirstCycle, SiblingCycle];
52
+ }
53
+
54
+ describe('when appending scoped constraints with legacy decorators', () => {
55
+ scopeScenarioBehaviors({ string: Text, shared: Shared, removed: Removed, expired: Expired,
56
+ first: First, sibling: Sibling, cycleRemoved: CycleRemoved, cycleExpired: CycleExpired }, constraints);
57
+ });
@@ -0,0 +1,56 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { field, type Constructor } from '@cratis/fundamentals';
5
+ import { describe } from 'vitest';
6
+ import { eventType } from '../../../events/eventTypeDecorator.js';
7
+ import { constraint } from '../../../events/constraints/constraint.js';
8
+ import { removeConstraint } from '../../../events/constraints/removeConstraint.js';
9
+ import type { ConstraintScopeCapture } from '../../../events/constraints/ConstraintBuilder.js';
10
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import { scopeScenarioBehaviors } from './scope_scenario_behaviors.fixture.js';
12
+
13
+ @eventType('OracleDomainText')
14
+ class Text { @field(String) key: string; constructor(key: string) { this.key = key; } }
15
+ @eventType('OracleDomainShared')
16
+ class Shared { @field(String) key: string; constructor(key: string) { this.key = key; } }
17
+ @eventType('OracleDomainRemoved')
18
+ class Removed { @field(String) label: string; constructor(label: string) { this.label = label; } }
19
+ @eventType('OracleDomainExpired')
20
+ class Expired { @field(String) label: string; constructor(label: string) { this.label = label; } }
21
+ @eventType('OracleCycleFirst')
22
+ class First { @field(String) label: string; constructor(label: string) { this.label = label; } }
23
+ @eventType('OracleCycleSibling')
24
+ class Sibling { @field(String) label: string; constructor(label: string) { this.label = label; } }
25
+ @eventType('OracleCycleRemoved')
26
+ @removeConstraint('OracleScopedCycle')
27
+ class CycleRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
28
+ @eventType('OracleCycleExpired')
29
+ @removeConstraint('OracleScopedCycle')
30
+ class CycleExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
31
+
32
+ function constraints(scope: ConstraintScopeCapture, kind: string): Constructor[] {
33
+ function apply(builder: IConstraintBuilder): IConstraintBuilder {
34
+ if (scope.perEventSourceType) builder.perEventSourceType();
35
+ if (scope.perEventStreamType) builder.perEventStreamType();
36
+ if (scope.perEventStreamId) builder.perEventStreamId();
37
+ return builder;
38
+ }
39
+ @constraint('OracleScopedKey')
40
+ class Key {
41
+ define(builder: IConstraintBuilder) {
42
+ apply(builder).unique(key => key.on(Text, event => event.key).on(Shared, event => event.key)
43
+ .removedWith(Removed).removedWith(Expired).withMessage('Taken: {PropertyValue}'));
44
+ }
45
+ }
46
+ @constraint('OracleScopedFirst')
47
+ class FirstCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(First, 'Cycle occupied', 'OracleScopedCycle'); } }
48
+ @constraint('OracleScopedSibling')
49
+ class SiblingCycle { define(builder: IConstraintBuilder) { apply(builder).uniqueFor(Sibling, 'Cycle occupied', 'OracleScopedCycle'); } }
50
+ return kind === 'property' ? [Key] : kind === 'once' ? [FirstCycle] : [FirstCycle, SiblingCycle];
51
+ }
52
+
53
+ describe('when appending scoped constraints with standard decorators', () => {
54
+ scopeScenarioBehaviors({ string: Text, shared: Shared, removed: Removed, expired: Expired,
55
+ first: First, sibling: Sibling, cycleRemoved: CycleRemoved, cycleExpired: CycleExpired }, constraints);
56
+ });
@@ -0,0 +1,48 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { constraint } from '../../../events/constraints/constraint.js';
9
+ import { removeConstraint } from '../../../events/constraints/removeConstraint.js';
10
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
12
+
13
+ chai.should();
14
+ @eventType('UnprovenScopedFirst')
15
+ class First { @field(String) label = 'First'; }
16
+ @eventType('UnprovenScopedSecond')
17
+ class Second { @field(String) label = 'Second'; }
18
+ @eventType('UnprovenScopedRenewed')
19
+ @removeConstraint('UnprovenScopedCycle')
20
+ class Renewed { @field(String) label = 'Renewed'; }
21
+ @eventType('UnprovenScopedRemoved')
22
+ @removeConstraint('UnprovenScopedCycle')
23
+ class Removed { @field(String) label = 'Removed'; }
24
+ @eventType('UnprovenScopedExpired')
25
+ @removeConstraint('UnprovenScopedCycle')
26
+ class Expired { @field(String) label = 'Expired'; }
27
+
28
+ function constraints(scoped: boolean): Function[] {
29
+ return [First, Second, Renewed].map((type, index) => {
30
+ @constraint(`UnprovenScopedDeclaration${index}`)
31
+ class Cycle {
32
+ define(builder: IConstraintBuilder) {
33
+ if (scoped) builder.perEventStreamId();
34
+ builder.uniqueFor(type, 'Occupied', 'UnprovenScopedCycle');
35
+ }
36
+ }
37
+ return Cycle;
38
+ });
39
+ }
40
+
41
+ describe('when configuring a scoped covered-and-removal cycle', () => {
42
+ it('should reject that scoped interaction specifically while preserving its unscoped support', () => {
43
+ const eventTypes = [First, Second, Renewed, Removed, Expired];
44
+ new EventScenario({ artifacts: { eventTypes, constraints: constraints(false) } }).should.be.instanceOf(EventScenario);
45
+ (() => new EventScenario({ artifacts: { eventTypes, constraints: constraints(true) } }))
46
+ .should.throw(UnsupportedEventSequenceOperation, 'Scoped covered-and-removal event cycles are not fixture-backed.');
47
+ });
48
+ });
@@ -0,0 +1,119 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { constraint } from '../../../events/constraints/constraint.js';
9
+ import { unique } from '../../../events/constraints/unique.js';
10
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import type { AppendOptions } from '../../../eventSequences/AppendOptions.js';
12
+ import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
13
+
14
+ chai.should();
15
+ @eventType('ScopedGuardClaim')
16
+ class Claim { @field(String) key: string; @field(String) other = 'Other'; constructor(key: string) { this.key = key; } }
17
+ @eventType('ScopedGuardFlag')
18
+ class Flag { @field(Boolean) key = true; }
19
+ @eventType('ScopedGuardRemoval')
20
+ class Removed { @field(String) label = 'release'; }
21
+ @eventType('ScopedGuardFieldless')
22
+ class Fieldless {}
23
+
24
+ function definition(configure: (builder: IConstraintBuilder) => void, name = 'ScopedGuard'): Function {
25
+ @constraint(name)
26
+ class Definition { define(builder: IConstraintBuilder) { configure(builder); } }
27
+ return Definition;
28
+ }
29
+ const scopedConstraint = definition(builder => builder.perEventStreamId().unique(value => value.on(Claim, event => event.key).removedWith(Removed)));
30
+ const create = () => new EventScenario({ artifacts: { eventTypes: [Claim, Removed], constraints: [scopedConstraint] } });
31
+ async function unsupported(action: () => unknown, reason: string): Promise<void> {
32
+ await Promise.resolve().then(action).then(() => { throw new Error('Unsupported operation succeeded'); }, error => {
33
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
34
+ (error as Error).message.should.include(reason);
35
+ (error as Error).message.should.include('Use a kernel-backed test.');
36
+ });
37
+ }
38
+
39
+ describe('when configuring unproven scoped property shapes', () => {
40
+ for (const [name, configure, reason] of [
41
+ ['composite', (builder: IConstraintBuilder) => builder.unique(value => value.on(Claim, event => event.key, event => event.other)), 'Scoped composite or case-insensitive property keys'],
42
+ ['casing', (builder: IConstraintBuilder) => builder.unique(value => value.on(Claim, event => event.key).ignoreCasing()), 'Scoped composite or case-insensitive property keys'],
43
+ ['boolean', (builder: IConstraintBuilder) => builder.unique(value => value.on(Flag, event => event.key)), 'Scoped property keys must be schema-backed strings'],
44
+ ['covered removal', (builder: IConstraintBuilder) => builder.unique(value => value.on(Claim, event => event.key).removedWith(Claim)), 'Scoped covered-and-removal property events'],
45
+ ['fieldless removal', (builder: IConstraintBuilder) => builder.unique(value => value.on(Claim, event => event.key).removedWith(Fieldless)), 'Scoped fieldless removal events']
46
+ ] as const) {
47
+ it(`should reject ${name} with its specific boundary rather than a compiler failure`, async () => {
48
+ const eventTypes = name === 'fieldless removal' ? [Claim, Fieldless] : [Claim, Flag, Removed];
49
+ new EventScenario({ artifacts: { eventTypes, constraints: [definition(configure)] } }).should.be.instanceOf(EventScenario);
50
+ await unsupported(() => new EventScenario({ artifacts: { eventTypes,
51
+ constraints: [definition(builder => { builder.perEventSourceType(); configure(builder); })] } }), reason);
52
+ });
53
+ }
54
+
55
+ it('should reject a scoped definition alongside another nonoverlapping definition', async () => {
56
+ const other = definition(builder => builder.unique(value => value.on(Flag, event => event.key)), 'OtherGuard');
57
+ await unsupported(() => new EventScenario({ artifacts: { eventTypes: [Claim, Flag, Removed], constraints: [scopedConstraint, other] } }),
58
+ 'Scoped constraints alongside other definitions');
59
+ });
60
+
61
+ it('should retain the production compiler conflict between scoped fluent and unscoped unique decorators', async () => {
62
+ @eventType('ScopedDecoratedConflict')
63
+ class Decorated { @field(String) @unique('Conflict') key = 'key'; }
64
+ const scoped = definition(builder => builder.perEventStreamId().unique(value => value.on(Decorated, event => event.key)), 'Conflict');
65
+ await unsupported(() => new EventScenario({ artifacts: { eventTypes: [Decorated], constraints: [scoped] } }),
66
+ "Conflicting scopes for constraint 'Conflict'");
67
+ });
68
+ });
69
+
70
+ describe('when a scoped append uses unsupported routes or metadata', () => {
71
+ it('should preserve claims, history, results, notifications and sequence after unsupported input', async () => {
72
+ const scenario = create();
73
+ (await scenario.append('A', new Claim('Alpha'), { streamId: 'West' })).isSuccess.should.be.true;
74
+ const before = scenario.appendedEvents;
75
+ const results = scenario.results;
76
+ const notifications = scenario.eventLog.appendOperations[Symbol.asyncIterator]();
77
+ const pending = notifications.next();
78
+ try {
79
+ for (const dimension of ['sourceType', 'streamType', 'streamId'] as const) {
80
+ for (const value of [' ', 'a|estt:b', '*', 'a:b', 'é', 'a\u0000b']) {
81
+ const options = { [dimension]: value };
82
+ await unsupported(() => scenario.append('B', new Claim('Beta'), options), `append.${dimension}`);
83
+ await unsupported(() => scenario.appendMany('B', [new Claim('Beta')], options), `appendMany.${dimension}`);
84
+ }
85
+ }
86
+ for (const options of [{ tags: [] }, { subject: '' }, { occurred: new Date() }, { correlationId: '00000000-0000-0000-0000-000000000001' },
87
+ { concurrencyScopes: {} }, { eventSourceId: 'other' }, { arbitrary: true }]) {
88
+ await unsupported(() => scenario.append('B', new Claim('Beta'), options as AppendOptions), 'append.options');
89
+ }
90
+ await unsupported(() => scenario.appendMany([
91
+ { eventSourceId: 'A', event: new Removed(), eventStreamId: 'West' },
92
+ { eventSourceId: 'B', event: new Claim('Alpha'), eventStreamId: 'bad|route' }
93
+ ]), 'appendMany.streamId');
94
+ scenario.appendedEvents.should.deep.equal(before);
95
+ scenario.results.should.deep.equal(results);
96
+ (await scenario.eventLog.getNextSequenceNumber()).value.should.equal(1n);
97
+ const rejected = await scenario.append('B', new Claim('Alpha'), { streamId: 'West' });
98
+ rejected.isSuccess.should.be.false;
99
+ (await pending).value![0].result.should.equal(rejected);
100
+ (await scenario.append('B', new Claim('Beta'), { streamId: 'West' })).sequenceNumber.value.should.equal(1n);
101
+ } finally { await notifications.return?.(); }
102
+ });
103
+
104
+ it('should roll back default-route setup without disturbing another scope', async () => {
105
+ const scenario = create();
106
+ await scenario.append('A', new Claim('Alpha'), { streamId: 'West' });
107
+ await scenario.given.forEventSource('A').events(new Claim('DefaultKey'));
108
+ const before = scenario.appendedEvents;
109
+ const results = scenario.results;
110
+ await scenario.given.forEventSource('B').events(new Claim('Temporary'), new Claim('DefaultKey'))
111
+ .then(() => { throw new Error('Invalid setup succeeded'); }, error => {
112
+ (error as Error).message.should.include('EventScenario given setup failed');
113
+ });
114
+ scenario.appendedEvents.should.deep.equal(before);
115
+ scenario.results.should.deep.equal(results);
116
+ (await scenario.append('C', new Claim('Temporary'))).isSuccess.should.be.true;
117
+ (await scenario.append('C', new Claim('Alpha'), { streamId: 'West' })).isSuccess.should.be.false;
118
+ });
119
+ });
@@ -0,0 +1,70 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { constraint } from '../../../events/constraints/constraint.js';
9
+ import { removeConstraint } from '../../../events/constraints/removeConstraint.js';
10
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import type { EventContext } from '../../../events/EventContext.js';
12
+ import { reactor } from '../../../reactors/reactor.js';
13
+ import { ReactorScenario } from '../../ReactorScenario.js';
14
+
15
+ chai.should();
16
+ @eventType('ScopedReactorFirst')
17
+ class First { @field(String) label = 'First'; }
18
+ @eventType('ScopedReactorSibling')
19
+ class Sibling { @field(String) label = 'Sibling'; }
20
+ @eventType('ScopedReactorRemoved')
21
+ @removeConstraint('ScopedReactorCycle')
22
+ class Removed { @field(String) label = 'Removed'; }
23
+ @eventType('ScopedReactorExpired')
24
+ @removeConstraint('ScopedReactorCycle')
25
+ class Expired { @field(String) label = 'Expired'; }
26
+ @eventType('ScopedReactorEffect')
27
+ class Effect { @field(String) label: string; constructor(label: string) { this.label = label; } }
28
+ @constraint('ScopedReactorFirstCycle')
29
+ class FirstCycle {
30
+ define(builder: IConstraintBuilder) { builder.perEventSourceType().perEventStreamType().perEventStreamId().uniqueFor(First, 'Occupied', 'ScopedReactorCycle'); }
31
+ }
32
+ @constraint('ScopedReactorSiblingCycle')
33
+ class SiblingCycle {
34
+ define(builder: IConstraintBuilder) { builder.perEventSourceType().perEventStreamType().perEventStreamId().uniqueFor(Sibling, 'Occupied', 'ScopedReactorCycle'); }
35
+ }
36
+
37
+ describe('when enforcing scoped reactor constraints on default-route input', () => {
38
+ it('should deliver only accepted cycles and retain no effects or deliveries from rejected actions', async () => {
39
+ const calls: EventContext[] = [];
40
+ @reactor('ScopedReactor')
41
+ class Reactor {
42
+ first(event: First, context: EventContext) { calls.push(context); return new Effect(event.label); }
43
+ sibling(event: Sibling, context: EventContext) { calls.push(context); return new Effect(event.label); }
44
+ }
45
+ const scenario = new ReactorScenario(Reactor, { artifacts: {
46
+ eventTypes: [First, Sibling, Removed, Expired, Effect], constraints: [FirstCycle, SiblingCycle]
47
+ } });
48
+ await scenario.given.forEventSource('A').events(new First());
49
+ await scenario.when.forEventSource('A').events(new Sibling()).then(
50
+ () => { throw new Error('Conflicting cycle delivered'); }, error => {
51
+ (error as Error).message.should.include('ReactorScenario action append failed');
52
+ (error as Error).message.should.include('ScopedReactorCycle');
53
+ });
54
+ calls.length.should.equal(1);
55
+ scenario.produced.length.should.equal(1);
56
+ scenario.results.length.should.equal(1);
57
+ scenario.appendedEvents.length.should.equal(1);
58
+ await scenario.when.forEventSource('A').events(new Removed(), new Sibling());
59
+ await scenario.when.forEventSource('A').events(new Expired(), new First(), new Sibling()).then(
60
+ () => { throw new Error('Invalid batch delivered'); }, error => {
61
+ (error as Error).message.should.include('ReactorScenario action append failed');
62
+ });
63
+ calls.length.should.equal(2);
64
+ scenario.produced.length.should.equal(2);
65
+ scenario.results.length.should.equal(2);
66
+ scenario.appendedEvents.length.should.equal(3);
67
+ calls.map(context => [context.eventSourceType, context.eventStreamType, context.eventStreamId])
68
+ .should.deep.equal([['Default', 'All', 'Default'], ['Default', 'All', 'Default']]);
69
+ });
70
+ });
@@ -0,0 +1,65 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { constraint } from '../../../events/constraints/constraint.js';
9
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
10
+ import type { EventContext } from '../../../events/EventContext.js';
11
+ import { reactor } from '../../../reactors/reactor.js';
12
+ import type { ReactorServices } from '../../../reactors/ReactorServices.js';
13
+ import { ReactorScenario } from '../../ReactorScenario.js';
14
+ import { UnsupportedEventSequenceOperation } from '../../UnsupportedEventSequenceOperation.js';
15
+
16
+ chai.should();
17
+ @eventType('ScopedServiceRequested')
18
+ class Requested { @field(String) stream: string; constructor(stream: string) { this.stream = stream; } }
19
+ @eventType('ScopedServiceClaimed')
20
+ class Claimed { @field(String) key = 'Key'; }
21
+ @eventType('ScopedServiceReturned')
22
+ class Returned { @field(String) label = 'returned'; }
23
+ @constraint('ScopedServiceKey')
24
+ class Key { define(builder: IConstraintBuilder) { builder.perEventStreamId().unique(key => key.on(Claimed, event => event.key)); } }
25
+
26
+ describe('when a reactor explicitly appends into a constraint scope', () => {
27
+ it('should preserve routed service appends without routing or recursively delivering the input', async () => {
28
+ const successes: boolean[] = [];
29
+ @reactor('ScopedServiceWriter')
30
+ class Writer {
31
+ async requested(event: Requested, context: EventContext, services: ReactorServices) {
32
+ successes.push((await services.eventStore.eventLog.append(context.eventSourceId, new Claimed(), { streamId: event.stream })).isSuccess);
33
+ }
34
+ }
35
+ const scenario = new ReactorScenario(Writer, { artifacts: { eventTypes: [Requested, Claimed], constraints: [Key] } });
36
+ await scenario.when.forEventSource('A').events(new Requested('West'));
37
+ await scenario.when.forEventSource('B').events(new Requested('East'));
38
+ await scenario.when.forEventSource('C').events(new Requested('West'));
39
+ successes.should.deep.equal([true, true, false]);
40
+ scenario.appendedEvents.map(event => event.context.eventStreamId).should.deep.equal(['Default', 'West', 'Default', 'East', 'Default']);
41
+ scenario.results.length.should.equal(3);
42
+ scenario.produced.length.should.equal(0);
43
+ });
44
+
45
+ it('should fail a delivery that swallows an unsupported route and record no returned effect', async () => {
46
+ @reactor('ScopedServiceUnsupportedWriter')
47
+ class Writer {
48
+ async requested(_event: Requested, context: EventContext, services: ReactorServices) {
49
+ try { await services.eventStore.eventLog.append(context.eventSourceId, new Claimed(), { streamId: 'bad|route' }); }
50
+ catch { /* The harness must latch unsupported appends even if the handler swallows them. */ }
51
+ return new Returned();
52
+ }
53
+ }
54
+ const scenario = new ReactorScenario(Writer, { artifacts: { eventTypes: [Requested, Claimed, Returned], constraints: [Key] } });
55
+ await scenario.when.forEventSource('A').events(new Requested('West')).then(
56
+ () => { throw new Error('Unsupported route was swallowed'); }, error => {
57
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
58
+ (error as Error).message.should.include('append.streamId');
59
+ });
60
+ scenario.appendedEvents.length.should.equal(1);
61
+ scenario.produced.length.should.equal(0);
62
+ scenario.sideEffects.length.should.equal(0);
63
+ scenario.results[0].completed.should.be.false;
64
+ });
65
+ });