@cratis/chronicle 6.50.0 → 6.51.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 (119) hide show
  1. package/dist/eventSequences/ITransactionalEventSequence.d.ts +2 -2
  2. package/dist/events/constraints/ConstraintBuilder.d.ts +7 -0
  3. package/dist/events/constraints/ConstraintBuilder.d.ts.map +1 -1
  4. package/dist/events/constraints/ConstraintBuilder.js +13 -0
  5. package/dist/events/constraints/ConstraintBuilder.js.map +1 -1
  6. package/dist/events/constraints/ConstraintEventSequences.d.ts +19 -0
  7. package/dist/events/constraints/ConstraintEventSequences.d.ts.map +1 -0
  8. package/dist/events/constraints/ConstraintEventSequences.js +39 -0
  9. package/dist/events/constraints/ConstraintEventSequences.js.map +1 -0
  10. package/dist/events/constraints/Constraints.d.ts.map +1 -1
  11. package/dist/events/constraints/Constraints.js +24 -3
  12. package/dist/events/constraints/Constraints.js.map +1 -1
  13. package/dist/events/constraints/IConstraintBuilder.d.ts +18 -0
  14. package/dist/events/constraints/IConstraintBuilder.d.ts.map +1 -1
  15. package/dist/events/constraints/UniqueOptions.d.ts +15 -0
  16. package/dist/events/constraints/UniqueOptions.d.ts.map +1 -0
  17. package/dist/events/constraints/UniqueOptions.js +4 -0
  18. package/dist/events/constraints/UniqueOptions.js.map +1 -0
  19. package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.d.ts +9 -0
  20. package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.d.ts.map +1 -0
  21. package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.js +22 -0
  22. package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.js.map +1 -0
  23. package/dist/events/constraints/index.d.ts +2 -0
  24. package/dist/events/constraints/index.d.ts.map +1 -1
  25. package/dist/events/constraints/index.js.map +1 -1
  26. package/dist/events/constraints/unique.d.ts +10 -0
  27. package/dist/events/constraints/unique.d.ts.map +1 -1
  28. package/dist/events/constraints/unique.js +11 -10
  29. package/dist/events/constraints/unique.js.map +1 -1
  30. package/dist/testing/EventScenario.d.ts.map +1 -1
  31. package/dist/testing/EventScenario.js +8 -3
  32. package/dist/testing/EventScenario.js.map +1 -1
  33. package/dist/testing/InProcessConstraints.d.ts +4 -1
  34. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  35. package/dist/testing/InProcessConstraints.js +5 -1
  36. package/dist/testing/InProcessConstraints.js.map +1 -1
  37. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  38. package/dist/testing/InProcessEventSequence.js +2 -1
  39. package/dist/testing/InProcessEventSequence.js.map +1 -1
  40. package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.d.ts +14 -0
  41. package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.d.ts.map +1 -0
  42. package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.js +20 -0
  43. package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.js.map +1 -0
  44. package/dist/transactions/IUnitOfWork.d.ts +2 -1
  45. package/dist/transactions/IUnitOfWork.d.ts.map +1 -1
  46. package/dist/transactions/TransactionalAppendOptions.d.ts +23 -2
  47. package/dist/transactions/TransactionalAppendOptions.d.ts.map +1 -1
  48. package/dist/transactions/UnitOfWork.d.ts +1 -0
  49. package/dist/transactions/UnitOfWork.d.ts.map +1 -1
  50. package/dist/transactions/UnitOfWork.js +28 -13
  51. package/dist/transactions/UnitOfWork.js.map +1 -1
  52. package/dist/transactions/concurrencyScopesAreEqual.d.ts +9 -0
  53. package/dist/transactions/concurrencyScopesAreEqual.d.ts.map +1 -0
  54. package/dist/transactions/concurrencyScopesAreEqual.js +19 -0
  55. package/dist/transactions/concurrencyScopesAreEqual.js.map +1 -0
  56. package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.d.ts +26 -0
  57. package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.d.ts.map +1 -0
  58. package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.js +31 -0
  59. package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.js.map +1 -0
  60. package/dist/transactions/index.d.ts +1 -0
  61. package/dist/transactions/index.d.ts.map +1 -1
  62. package/dist/transactions/index.js +1 -0
  63. package/dist/transactions/index.js.map +1 -1
  64. package/dist/transactions/toEventForEventSourceId.d.ts +13 -0
  65. package/dist/transactions/toEventForEventSourceId.d.ts.map +1 -0
  66. package/dist/transactions/toEventForEventSourceId.js +29 -0
  67. package/dist/transactions/toEventForEventSourceId.js.map +1 -0
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/eventSequences/ITransactionalEventSequence.ts +2 -2
  70. package/eventSequences/for_TransactionalEventSequence/when_appending_many_with_options.spec.ts +29 -0
  71. package/events/constraints/ConstraintBuilder.ts +16 -0
  72. package/events/constraints/ConstraintEventSequences.ts +41 -0
  73. package/events/constraints/Constraints.ts +22 -3
  74. package/events/constraints/IConstraintBuilder.ts +20 -0
  75. package/events/constraints/UniqueOptions.ts +20 -0
  76. package/events/constraints/for_Constraints/against_a_kernel/when_appending_to_event_sequences_a_constraint_is_scoped_to.integration.spec.ts +118 -0
  77. package/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.ts +26 -0
  78. package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_fluent_builder.spec.ts +40 -0
  79. package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_legacy_decorators.legacy.spec.ts +47 -0
  80. package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_merged_declarations.spec.ts +36 -0
  81. package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_standard_decorators.standard.spec.ts +46 -0
  82. package/events/constraints/index.ts +2 -0
  83. package/events/constraints/unique.ts +22 -2
  84. package/package.json +2 -2
  85. package/testing/EventScenario.fixtures.spec.ts +1 -1
  86. package/testing/EventScenario.ts +10 -4
  87. package/testing/InProcessConstraints.ts +5 -1
  88. package/testing/InProcessEventSequence.ts +2 -1
  89. package/testing/for_EventScenario/when_appending_event_sequence_constraints/event_sequence_scenario_behaviors.fixture.ts +101 -0
  90. package/testing/for_EventScenario/when_appending_event_sequence_constraints/other_sequence_extended_key.legacy.spec.ts +28 -0
  91. package/testing/for_EventScenario/when_appending_event_sequence_constraints/other_sequence_extended_key.standard.spec.ts +27 -0
  92. package/testing/for_EventScenario/when_appending_event_sequence_constraints/other_sequence_extended_key_behaviors.fixture.ts +40 -0
  93. package/testing/for_EventScenario/when_appending_event_sequence_constraints/with_legacy_decorators.legacy.spec.ts +55 -0
  94. package/testing/for_EventScenario/when_appending_event_sequence_constraints/with_standard_decorators.standard.spec.ts +54 -0
  95. package/transactions/ConflictingConcurrencyScopesInUnitOfWork.ts +19 -0
  96. package/transactions/IUnitOfWork.ts +2 -1
  97. package/transactions/TransactionalAppendOptions.ts +30 -2
  98. package/transactions/UnitOfWork.ts +29 -13
  99. package/transactions/concurrencyScopesAreEqual.ts +23 -0
  100. package/transactions/for_UnitOfWork/against_a_kernel/when_committing_with_append_metadata.integration.spec.ts +70 -0
  101. package/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.ts +54 -0
  102. package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_event_type_filters_are_equivalent.spec.ts +32 -0
  103. package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_event_type_filters_differ.spec.ts +40 -0
  104. package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_scopes_are_equal_or_omitted.spec.ts +29 -0
  105. package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_scopes_differ_for_one_event_source.spec.ts +29 -0
  106. package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_a_later_event_sequence_fails.spec.ts +30 -0
  107. package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_events_span_event_sequences.spec.ts +34 -0
  108. package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_options_differ_per_event.spec.ts +45 -0
  109. package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_the_append_is_rejected.spec.ts +26 -0
  110. package/transactions/for_UnitOfWork/when_rolling_back_events_with_append_metadata.spec.ts +24 -0
  111. package/transactions/index.ts +1 -0
  112. package/transactions/toEventForEventSourceId.ts +32 -0
  113. package/vitest.integration.config.ts +2 -0
  114. package/webhooks/for_Webhooks/against_a_kernel/given/ReceivedWebhookRequest.fixture.ts +21 -0
  115. package/webhooks/for_Webhooks/against_a_kernel/given/WebhookReceiver.fixture.ts +99 -0
  116. package/webhooks/for_Webhooks/against_a_kernel/when_delivering_events_to_a_failing_webhook.integration.spec.ts +101 -0
  117. package/webhooks/for_Webhooks/against_a_kernel/when_delivering_events_to_a_webhook.integration.spec.ts +130 -0
  118. package/webhooks/for_Webhooks/against_a_kernel/when_registering_a_webhook_that_is_not_replayable_and_not_active.integration.spec.ts +101 -0
  119. package/webhooks/for_Webhooks/when_registering_a_webhook_that_is_not_replayable_and_not_active.spec.ts +43 -0
