@cratis/chronicle 6.49.0 → 6.50.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/AppendOptions.d.ts +10 -0
- package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
- package/dist/eventSequences/EventForEventSourceId.d.ts +6 -0
- package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +28 -8
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/ITransactionalEventSequence.d.ts +5 -5
- package/dist/eventSequences/ITransactionalEventSequence.d.ts.map +1 -1
- package/dist/eventSequences/NamedTagsWithRegisteredEventSourceNotSupported.d.ts +5 -0
- package/dist/eventSequences/NamedTagsWithRegisteredEventSourceNotSupported.d.ts.map +1 -0
- package/dist/eventSequences/NamedTagsWithRegisteredEventSourceNotSupported.js +12 -0
- package/dist/eventSequences/NamedTagsWithRegisteredEventSourceNotSupported.js.map +1 -0
- package/dist/eventSequences/TransactionalEventSequence.d.ts +3 -3
- package/dist/eventSequences/TransactionalEventSequence.d.ts.map +1 -1
- package/dist/eventSequences/TransactionalEventSequence.js +7 -4
- package/dist/eventSequences/TransactionalEventSequence.js.map +1 -1
- package/dist/eventSequences/createAppendNotification.d.ts +2 -1
- package/dist/eventSequences/createAppendNotification.d.ts.map +1 -1
- package/dist/eventSequences/createAppendNotification.js +3 -2
- package/dist/eventSequences/createAppendNotification.js.map +1 -1
- package/dist/eventSequences/ensureNamedTagBatchIsSupported.d.ts +3 -0
- package/dist/eventSequences/ensureNamedTagBatchIsSupported.d.ts.map +1 -0
- package/dist/eventSequences/ensureNamedTagBatchIsSupported.js +10 -0
- package/dist/eventSequences/ensureNamedTagBatchIsSupported.js.map +1 -0
- package/dist/eventSequences/index.d.ts +1 -0
- package/dist/eventSequences/index.d.ts.map +1 -1
- package/dist/eventSequences/index.js +1 -0
- package/dist/eventSequences/index.js.map +1 -1
- package/dist/eventSequences/prepareBatchAppend.d.ts +1 -0
- package/dist/eventSequences/prepareBatchAppend.d.ts.map +1 -1
- package/dist/eventSequences/prepareBatchAppend.js +4 -1
- package/dist/eventSequences/prepareBatchAppend.js.map +1 -1
- package/dist/eventSequences/prepareSingleAppend.d.ts +1 -0
- package/dist/eventSequences/prepareSingleAppend.d.ts.map +1 -1
- package/dist/eventSequences/prepareSingleAppend.js +3 -1
- package/dist/eventSequences/prepareSingleAppend.js.map +1 -1
- package/dist/events/EventContext.d.ts +7 -0
- package/dist/events/EventContext.d.ts.map +1 -1
- package/dist/events/InvalidNamedTag.d.ts +8 -0
- package/dist/events/InvalidNamedTag.d.ts.map +1 -0
- package/dist/events/InvalidNamedTag.js +13 -0
- package/dist/events/InvalidNamedTag.js.map +1 -0
- package/dist/events/NamedTag.d.ts +25 -0
- package/dist/events/NamedTag.d.ts.map +1 -0
- package/dist/events/NamedTag.js +39 -0
- package/dist/events/NamedTag.js.map +1 -0
- package/dist/events/index.d.ts +3 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +3 -0
- package/dist/events/index.js.map +1 -1
- package/dist/events/mergeNamedTags.d.ts +13 -0
- package/dist/events/mergeNamedTags.d.ts.map +1 -0
- package/dist/events/mergeNamedTags.js +39 -0
- package/dist/events/mergeNamedTags.js.map +1 -0
- package/dist/events/toClientEventContext.d.ts.map +1 -1
- package/dist/events/toClientEventContext.js +3 -1
- package/dist/events/toClientEventContext.js.map +1 -1
- package/dist/projections/eventContextPropertyExpression.d.ts.map +1 -1
- package/dist/projections/eventContextPropertyExpression.js +2 -1
- package/dist/projections/eventContextPropertyExpression.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +17 -7
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/testing/ReactorScenario.d.ts.map +1 -1
- package/dist/testing/ReactorScenario.js +6 -0
- package/dist/testing/ReactorScenario.js.map +1 -1
- package/dist/testing/singleAppendRoute.d.ts +5 -0
- package/dist/testing/singleAppendRoute.d.ts.map +1 -1
- package/dist/testing/singleAppendRoute.js +12 -1
- package/dist/testing/singleAppendRoute.js.map +1 -1
- package/dist/transactions/IUnitOfWork.d.ts +4 -3
- package/dist/transactions/IUnitOfWork.d.ts.map +1 -1
- package/dist/transactions/TransactionalAppendOptions.d.ts +14 -0
- package/dist/transactions/TransactionalAppendOptions.d.ts.map +1 -0
- package/dist/transactions/TransactionalAppendOptions.js +4 -0
- package/dist/transactions/TransactionalAppendOptions.js.map +1 -0
- package/dist/transactions/UnitOfWork.d.ts +2 -2
- package/dist/transactions/UnitOfWork.d.ts.map +1 -1
- package/dist/transactions/UnitOfWork.js +9 -3
- package/dist/transactions/UnitOfWork.js.map +1 -1
- package/dist/transactions/index.d.ts +1 -0
- package/dist/transactions/index.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/AppendOptions.ts +11 -0
- package/eventSequences/EventForEventSourceId.ts +7 -0
- package/eventSequences/EventSequence.ts +30 -8
- package/eventSequences/ITransactionalEventSequence.ts +5 -5
- package/eventSequences/NamedTagsWithRegisteredEventSourceNotSupported.ts +12 -0
- package/eventSequences/TransactionalEventSequence.ts +8 -5
- package/eventSequences/createAppendNotification.ts +4 -2
- package/eventSequences/ensureNamedTagBatchIsSupported.ts +11 -0
- package/eventSequences/for_EventSequence/against_a_kernel/when_appending_named_tags.integration.spec.ts +107 -0
- package/eventSequences/for_EventSequence/when_appending_named_tags/given.fixture.ts +38 -0
- package/eventSequences/for_EventSequence/when_appending_named_tags/with_a_batch.spec.ts +49 -0
- package/eventSequences/for_EventSequence/when_appending_named_tags/with_a_single_event.spec.ts +47 -0
- package/eventSequences/for_EventSequence/when_appending_named_tags/with_an_invalid_tag.spec.ts +36 -0
- package/eventSequences/for_EventSequence/when_appending_named_tags/with_registered_sources.spec.ts +74 -0
- package/eventSequences/for_TransactionalEventSequence/when_appending_many_with_named_tags.spec.ts +41 -0
- package/eventSequences/index.ts +1 -0
- package/eventSequences/prepareBatchAppend.ts +4 -1
- package/eventSequences/prepareSingleAppend.ts +3 -1
- package/events/EventContext.ts +8 -0
- package/events/InvalidNamedTag.ts +13 -0
- package/events/NamedTag.ts +44 -0
- package/events/for_NamedTag/when_constructing/with_invalid_values.spec.ts +17 -0
- package/events/for_NamedTag/when_constructing/with_valid_values.spec.ts +19 -0
- package/events/for_mergeNamedTags/when_merging/with_event_and_call_tags.spec.ts +31 -0
- package/events/for_mergeNamedTags/when_merging/with_invalid_entries.spec.ts +17 -0
- package/events/for_toClientEventContext/when_converting_named_tags.spec.ts +31 -0
- package/events/index.ts +3 -0
- package/events/mergeNamedTags.ts +37 -0
- package/events/toClientEventContext.ts +3 -1
- package/package.json +5 -5
- package/projections/eventContextPropertyExpression.ts +3 -2
- package/testing/InProcessEventSequence.ts +17 -7
- package/testing/ReactorScenario.ts +6 -0
- package/testing/for_EventScenario/when_appending_named_tags/with_named_tags.spec.ts +48 -0
- package/testing/for_ReactorScenario/when_recording_side_effects/with_named_tags.spec.ts +119 -0
- package/testing/for_ReadModelScenario/when_seeding_with_options/with_named_tags.spec.ts +35 -0
- package/testing/singleAppendRoute.ts +13 -1
- package/transactions/IUnitOfWork.ts +4 -3
- package/transactions/TransactionalAppendOptions.ts +17 -0
- package/transactions/UnitOfWork.ts +13 -4
- package/transactions/for_UnitOfWork/when_committing_events_with_named_tags.spec.ts +112 -0
- package/transactions/index.ts +1 -0
|
@@ -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 { field } from '@cratis/fundamentals';
|
|
5
|
+
import { beforeEach, chai, describe, it } from 'vitest';
|
|
6
|
+
import { eventType } from '../../../events/eventTypeDecorator.js';
|
|
7
|
+
import { InvalidNamedTag } from '../../../events/InvalidNamedTag.js';
|
|
8
|
+
import { NamedTag } from '../../../events/NamedTag.js';
|
|
9
|
+
import { NamedTagsWithRegisteredEventSourceNotSupported } from '../../../eventSequences/index.js';
|
|
10
|
+
import type { EventContext } from '../../../events/EventContext.js';
|
|
11
|
+
import { reactor } from '../../../reactors/reactor.js';
|
|
12
|
+
import { ReactorScenario } from '../../ReactorScenario.js';
|
|
13
|
+
|
|
14
|
+
chai.should();
|
|
15
|
+
@eventType('scenario-named-tag-input')
|
|
16
|
+
class NamedTagInput { @field(String) name = 'input'; }
|
|
17
|
+
@eventType('scenario-named-tag-output')
|
|
18
|
+
class NamedTagOutput { @field(String) name = 'output'; }
|
|
19
|
+
|
|
20
|
+
const contexts: EventContext[] = [];
|
|
21
|
+
@reactor('scenario-named-tag-reactor')
|
|
22
|
+
class NamedTagReactor {
|
|
23
|
+
namedTagInput(_: NamedTagInput, context: EventContext) {
|
|
24
|
+
contexts.push(context);
|
|
25
|
+
return { eventSourceId: 'other', event: new NamedTagOutput(), namedTags: [new NamedTag('origin', context.eventSourceId)] };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
@reactor('scenario-invalid-named-tag-reactor')
|
|
29
|
+
class InvalidNamedTagReactor {
|
|
30
|
+
namedTagInput() {
|
|
31
|
+
return { eventSourceId: 'other', event: new NamedTagOutput(), namedTags: [{ name: ' ', value: 'x' }] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
@reactor('scenario-named-tags-registered-source-same-entry')
|
|
35
|
+
class NamedTagRegisteredSourceReactor {
|
|
36
|
+
namedTagInput() {
|
|
37
|
+
return { eventSourceId: 'other', event: new NamedTagOutput(), eventSource: 'Account', namedTags: [new NamedTag('origin', 'trigger')] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
@reactor('scenario-named-tags-registered-source-cross-entry')
|
|
41
|
+
class NamedTagCrossEntryReactor {
|
|
42
|
+
namedTagInput() {
|
|
43
|
+
return [
|
|
44
|
+
{ eventSourceId: 'tagged', event: new NamedTagOutput(), namedTags: [new NamedTag('origin', 'trigger')] },
|
|
45
|
+
{ eventSourceId: 'registered', event: new NamedTagOutput(), eventSource: 'Account' }
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
@reactor('scenario-named-tags-registered-source-cross-entry-reversed')
|
|
50
|
+
class NamedTagCrossEntryReversedReactor {
|
|
51
|
+
namedTagInput() {
|
|
52
|
+
return [
|
|
53
|
+
{ eventSourceId: 'registered', event: new NamedTagOutput(), eventSource: 'Account' },
|
|
54
|
+
{ eventSourceId: 'tagged', event: new NamedTagOutput(), namedTags: [new NamedTag('origin', 'trigger')] }
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
@reactor('scenario-empty-named-tags-registered-source')
|
|
59
|
+
class EmptyNamedTagsRegisteredSourceReactor {
|
|
60
|
+
namedTagInput() {
|
|
61
|
+
return { eventSourceId: 'other', event: new NamedTagOutput(), eventSource: 'Account', namedTags: [] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const artifacts = { eventTypes: [NamedTagInput, NamedTagOutput] };
|
|
65
|
+
|
|
66
|
+
describe('when recording a side effect with named tags', () => {
|
|
67
|
+
let scenario: ReactorScenario;
|
|
68
|
+
beforeEach(async () => {
|
|
69
|
+
contexts.length = 0;
|
|
70
|
+
scenario = new ReactorScenario(NamedTagReactor, { artifacts, constraints: 'disabled' });
|
|
71
|
+
await scenario.when.forEventSource('trigger').events(new NamedTagInput());
|
|
72
|
+
});
|
|
73
|
+
it('should record the named tags on the side-effect target', () =>
|
|
74
|
+
scenario.sideEffects[0].target.namedTags!.map(tag => `${tag.name}=${tag.value}`).should.deep.equal(['origin=trigger']));
|
|
75
|
+
it('should deliver an empty named tag list like a kernel delivery of an untagged event', () =>
|
|
76
|
+
contexts[0].namedTags!.should.deep.equal([]));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('when recording a side effect with an invalid named tag', () => {
|
|
80
|
+
let error: unknown;
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
const scenario = new ReactorScenario(InvalidNamedTagReactor, { artifacts, constraints: 'disabled' });
|
|
83
|
+
error = await scenario.when.forEventSource('trigger').events(new NamedTagInput()).catch(caught => caught);
|
|
84
|
+
});
|
|
85
|
+
it('should fail the delivery with an invalid named tag error, as production append does', () =>
|
|
86
|
+
(error as Error).should.be.instanceOf(InvalidNamedTag));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
for (const [description, reactorType] of [
|
|
90
|
+
['the same entry', NamedTagRegisteredSourceReactor],
|
|
91
|
+
['a tagged entry before a registered entry', NamedTagCrossEntryReactor],
|
|
92
|
+
['a registered entry before a tagged entry', NamedTagCrossEntryReversedReactor]
|
|
93
|
+
] as const) {
|
|
94
|
+
describe(`when recording named tags and a registered source in ${description}`, () => {
|
|
95
|
+
let scenario: ReactorScenario;
|
|
96
|
+
let error: unknown;
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
scenario = new ReactorScenario(reactorType, { artifacts, constraints: 'disabled' });
|
|
99
|
+
error = await scenario.when.forEventSource('trigger').events(new NamedTagInput()).catch(caught => caught);
|
|
100
|
+
});
|
|
101
|
+
it('should reject with the production unsupported combination error', () =>
|
|
102
|
+
(error as Error).should.be.instanceOf(NamedTagsWithRegisteredEventSourceNotSupported));
|
|
103
|
+
it('should record an unsuccessful delivery', () => scenario.results[0].completed.should.be.false);
|
|
104
|
+
it('should retain the rejection on the delivery result', () => scenario.results[0].error!.should.equal(error));
|
|
105
|
+
it('should not record any returned side effect', () => scenario.sideEffects.should.have.lengthOf(0));
|
|
106
|
+
it('should not report any produced event', () => scenario.produced.should.have.lengthOf(0));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('when recording a registered source with empty named tags', () => {
|
|
111
|
+
let scenario: ReactorScenario;
|
|
112
|
+
beforeEach(async () => {
|
|
113
|
+
scenario = new ReactorScenario(EmptyNamedTagsRegisteredSourceReactor, { artifacts, constraints: 'disabled' });
|
|
114
|
+
await scenario.when.forEventSource('trigger').events(new NamedTagInput());
|
|
115
|
+
});
|
|
116
|
+
it('should complete the delivery', () => scenario.results[0].completed.should.be.true);
|
|
117
|
+
it('should retain the registered source on the recorded effect', () => scenario.sideEffects[0].target.eventSource!.should.equal('Account'));
|
|
118
|
+
it('should retain the empty named tag array', () => scenario.sideEffects[0].target.namedTags!.should.deep.equal([]));
|
|
119
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { beforeEach, chai, describe, it } from 'vitest';
|
|
5
|
+
import { NamedTag } from '../../../events/index.js';
|
|
6
|
+
import { reducer } from '../../../reducers/reducer.js';
|
|
7
|
+
import { ReadModelScenario, UnsupportedEventSequenceOperation } from '../../index.js';
|
|
8
|
+
import { Claim, rejection } from '../../for_EventScenario/given/routed_scenario.fixture.js';
|
|
9
|
+
|
|
10
|
+
chai.should();
|
|
11
|
+
class Count { claimed = 0; }
|
|
12
|
+
class Counter { claim(_event: Claim, current: Count | undefined): Count { return { claimed: (current?.claimed ?? 0) + 1 }; } }
|
|
13
|
+
reducer('NamedTagSetupCounter', undefined, Count)(Counter);
|
|
14
|
+
const create = () => new ReadModelScenario(Count, { eventTypes: [Claim], reducers: [Counter], projections: [] });
|
|
15
|
+
|
|
16
|
+
describe('when read-model setup receives non-empty named tags', () => {
|
|
17
|
+
let scenario: ReadModelScenario<Count>;
|
|
18
|
+
let error: Error;
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
scenario = create();
|
|
21
|
+
error = await rejection(() => scenario.given.forEventSource('A', { namedTags: [new NamedTag('batch', 'b-1')] }).events(new Claim('Rejected')));
|
|
22
|
+
});
|
|
23
|
+
it('should reject with the typed unsupported operation error', () => error.should.be.instanceOf(UnsupportedEventSequenceOperation));
|
|
24
|
+
it('should identify the append named tags operation', () => error.message.should.contain('UnsupportedEventSequenceOperation: append.namedTags (Claim)'));
|
|
25
|
+
it('should not collect the rejected event', async () => ((await scenario.instance) === null).should.be.true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('when read-model setup receives an empty named tag array', () => {
|
|
29
|
+
let scenario: ReadModelScenario<Count>;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
scenario = create();
|
|
32
|
+
scenario.given.forEventSource('A', { namedTags: [] }).events(new Claim('Accepted'));
|
|
33
|
+
});
|
|
34
|
+
it('should accept and fold the event', async () => (await scenario.instance)!.claimed.should.equal(1));
|
|
35
|
+
});
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
import type { AppendOptions } from '../eventSequences/AppendOptions.js';
|
|
5
5
|
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Whether a named-tag option asks for anything. An explicitly empty array carries no tags, exactly like an
|
|
9
|
+
* omitted option; anything else is reported, including malformed values the production client would reject.
|
|
10
|
+
*/
|
|
11
|
+
export function hasNamedTags(namedTags: unknown): boolean {
|
|
12
|
+
return namedTags !== undefined && !(Array.isArray(namedTags) && namedTags.length === 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
/** The fixture-proven single-append route boundary, shared with read-model setup. */
|
|
8
16
|
export function singleAppendRoute(options: AppendOptions | undefined, artifact: string,
|
|
9
17
|
unsupported = (operation: string, artifact: string, reason: string) => new UnsupportedEventSequenceOperation(operation, artifact, reason),
|
|
@@ -12,8 +20,12 @@ export function singleAppendRoute(options: AppendOptions | undefined, artifact:
|
|
|
12
20
|
if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) {
|
|
13
21
|
throw unsupported('append.options', artifact, 'Append options must be an object.');
|
|
14
22
|
}
|
|
23
|
+
if (options && hasNamedTags(options.namedTags)) {
|
|
24
|
+
throw unsupported('append.namedTags', artifact, 'Named tags are not fixture-backed.');
|
|
25
|
+
}
|
|
15
26
|
const allowed = ['sourceType', 'streamType', 'streamId', ...(allowSubject ? ['subject'] : [])];
|
|
16
|
-
|
|
27
|
+
// An explicitly empty namedTags array is accepted (checked above) but not advertised as a supported option.
|
|
28
|
+
if (options && (Reflect.ownKeys(options).some(key => key !== 'namedTags' && !allowed.includes(String(key))) ||
|
|
17
29
|
options.correlationId !== undefined || (!allowSubject && options.subject !== undefined) || options.occurred !== undefined ||
|
|
18
30
|
options.eventSourceId !== undefined || options.concurrencyScope !== undefined || options.tags !== undefined ||
|
|
19
31
|
options.concurrencyScopes !== undefined)) {
|
|
@@ -6,7 +6,7 @@ import { AppendError } from '../eventSequences/AppendError.js';
|
|
|
6
6
|
import { ConcurrencyViolation } from '../eventSequences/ConcurrencyViolation.js';
|
|
7
7
|
import { ConstraintViolation } from '../eventSequences/ConstraintViolation.js';
|
|
8
8
|
import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
|
|
9
|
-
import {
|
|
9
|
+
import type { TransactionalAppendOptions } from './TransactionalAppendOptions.js';
|
|
10
10
|
import { AppendResult } from '../eventSequences/AppendResult.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -27,9 +27,10 @@ export interface IUnitOfWork {
|
|
|
27
27
|
* @param eventSequenceId - The identifier of the event sequence to append to.
|
|
28
28
|
* @param eventSourceId - The event source identifier.
|
|
29
29
|
* @param event - The event payload.
|
|
30
|
-
* @param
|
|
30
|
+
* @param options - Optional registered event source / stream routing and named tags for this event.
|
|
31
|
+
* @throws {@link InvalidNamedTag} when a named tag is invalid; the event is not added.
|
|
31
32
|
*/
|
|
32
|
-
addEvent(eventSequenceId: EventSequenceId, eventSourceId: string, event: object,
|
|
33
|
+
addEvent(eventSequenceId: EventSequenceId, eventSourceId: string, event: object, options?: TransactionalAppendOptions): void;
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Gets all events currently buffered in this unit of work.
|
|
@@ -0,0 +1,17 @@
|
|
|
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 type { NamedTag } from '../events/NamedTag.js';
|
|
5
|
+
import type { TransactionalEventRouting } from './TransactionalEventRouting.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Optional metadata for an event added to a unit of work: registered event source routing and
|
|
9
|
+
* structured named tags. Carried through to the append on commit.
|
|
10
|
+
*/
|
|
11
|
+
export interface TransactionalAppendOptions extends TransactionalEventRouting {
|
|
12
|
+
/**
|
|
13
|
+
* Structured named tags for the event. Validated when the event is added, so an invalid tag fails
|
|
14
|
+
* before anything is committed; the first occurrence of each exact name and value pair is kept.
|
|
15
|
+
*/
|
|
16
|
+
readonly namedTags?: ReadonlyArray<NamedTag>;
|
|
17
|
+
}
|
|
@@ -9,8 +9,10 @@ import { ConstraintViolation } from '../eventSequences/ConstraintViolation.js';
|
|
|
9
9
|
import { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
|
|
10
10
|
import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
|
|
11
11
|
import { planDerivedGuards, resolveBatchRouting } from '../eventSequences/resolveEventSourceRouting.js';
|
|
12
|
+
import { ensureNamedTagBatchIsSupported } from '../eventSequences/ensureNamedTagBatchIsSupported.js';
|
|
12
13
|
import { IEventStore } from '../IEventStore.js';
|
|
13
|
-
import {
|
|
14
|
+
import type { TransactionalAppendOptions } from './TransactionalAppendOptions.js';
|
|
15
|
+
import { mergeNamedTags } from '../events/mergeNamedTags.js';
|
|
14
16
|
import { IUnitOfWork } from './IUnitOfWork.js';
|
|
15
17
|
|
|
16
18
|
interface EventForEventSourceIdWithSequenceNumber {
|
|
@@ -48,16 +50,18 @@ export class UnitOfWork implements IUnitOfWork {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** @inheritdoc */
|
|
51
|
-
addEvent(eventSequenceId: EventSequenceId, eventSourceId: string, event: object,
|
|
53
|
+
addEvent(eventSequenceId: EventSequenceId, eventSourceId: string, event: object, options?: TransactionalAppendOptions): void {
|
|
52
54
|
this.throwIfCompleted();
|
|
55
|
+
const namedTags = mergeNamedTags(options?.namedTags);
|
|
53
56
|
this._events.push({
|
|
54
57
|
sequenceNumber: this._events.length,
|
|
55
58
|
eventSequenceId,
|
|
56
59
|
eventForEventSourceId: {
|
|
57
60
|
eventSourceId,
|
|
58
61
|
event,
|
|
59
|
-
...(
|
|
60
|
-
...(
|
|
62
|
+
...(options?.eventSource !== undefined ? { eventSource: options.eventSource } : {}),
|
|
63
|
+
...(options?.eventStream !== undefined ? { eventStream: options.eventStream } : {}),
|
|
64
|
+
...(namedTags.length > 0 ? { namedTags } : {})
|
|
61
65
|
}
|
|
62
66
|
});
|
|
63
67
|
}
|
|
@@ -94,6 +98,11 @@ export class UnitOfWork implements IUnitOfWork {
|
|
|
94
98
|
this.throwIfCompleted();
|
|
95
99
|
|
|
96
100
|
if (this._events.length > 0) {
|
|
101
|
+
// Validate the entire commit before writing any sequence, including combinations in different groups.
|
|
102
|
+
ensureNamedTagBatchIsSupported(
|
|
103
|
+
this._events.some(entry => (entry.eventForEventSourceId.namedTags?.length ?? 0) > 0),
|
|
104
|
+
this._events.some(entry => entry.eventForEventSourceId.eventSource !== undefined)
|
|
105
|
+
);
|
|
97
106
|
const resultsBySequenceNumber = new Map<number, AppendResult>();
|
|
98
107
|
const eventsByEventSequence = new Map<string, EventForEventSourceIdWithSequenceNumber[]>();
|
|
99
108
|
|
|
@@ -0,0 +1,112 @@
|
|
|
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 { Guid } from '@cratis/fundamentals';
|
|
5
|
+
import { beforeEach, chai, describe, it, vi } from 'vitest';
|
|
6
|
+
import { InvalidNamedTag, NamedTag } from '../../events/index.js';
|
|
7
|
+
import { EventSequenceId, NamedTagsWithRegisteredEventSourceNotSupported, type EventForEventSourceId } from '../../eventSequences/index.js';
|
|
8
|
+
import { ConcurrencyDimensions, type IEventSources } from '../../eventSources/index.js';
|
|
9
|
+
import type { IEventStore } from '../../IEventStore.js';
|
|
10
|
+
import { UnitOfWork } from '../UnitOfWork.js';
|
|
11
|
+
|
|
12
|
+
const should = chai.should();
|
|
13
|
+
const success = { isSuccess: true, constraintViolations: [], errors: [] };
|
|
14
|
+
|
|
15
|
+
class Account {}
|
|
16
|
+
const eventSources = {
|
|
17
|
+
getFor: () => ({ type: Account, name: 'Account', description: '', concurrency: ConcurrencyDimensions.none, streams: [] })
|
|
18
|
+
} as unknown as IEventSources;
|
|
19
|
+
|
|
20
|
+
function a_unit_of_work() {
|
|
21
|
+
const appendMany = vi.fn().mockImplementation(async (events: EventForEventSourceId[]) => events.map(() => success));
|
|
22
|
+
const completed = vi.fn();
|
|
23
|
+
const unitOfWork = new UnitOfWork(Guid.create(), completed, { eventSources, getEventSequence: () => ({ appendMany }) } as unknown as IEventStore);
|
|
24
|
+
return { unitOfWork, appendMany, completed };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('when committing events with named tags in a unit of work', () => {
|
|
28
|
+
let batch: EventForEventSourceId[];
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
const { unitOfWork, appendMany } = a_unit_of_work();
|
|
31
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { first: 1 },
|
|
32
|
+
{ namedTags: [new NamedTag('batch', 'b-1'), new NamedTag('batch', 'b-1')] });
|
|
33
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-2', { second: 2 }, { namedTags: [] });
|
|
34
|
+
await unitOfWork.commit();
|
|
35
|
+
batch = appendMany.mock.calls[0][0];
|
|
36
|
+
});
|
|
37
|
+
it('should carry the distinct named tags on the event they were added with', () =>
|
|
38
|
+
batch[0].namedTags!.map(tag => `${tag.name}=${tag.value}`).should.deep.equal(['batch=b-1']));
|
|
39
|
+
it('should not add named tags to an event added with none', () => should.not.exist(batch[1].namedTags));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('when adding an event with an invalid named tag to a unit of work', () => {
|
|
43
|
+
let error: unknown;
|
|
44
|
+
let unitOfWork: UnitOfWork;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
({ unitOfWork } = a_unit_of_work());
|
|
47
|
+
try {
|
|
48
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', {}, { namedTags: [{ name: ' ', value: 'x' } as NamedTag] });
|
|
49
|
+
} catch (caught) {
|
|
50
|
+
error = caught;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
it('should fail with an invalid named tag error', () => (error as Error).should.be.instanceOf(InvalidNamedTag));
|
|
54
|
+
it('should not buffer the event', () => unitOfWork.getEvents().should.have.lengthOf(0));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
for (const separateSequences of [false, true]) {
|
|
58
|
+
describe(`when committing named tags and a registered source in ${separateSequences ? 'different event sequences' : 'one batch'}`, () => {
|
|
59
|
+
let calls: ReturnType<typeof a_unit_of_work>;
|
|
60
|
+
let error: unknown;
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
calls = a_unit_of_work();
|
|
63
|
+
// An otherwise valid group must not be written before a later unsupported combination is found.
|
|
64
|
+
calls.unitOfWork.addEvent(EventSequenceId.eventLog, 'plain', { first: 1 });
|
|
65
|
+
const sequenceId = separateSequences ? new EventSequenceId('other') : EventSequenceId.eventLog;
|
|
66
|
+
calls.unitOfWork.addEvent(sequenceId, 'tagged', { second: 2 }, { namedTags: [new NamedTag('batch', 'b-1')] });
|
|
67
|
+
calls.unitOfWork.addEvent(sequenceId, 'registered', { third: 3 }, { eventSource: Account });
|
|
68
|
+
error = await calls.unitOfWork.commit().catch(caught => caught);
|
|
69
|
+
});
|
|
70
|
+
it('should reject with the typed unsupported combination error', () =>
|
|
71
|
+
(error as Error).should.be.instanceOf(NamedTagsWithRegisteredEventSourceNotSupported));
|
|
72
|
+
it('should not write any group', () => calls.appendMany.mock.calls.should.have.lengthOf(0));
|
|
73
|
+
it('should leave the unit of work uncompleted', () => calls.unitOfWork.isCompleted.should.be.false);
|
|
74
|
+
it('should not invoke completion callbacks', () => calls.completed.mock.calls.should.have.lengthOf(0));
|
|
75
|
+
it('should retain the buffered events for rollback', () => calls.unitOfWork.getEvents().should.have.lengthOf(3));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('when committing tags and registered sources on different event sequences', () => {
|
|
80
|
+
let calls: ReturnType<typeof a_unit_of_work>;
|
|
81
|
+
let error: unknown;
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
calls = a_unit_of_work();
|
|
84
|
+
calls.unitOfWork.addEvent(EventSequenceId.eventLog, 'tagged', {}, { namedTags: [new NamedTag('batch', 'b-1')] });
|
|
85
|
+
calls.unitOfWork.addEvent(new EventSequenceId('other'), 'registered', {}, { eventSource: 'Account' });
|
|
86
|
+
error = await calls.unitOfWork.commit().catch(caught => caught);
|
|
87
|
+
});
|
|
88
|
+
it('should reject the entire commit', () => (error as Error).should.be.instanceOf(NamedTagsWithRegisteredEventSourceNotSupported));
|
|
89
|
+
it('should not append the first event sequence', () => calls.appendMany.mock.calls.should.have.lengthOf(0));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('when committing a registered source and named tags on one event', () => {
|
|
93
|
+
let error: unknown;
|
|
94
|
+
beforeEach(async () => {
|
|
95
|
+
const { unitOfWork } = a_unit_of_work();
|
|
96
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'registered', {}, { eventSource: 'Account', namedTags: [new NamedTag('batch', 'b-1')] });
|
|
97
|
+
error = await unitOfWork.commit().catch(caught => caught);
|
|
98
|
+
});
|
|
99
|
+
it('should reject with the typed unsupported combination error', () =>
|
|
100
|
+
(error as Error).should.be.instanceOf(NamedTagsWithRegisteredEventSourceNotSupported));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('when committing a registered source with an empty named tag array', () => {
|
|
104
|
+
let calls: ReturnType<typeof a_unit_of_work>;
|
|
105
|
+
beforeEach(async () => {
|
|
106
|
+
calls = a_unit_of_work();
|
|
107
|
+
calls.unitOfWork.addEvent(EventSequenceId.eventLog, 'registered', {}, { eventSource: 'Account', namedTags: [] });
|
|
108
|
+
await calls.unitOfWork.commit();
|
|
109
|
+
});
|
|
110
|
+
it('should append the registered source', () => calls.appendMany.mock.calls[0][0][0].eventSource.should.equal('Account'));
|
|
111
|
+
it('should complete the unit of work', () => calls.unitOfWork.isCompleted.should.be.true);
|
|
112
|
+
});
|
package/transactions/index.ts
CHANGED
|
@@ -7,3 +7,4 @@ export { NoUnitOfWorkHasBeenStarted } from './NoUnitOfWorkHasBeenStarted.js';
|
|
|
7
7
|
export { UnitOfWork } from './UnitOfWork.js';
|
|
8
8
|
export { UnitOfWorkManager } from './UnitOfWorkManager.js';
|
|
9
9
|
export type { TransactionalEventRouting } from './TransactionalEventRouting.js';
|
|
10
|
+
export type { TransactionalAppendOptions } from './TransactionalAppendOptions.js';
|