@@ -0,0 +1,27 @@
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 { describe } from 'vitest';
6
+ import { eventType } from '../../../events/eventTypeDecorator.js';
7
+ import { constraint } from '../../../events/constraints/constraint.js';
8
+ import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
9
+ import { otherSequenceExtendedKeyBehaviors } from './other_sequence_extended_key_behaviors.fixture.js';
10
+
11
+ @eventType('OracleOtherSequenceNumber')
12
+ class NumberClaimed { @field(Number) key = 1; }
13
+ @eventType('OracleOtherSequenceOutbox')
14
+ class OutboxClaimed { @field(String) label = 'x'; }
15
+
16
+ @constraint('OracleOtherSequenceNumberKey')
17
+ class NumberKey {
18
+ define(builder: IConstraintBuilder) { builder.forEventLog().unique(key => key.on(NumberClaimed, event => event.key)); }
19
+ }
20
+ @constraint('OracleOtherSequenceOutboxKey')
21
+ class OutboxKey {
22
+ define(builder: IConstraintBuilder) { builder.forEventSequences('outbox').unique(key => key.on(OutboxClaimed, event => event.label)); }
23
+ }
24
+
25
+ describe('when appending extended keys beside other event sequence constraints with decorators', () => {
26
+ otherSequenceExtendedKeyBehaviors(NumberClaimed, OutboxClaimed, [NumberKey, OutboxKey]);
27
+ });
@@ -0,0 +1,40 @@
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 { chai, describe, it, type Assertion } from 'vitest';
5
+ import type { Constructor } from '@cratis/fundamentals';
6
+ import { EventScenario } from '../../index.js';
7
+
8
+ chai.should();
9
+ function should(value: unknown): Assertion { return (value as { should: Assertion }).should; }
10
+
11
+ /**
12
+ * Proves a numeric unique constraint applicable to the event log is not rejected as non-isolated because another
13
+ * constraint is scoped only to a different event sequence.
14
+ * @param claimed - Event type constructor with a numeric key.
15
+ * @param other - Event type constructor used by the outbox-only constraint.
16
+ * @param constraints - The numeric event log constraint and the outbox-only constraint.
17
+ */
18
+ export function otherSequenceExtendedKeyBehaviors(
19
+ claimed: new () => object,
20
+ other: new () => object,
21
+ constraints: Constructor[]
22
+ ): void {
23
+ describe('with a numeric key and a constraint scoped to another event sequence', () => {
24
+ const create = () => new EventScenario({ artifacts: { eventTypes: [claimed, other], constraints } });
25
+
26
+ it('should construct the scenario', () => {
27
+ should(create).not.throw();
28
+ });
29
+
30
+ it('should accept the first event', async () => {
31
+ should((await create().append('A', new claimed())).isSuccess).equal(true);
32
+ });
33
+
34
+ it('should enforce the numeric constraint on the event log', async () => {
35
+ const scenario = create();
36
+ await scenario.append('A', new claimed());
37
+ should((await scenario.append('B', new claimed())).isSuccess).equal(false);
38
+ });
39
+ });
40
+ }
@@ -0,0 +1,55 @@
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 { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
11
+ import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
12
+ import { eventSequenceScenarioBehaviors } from './event_sequence_scenario_behaviors.fixture.js';
13
+
14
+ @eventType('OracleDomainText')
15
+ class Text { @field(String) key: string; constructor(key: string) { this.key = key; } }
16
+ @eventType('OracleDomainRemoved')
17
+ class Removed { @field(String) label: string; constructor(label: string) { this.label = label; } }
18
+ @eventType('OracleCycleFirst')
19
+ class First { @field(String) label: string; constructor(label: string) { this.label = label; } }
20
+ @eventType('OracleCycleSibling')
21
+ class Sibling { @field(String) label: string; constructor(label: string) { this.label = label; } }
22
+ @eventType('OracleCycleRemoved')
23
+ @removeConstraint('OracleSequencedCycle')
24
+ class CycleRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
25
+ @eventType('OracleCycleExpired')
26
+ @removeConstraint('OracleSequencedCycle')
27
+ class CycleExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
28
+
29
+ const outbox = new EventSequenceId('outbox');
30
+
31
+ function constraints(caseName: string): Constructor[] {
32
+ function select(builder: IConstraintBuilder): IConstraintBuilder {
33
+ if (caseName.endsWith('-event-log-and-outbox')) return builder.forEventSequences('outbox', EventSequenceId.eventLog);
34
+ return caseName.endsWith('-event-log') ? builder.forEventLog() : builder.forEventSequences(outbox);
35
+ }
36
+ @constraint('OracleSequencedKey')
37
+ class Key {
38
+ define(builder: IConstraintBuilder) {
39
+ select(builder).unique(key => key.on(Text, event => event.key).removedWith(Removed).withMessage('Taken: {PropertyValue}'));
40
+ }
41
+ }
42
+ @constraint('OracleSequencedFirst')
43
+ class FirstCycle { define(builder: IConstraintBuilder) { select(builder).uniqueFor(First, 'Cycle occupied', 'OracleSequencedCycle'); } }
44
+ @constraint('OracleSequencedSibling')
45
+ class SiblingCycle { define(builder: IConstraintBuilder) { select(builder).uniqueFor(Sibling, 'Cycle occupied', 'OracleSequencedCycle'); } }
46
+ @constraint('OracleSequencedOnceConstraint')
47
+ class Once { define(builder: IConstraintBuilder) { select(builder).uniqueFor(First, 'Already recorded', 'OracleSequencedOnce'); } }
48
+ const kind = caseName.split('-')[0];
49
+ return kind === 'property' ? [Key] : kind === 'once' ? [Once] : [FirstCycle, SiblingCycle];
50
+ }
51
+
52
+ describe('when appending event sequence constraints with legacy decorators', () => {
53
+ eventSequenceScenarioBehaviors({ string: Text, removed: Removed, first: First, sibling: Sibling,
54
+ cycleRemoved: CycleRemoved, cycleExpired: CycleExpired }, constraints);
55
+ });
@@ -0,0 +1,54 @@
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 { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
10
+ import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
11
+ import { eventSequenceScenarioBehaviors } from './event_sequence_scenario_behaviors.fixture.js';
12
+
13
+ @eventType('OracleDomainText')
14
+ class Text { @field(String) key: string; constructor(key: string) { this.key = key; } }
15
+ @eventType('OracleDomainRemoved')
16
+ class Removed { @field(String) label: string; constructor(label: string) { this.label = label; } }
17
+ @eventType('OracleCycleFirst')
18
+ class First { @field(String) label: string; constructor(label: string) { this.label = label; } }
19
+ @eventType('OracleCycleSibling')
20
+ class Sibling { @field(String) label: string; constructor(label: string) { this.label = label; } }
21
+ @eventType('OracleCycleRemoved')
22
+ @removeConstraint('OracleSequencedCycle')
23
+ class CycleRemoved { @field(String) label: string; constructor(label: string) { this.label = label; } }
24
+ @eventType('OracleCycleExpired')
25
+ @removeConstraint('OracleSequencedCycle')
26
+ class CycleExpired { @field(String) label: string; constructor(label: string) { this.label = label; } }
27
+
28
+ const outbox = new EventSequenceId('outbox');
29
+
30
+ function constraints(caseName: string): Constructor[] {
31
+ function select(builder: IConstraintBuilder): IConstraintBuilder {
32
+ if (caseName.endsWith('-event-log-and-outbox')) return builder.forEventSequences('outbox', EventSequenceId.eventLog);
33
+ return caseName.endsWith('-event-log') ? builder.forEventLog() : builder.forEventSequences(outbox);
34
+ }
35
+ @constraint('OracleSequencedKey')
36
+ class Key {
37
+ define(builder: IConstraintBuilder) {
38
+ select(builder).unique(key => key.on(Text, event => event.key).removedWith(Removed).withMessage('Taken: {PropertyValue}'));
39
+ }
40
+ }
41
+ @constraint('OracleSequencedFirst')
42
+ class FirstCycle { define(builder: IConstraintBuilder) { select(builder).uniqueFor(First, 'Cycle occupied', 'OracleSequencedCycle'); } }
43
+ @constraint('OracleSequencedSibling')
44
+ class SiblingCycle { define(builder: IConstraintBuilder) { select(builder).uniqueFor(Sibling, 'Cycle occupied', 'OracleSequencedCycle'); } }
45
+ @constraint('OracleSequencedOnceConstraint')
46
+ class Once { define(builder: IConstraintBuilder) { select(builder).uniqueFor(First, 'Already recorded', 'OracleSequencedOnce'); } }
47
+ const kind = caseName.split('-')[0];
48
+ return kind === 'property' ? [Key] : kind === 'once' ? [Once] : [FirstCycle, SiblingCycle];
49
+ }
50
+
51
+ describe('when appending event sequence constraints with standard decorators', () => {
52
+ eventSequenceScenarioBehaviors({ string: Text, removed: Removed, first: First, sibling: Sibling,
53
+ cycleRemoved: CycleRemoved, cycleExpired: CycleExpired }, constraints);
54
+ });
@@ -0,0 +1,19 @@
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
+ /**
5
+ * Error thrown when a unit of work is given a concurrency scope for an event source identifier that already has a different
6
+ * scope in the same event sequence. Chronicle validates one scope per event source identifier in an append, so the unit of work
7
+ * cannot commit both.
8
+ */
9
+ export class ConflictingConcurrencyScopesInUnitOfWork extends Error {
10
+ /**
11
+ * Initializes a new instance of the {@link ConflictingConcurrencyScopesInUnitOfWork} class.
12
+ * @param eventSequenceId - The identifier of the event sequence the events are added to.
13
+ */
14
+ constructor(readonly eventSequenceId: string) {
15
+ super(
16
+ `An event source in event sequence '${eventSequenceId}' already has a different concurrency scope in this unit of work. ` +
17
+ 'Use the same scope for every event of the event source, or commit the events in separate units of work.');
18
+ }
19
+ }
@@ -27,7 +27,8 @@ 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 options - Optional registered event source / stream routing and named tags for this event.
30
+ * @param options - Optional append metadata for this event: registered event source / stream routing, stream and source
31
+ * types, stream identifier, subject, occurrence time, tags, named tags and concurrency scope. Kept with the event until commit.
31
32
  * @throws {@link InvalidNamedTag} when a named tag is invalid; the event is not added.
32
33
  */
33
34
  addEvent(eventSequenceId: EventSequenceId, eventSourceId: string, event: object, options?: TransactionalAppendOptions): void;
@@ -2,13 +2,41 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import type { NamedTag } from '../events/NamedTag.js';
5
+ import type { Tag } from '../events/Tag.js';
6
+ import type { ConcurrencyScope } from '../eventSequences/ConcurrencyScope.js';
5
7
  import type { TransactionalEventRouting } from './TransactionalEventRouting.js';
6
8
 
7
9
  /**
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
+ * Optional append metadata for an event added to a unit of work.
11
+ * Every value is kept with the event and carried to the append when the unit of work commits,
12
+ * where it is validated like any other append.
10
13
  */
11
14
  export interface TransactionalAppendOptions extends TransactionalEventRouting {
15
+ /** Optional stream type. When omitted or empty, the kernel selects the route. */
16
+ readonly eventStreamType?: string;
17
+
18
+ /** Optional stream identifier. When omitted or empty, the kernel selects the route. */
19
+ readonly eventStreamId?: string;
20
+
21
+ /** Optional source type. When omitted or empty, the kernel selects the route. */
22
+ readonly eventSourceType?: string;
23
+
24
+ /** Optional occurrence time. When omitted, the kernel supplies the timestamp. */
25
+ readonly occurred?: Date;
26
+
27
+ /** Optional compliance subject. Overrides the event's @subject() value; otherwise defaults to the event source identifier. */
28
+ readonly subject?: string;
29
+
30
+ /** Optional tags, combined with any static tags declared on the event type via `@tag()`/`@tags()`. */
31
+ readonly tags?: ReadonlyArray<string | Tag>;
32
+
33
+ /**
34
+ * Optional concurrency scope for the event source identifier, validated when the unit of work commits.
35
+ * Chronicle accepts one scope per event source identifier in an append, so every event a unit of work holds
36
+ * for the same event source identifier in the same event sequence must use an equal scope or none.
37
+ */
38
+ readonly concurrencyScope?: ConcurrencyScope;
39
+
12
40
  /**
13
41
  * Structured named tags for the event. Validated when the event is added, so an invalid tag fails
14
42
  * before anything is committed; the first occurrence of each exact name and value pair is kept.
@@ -12,7 +12,10 @@ import { planDerivedGuards, resolveBatchRouting } from '../eventSequences/resolv
12
12
  import { ensureNamedTagBatchIsSupported } from '../eventSequences/ensureNamedTagBatchIsSupported.js';
13
13
  import { IEventStore } from '../IEventStore.js';
14
14
  import type { TransactionalAppendOptions } from './TransactionalAppendOptions.js';
15
- import { mergeNamedTags } from '../events/mergeNamedTags.js';
15
+ import type { ConcurrencyScope } from '../eventSequences/ConcurrencyScope.js';
16
+ import { ConflictingConcurrencyScopesInUnitOfWork } from './ConflictingConcurrencyScopesInUnitOfWork.js';
17
+ import { concurrencyScopesAreEqual } from './concurrencyScopesAreEqual.js';
18
+ import { toEventForEventSourceId } from './toEventForEventSourceId.js';
16
19
  import { IUnitOfWork } from './IUnitOfWork.js';
17
20
 
18
21
  interface EventForEventSourceIdWithSequenceNumber {
@@ -26,6 +29,7 @@ interface EventForEventSourceIdWithSequenceNumber {
26
29
  */
27
30
  export class UnitOfWork implements IUnitOfWork {
28
31
  private _events: EventForEventSourceIdWithSequenceNumber[] = [];
32
+ private _concurrencyScopes = new Map<string, Map<string, ConcurrencyScope>>();
29
33
  private _appendResults: AppendResult[] = [];
30
34
  private _isCommitted = false;
31
35
  private _isRolledBack = false;
@@ -52,17 +56,21 @@ export class UnitOfWork implements IUnitOfWork {
52
56
  /** @inheritdoc */
53
57
  addEvent(eventSequenceId: EventSequenceId, eventSourceId: string, event: object, options?: TransactionalAppendOptions): void {
54
58
  this.throwIfCompleted();
55
- const namedTags = mergeNamedTags(options?.namedTags);
59
+ const eventForEventSourceId = toEventForEventSourceId(eventSourceId, event, options);
60
+ const scope = options?.concurrencyScope;
61
+ const scopesForSequence = this._concurrencyScopes.get(eventSequenceId.value) ?? new Map<string, ConcurrencyScope>();
62
+ if (scope !== undefined) {
63
+ const enrolled = scopesForSequence.get(eventSourceId);
64
+ if (enrolled !== undefined && !concurrencyScopesAreEqual(enrolled, scope)) {
65
+ throw new ConflictingConcurrencyScopesInUnitOfWork(eventSequenceId.value);
66
+ }
67
+ scopesForSequence.set(eventSourceId, { ...scope, ...(scope.eventTypes ? { eventTypes: [...scope.eventTypes] } : {}) });
68
+ this._concurrencyScopes.set(eventSequenceId.value, scopesForSequence);
69
+ }
56
70
  this._events.push({
57
71
  sequenceNumber: this._events.length,
58
72
  eventSequenceId,
59
- eventForEventSourceId: {
60
- eventSourceId,
61
- event,
62
- ...(options?.eventSource !== undefined ? { eventSource: options.eventSource } : {}),
63
- ...(options?.eventStream !== undefined ? { eventStream: options.eventStream } : {}),
64
- ...(namedTags.length > 0 ? { namedTags } : {})
65
- }
73
+ eventForEventSourceId
66
74
  });
67
75
  }
68
76
 
@@ -115,16 +123,23 @@ export class UnitOfWork implements IUnitOfWork {
115
123
 
116
124
  // Resolve routing and required guards for every group first: a rejection must not follow a written group.
117
125
  const eventSources = this._eventStore.eventSources;
118
- for (const eventsForSequence of eventSources ? eventsByEventSequence.values() : []) {
119
- const events = eventsForSequence.map(_ => _.eventForEventSourceId);
120
- planDerivedGuards(events.map(_ => resolveBatchRouting(eventSources, _)), events, undefined);
126
+ if (eventSources) {
127
+ for (const [eventSequenceId, eventsForSequence] of eventsByEventSequence) {
128
+ const events = eventsForSequence.map(_ => _.eventForEventSourceId);
129
+ const explicitIds = new Set(this._concurrencyScopes.get(eventSequenceId)?.keys() ?? []);
130
+ planDerivedGuards(events.map(_ => resolveBatchRouting(eventSources, _)), events, undefined, explicitIds);
131
+ }
121
132
  }
122
133
 
123
134
  for (const [eventSequenceId, eventsForSequence] of eventsByEventSequence) {
124
135
  const sequence = this._eventStore.getEventSequence(new EventSequenceId(eventSequenceId));
136
+ const scopes = this._concurrencyScopes.get(eventSequenceId);
125
137
  const appendResults = await sequence.appendMany(
126
138
  eventsForSequence.map(_ => _.eventForEventSourceId),
127
- { correlationId: this.correlationId }
139
+ {
140
+ correlationId: this.correlationId,
141
+ ...(scopes && scopes.size > 0 ? { concurrencyScopes: Object.fromEntries(scopes) } : {})
142
+ }
128
143
  );
129
144
 
130
145
  appendResults.forEach((appendResult, index) => {
@@ -148,6 +163,7 @@ export class UnitOfWork implements IUnitOfWork {
148
163
  this.throwIfCompleted();
149
164
  this._isRolledBack = true;
150
165
  this._events = [];
166
+ this._concurrencyScopes = new Map();
151
167
  this._appendResults = [];
152
168
  this._onCompleted(this);
153
169
  }
@@ -0,0 +1,23 @@
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 { ConcurrencyScope } from '../eventSequences/ConcurrencyScope.js';
5
+
6
+ /**
7
+ * Compares two concurrency scopes by value.
8
+ * @param first - The first scope.
9
+ * @param second - The second scope.
10
+ * @returns True when both scopes validate the same expectation.
11
+ */
12
+ export function concurrencyScopesAreEqual(first: ConcurrencyScope, second: ConcurrencyScope): boolean {
13
+ // Event type filters are sets of id + generation: omitted equals empty, duplicates and order do not matter.
14
+ const eventTypeKeys = (scope: ConcurrencyScope) =>
15
+ [...new Set((scope.eventTypes ?? []).map(_ => `${_.id.value}+${_.generation.value}`))].sort().join(',');
16
+
17
+ return first.sequenceNumber === second.sequenceNumber
18
+ && (first.eventSourceId ?? false) === (second.eventSourceId ?? false)
19
+ && first.eventStreamType === second.eventStreamType
20
+ && first.eventStreamId === second.eventStreamId
21
+ && first.eventSourceType === second.eventSourceType
22
+ && eventTypeKeys(first) === eventTypeKeys(second);
23
+ }
@@ -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 { randomUUID } from 'node:crypto';
5
+ import { afterAll, beforeAll, chai, describe, it } from 'vitest';
6
+ import { field } from '@cratis/fundamentals';
7
+ import { ChronicleClient, ChronicleOptions, EventSequenceNumber, eventType, type AppendedEvent,
8
+ type IClientArtifactsProvider, type IEventStore } from '../../../index.js';
9
+
10
+ chai.should();
11
+ const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
12
+
13
+ @eventType()
14
+ class ItemAdded { @field(Number) order = 0; }
15
+
16
+ const artifacts: IClientArtifactsProvider = {
17
+ eventTypes: [ItemAdded], eventSources: [], readModels: [], reactors: [], reducers: [],
18
+ seeders: [], constraints: [], projections: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
19
+ };
20
+
21
+ const item = (order: number) => Object.assign(new ItemAdded(), { order });
22
+
23
+ describe.skipIf(!connectionString && !process.env.CI)('when committing a unit of work with append metadata against a kernel', () => {
24
+ const storeName = `UnitOfWork${randomUUID().replaceAll('-', '').slice(0, 12)}`;
25
+ const occurred = new Date('2026-03-04T05:06:07.000Z');
26
+ let client: ChronicleClient;
27
+ let store: IEventStore;
28
+ let id: string;
29
+ let events: AppendedEvent[];
30
+ let committed: boolean;
31
+ let staleSucceeded: boolean;
32
+ let eventsAfterStale: number;
33
+
34
+ beforeAll(async () => {
35
+ client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, { discoveryPatterns: [], clientArtifactsProvider: artifacts }));
36
+ store = await client.getEventStore(storeName);
37
+ id = randomUUID();
38
+ const seeded = await store.eventLog.append(id, item(-1));
39
+ const tail = { sequenceNumber: seeded.sequenceNumber.value, eventSourceId: true };
40
+
41
+ const unitOfWork = store.unitOfWorkManager.begin();
42
+ await store.eventLog.transactional.append(id, item(0), {
43
+ eventStreamType: 'Orders', eventStreamId: 'order-1', eventSourceType: 'Customer',
44
+ subject: 'subject-1', occurred, tags: ['priority'], concurrencyScope: tail
45
+ });
46
+ await store.eventLog.transactional.append(id, item(1));
47
+ await unitOfWork.commit();
48
+ committed = unitOfWork.isSuccess;
49
+ events = (await store.eventLog.getFromSequenceNumber(EventSequenceNumber.first, id, [ItemAdded])).slice(1);
50
+
51
+ const stale = store.unitOfWorkManager.begin();
52
+ await store.eventLog.transactional.append(id, item(2), { concurrencyScope: tail });
53
+ await stale.commit();
54
+ staleSucceeded = stale.isSuccess;
55
+ eventsAfterStale = (await store.eventLog.getFromSequenceNumber(EventSequenceNumber.first, id, [ItemAdded])).length;
56
+ });
57
+ afterAll(() => client?.dispose());
58
+
59
+ it('should accept a current concurrency scope', () => committed.should.be.true);
60
+ it('should append both events in input order', () => events.map(_ => (_.content as { order: number }).order).should.deep.equal([0, 1]));
61
+ it('should keep the stream type', () => events[0].context.eventStreamType!.should.equal('Orders'));
62
+ it('should keep the stream id', () => events[0].context.eventStreamId!.should.equal('order-1'));
63
+ it('should keep the source type', () => events[0].context.eventSourceType!.should.equal('Customer'));
64
+ it('should keep the subject', () => events[0].context.subject!.should.equal('subject-1'));
65
+ it('should keep the occurrence time', () => events[0].context.occurred.toISOString().should.equal(occurred.toISOString()));
66
+ it('should keep the tags', () => events[0].context.tags.map(String).should.contain('priority'));
67
+ it('should default the subject of the event without options to its event source id', () => events[1].context.subject!.should.equal(id));
68
+ it('should reject a stale concurrency scope', () => staleSucceeded.should.be.false);
69
+ it('should not write the event rejected by the stale scope', () => eventsAfterStale.should.equal(3));
70
+ });
@@ -0,0 +1,54 @@
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 { vi, type Mock } from 'vitest';
6
+ import type { AppendOptions } from '../../../eventSequences/AppendOptions.js';
7
+ import type { AppendResult } from '../../../eventSequences/AppendResult.js';
8
+ import type { EventForEventSourceId } from '../../../eventSequences/EventForEventSourceId.js';
9
+ import { EventSequenceNumber } from '../../../eventSequences/EventSequenceNumber.js';
10
+ import type { IEventStore } from '../../../IEventStore.js';
11
+ import { UnitOfWork } from '../../UnitOfWork.js';
12
+
13
+ /** One recorded appendMany call. */
14
+ export interface RecordedAppend {
15
+ readonly eventSequenceId: string;
16
+ readonly events: EventForEventSourceId[];
17
+ readonly options: AppendOptions;
18
+ }
19
+
20
+ /** Creates a successful append result with a given sequence number. */
21
+ export function successfulAppendResult(sequenceNumber: bigint): AppendResult {
22
+ return { sequenceNumber: new EventSequenceNumber(sequenceNumber), constraintViolations: [], errors: [], isSuccess: true, waitForCompletion: vi.fn() };
23
+ }
24
+
25
+ /** The unit of work and what its store recorded. */
26
+ export interface RecordingUnitOfWork {
27
+ readonly unitOfWork: UnitOfWork;
28
+ readonly appends: RecordedAppend[];
29
+ readonly record: Mock;
30
+ readonly completed: Mock;
31
+ }
32
+
33
+ /**
34
+ * Creates a unit of work over a store whose event sequences record every appendMany call.
35
+ * @param appendMany - Optional replacement for the recording appendMany; receives the sequence id first.
36
+ */
37
+ export function a_unit_of_work_with_a_recording_store(
38
+ appendMany?: (eventSequenceId: string, events: EventForEventSourceId[], options: AppendOptions) => Promise<AppendResult[]>
39
+ ): RecordingUnitOfWork {
40
+ const appends: RecordedAppend[] = [];
41
+ let next = 0n;
42
+ const record: Mock = vi.fn(async (eventSequenceId: string, events: EventForEventSourceId[], options: AppendOptions) => {
43
+ appends.push({ eventSequenceId, events, options });
44
+ return appendMany ? appendMany(eventSequenceId, events, options) : events.map(() => successfulAppendResult(next++));
45
+ });
46
+ const store = {
47
+ getEventSequence: (id: { value: string }) => ({
48
+ appendMany: (events: EventForEventSourceId[], options: AppendOptions) => record(id.value, events, options)
49
+ })
50
+ } as unknown as IEventStore;
51
+ const completed = vi.fn();
52
+ const unitOfWork = new UnitOfWork(Guid.create(), completed, store);
53
+ return { unitOfWork, appends, record, completed };
54
+ }
@@ -0,0 +1,32 @@
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 { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
6
+ import { EventType } from '../../../events/EventType.js';
7
+ import { a_unit_of_work_with_a_recording_store, type RecordedAppend } from '../given/a_unit_of_work_with_a_recording_store.js';
8
+
9
+ chai.should();
10
+
11
+ describe('when adding events with concurrency scopes and the event type filters are equivalent', () => {
12
+ let appends: RecordedAppend[];
13
+
14
+ beforeEach(async () => {
15
+ const recording = a_unit_of_work_with_a_recording_store();
16
+ const orderPlaced = EventType.parse('OrderPlaced+1');
17
+ const orderShipped = EventType.parse('OrderShipped+2');
18
+ const add = (eventSourceId: string, order: number, concurrencyScope: object) =>
19
+ recording.unitOfWork.addEvent(EventSequenceId.eventLog, eventSourceId, { order }, { concurrencyScope });
20
+
21
+ add('omitted', 0, { sequenceNumber: 1n });
22
+ add('omitted', 1, { sequenceNumber: 1n, eventTypes: [] });
23
+ add('duplicates', 2, { sequenceNumber: 1n, eventTypes: [orderPlaced] });
24
+ add('duplicates', 3, { sequenceNumber: 1n, eventTypes: [orderPlaced, EventType.parse('OrderPlaced+1')] });
25
+ add('order', 4, { sequenceNumber: 1n, eventTypes: [orderPlaced, orderShipped] });
26
+ add('order', 5, { sequenceNumber: 1n, eventTypes: [orderShipped, orderPlaced] });
27
+ await recording.unitOfWork.commit();
28
+ appends = recording.appends;
29
+ });
30
+
31
+ it('should append every event', () => appends[0].events.should.have.lengthOf(6));
32
+ });
@@ -0,0 +1,40 @@
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 { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
6
+ import { EventType } from '../../../events/EventType.js';
7
+ import { ConflictingConcurrencyScopesInUnitOfWork } from '../../ConflictingConcurrencyScopesInUnitOfWork.js';
8
+ import { a_unit_of_work_with_a_recording_store } from '../given/a_unit_of_work_with_a_recording_store.js';
9
+ import type { UnitOfWork } from '../../UnitOfWork.js';
10
+
11
+ chai.should();
12
+
13
+ describe('when adding events with concurrency scopes and the event type filters differ', () => {
14
+ let unitOfWork: UnitOfWork;
15
+ let errors: unknown[];
16
+
17
+ const tryAdd = (eventSourceId: string, eventTypes: EventType[]) => {
18
+ try {
19
+ unitOfWork.addEvent(EventSequenceId.eventLog, eventSourceId, { eventSourceId }, { concurrencyScope: { sequenceNumber: 1n, eventTypes } });
20
+ } catch (caught) {
21
+ errors.push(caught);
22
+ }
23
+ };
24
+
25
+ beforeEach(() => {
26
+ ({ unitOfWork } = a_unit_of_work_with_a_recording_store());
27
+ errors = [];
28
+ tryAdd('different-type', [EventType.parse('OrderPlaced+1')]);
29
+ tryAdd('different-type', [EventType.parse('OrderShipped+1')]);
30
+ tryAdd('different-generation', [EventType.parse('OrderPlaced+1')]);
31
+ tryAdd('different-generation', [EventType.parse('OrderPlaced+2')]);
32
+ tryAdd('empty-versus-filled', []);
33
+ tryAdd('empty-versus-filled', [EventType.parse('OrderPlaced+1')]);
34
+ });
35
+
36
+ it('should reject each conflicting event', () => {
37
+ errors.should.have.lengthOf(3);
38
+ errors.every(_ => _ instanceof ConflictingConcurrencyScopesInUnitOfWork).should.be.true;
39
+ });
40
+ });
@@ -0,0 +1,29 @@
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 { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
6
+ import { a_unit_of_work_with_a_recording_store, type RecordedAppend } from '../given/a_unit_of_work_with_a_recording_store.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when adding events with concurrency scopes and the scopes are equal or omitted', () => {
11
+ let appends: RecordedAppend[];
12
+
13
+ beforeEach(async () => {
14
+ const recording = a_unit_of_work_with_a_recording_store();
15
+ recording.unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 0 }, { concurrencyScope: { sequenceNumber: 3n, eventStreamType: 'Orders' } });
16
+ recording.unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 1 });
17
+ recording.unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 2 }, { concurrencyScope: { eventStreamType: 'Orders', sequenceNumber: 3n } });
18
+ recording.unitOfWork.addEvent(EventSequenceId.eventLog, 'b-1', { order: 3 }, { concurrencyScope: { sequenceNumber: 9n } });
19
+ await recording.unitOfWork.commit();
20
+ appends = recording.appends;
21
+ });
22
+
23
+ it('should append every event', () => appends[0].events.should.have.lengthOf(4));
24
+ it('should pass one scope per event source', () => appends[0].options.concurrencyScopes!.should.deep.equal({
25
+ 'a-1': { sequenceNumber: 3n, eventStreamType: 'Orders' },
26
+ 'b-1': { sequenceNumber: 9n }
27
+ }));
28
+ it('should keep the correlation of the unit of work', () => appends[0].options.should.have.property('correlationId'));
29
+ });
@@ -0,0 +1,29 @@
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 { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
6
+ import { ConflictingConcurrencyScopesInUnitOfWork } from '../../ConflictingConcurrencyScopesInUnitOfWork.js';
7
+ import { a_unit_of_work_with_a_recording_store } from '../given/a_unit_of_work_with_a_recording_store.js';
8
+ import type { UnitOfWork } from '../../UnitOfWork.js';
9
+
10
+ chai.should();
11
+
12
+ describe('when adding events with concurrency scopes and the scopes differ for one event source', () => {
13
+ let unitOfWork: UnitOfWork;
14
+ let error: unknown;
15
+
16
+ beforeEach(() => {
17
+ ({ unitOfWork } = a_unit_of_work_with_a_recording_store());
18
+ unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 0 }, { concurrencyScope: { sequenceNumber: 1n } });
19
+ try {
20
+ unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 1 }, { concurrencyScope: { sequenceNumber: 2n } });
21
+ } catch (caught) {
22
+ error = caught;
23
+ }
24
+ });
25
+
26
+ it('should reject the conflicting event', () => error!.should.be.instanceOf(ConflictingConcurrencyScopesInUnitOfWork));
27
+ it('should not name the event source in the message', () => (error as Error).message.should.not.contain('a-1'));
28
+ it('should keep only the first event', () => unitOfWork.getEvents().should.deep.equal([{ order: 0 }]));
29
+ });