@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.
- package/dist/eventSequences/ITransactionalEventSequence.d.ts +2 -2
- package/dist/events/constraints/ConstraintBuilder.d.ts +7 -0
- package/dist/events/constraints/ConstraintBuilder.d.ts.map +1 -1
- package/dist/events/constraints/ConstraintBuilder.js +13 -0
- package/dist/events/constraints/ConstraintBuilder.js.map +1 -1
- package/dist/events/constraints/ConstraintEventSequences.d.ts +19 -0
- package/dist/events/constraints/ConstraintEventSequences.d.ts.map +1 -0
- package/dist/events/constraints/ConstraintEventSequences.js +39 -0
- package/dist/events/constraints/ConstraintEventSequences.js.map +1 -0
- package/dist/events/constraints/Constraints.d.ts.map +1 -1
- package/dist/events/constraints/Constraints.js +24 -3
- package/dist/events/constraints/Constraints.js.map +1 -1
- package/dist/events/constraints/IConstraintBuilder.d.ts +18 -0
- package/dist/events/constraints/IConstraintBuilder.d.ts.map +1 -1
- package/dist/events/constraints/UniqueOptions.d.ts +15 -0
- package/dist/events/constraints/UniqueOptions.d.ts.map +1 -0
- package/dist/events/constraints/UniqueOptions.js +4 -0
- package/dist/events/constraints/UniqueOptions.js.map +1 -0
- package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.d.ts +9 -0
- package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.d.ts.map +1 -0
- package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.js +22 -0
- package/dist/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.js.map +1 -0
- package/dist/events/constraints/index.d.ts +2 -0
- package/dist/events/constraints/index.d.ts.map +1 -1
- package/dist/events/constraints/index.js.map +1 -1
- package/dist/events/constraints/unique.d.ts +10 -0
- package/dist/events/constraints/unique.d.ts.map +1 -1
- package/dist/events/constraints/unique.js +11 -10
- package/dist/events/constraints/unique.js.map +1 -1
- package/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +8 -3
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/InProcessConstraints.d.ts +4 -1
- package/dist/testing/InProcessConstraints.d.ts.map +1 -1
- package/dist/testing/InProcessConstraints.js +5 -1
- package/dist/testing/InProcessConstraints.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +2 -1
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.d.ts +14 -0
- package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.d.ts.map +1 -0
- package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.js +20 -0
- package/dist/transactions/ConflictingConcurrencyScopesInUnitOfWork.js.map +1 -0
- package/dist/transactions/IUnitOfWork.d.ts +2 -1
- package/dist/transactions/IUnitOfWork.d.ts.map +1 -1
- package/dist/transactions/TransactionalAppendOptions.d.ts +23 -2
- package/dist/transactions/TransactionalAppendOptions.d.ts.map +1 -1
- package/dist/transactions/UnitOfWork.d.ts +1 -0
- package/dist/transactions/UnitOfWork.d.ts.map +1 -1
- package/dist/transactions/UnitOfWork.js +28 -13
- package/dist/transactions/UnitOfWork.js.map +1 -1
- package/dist/transactions/concurrencyScopesAreEqual.d.ts +9 -0
- package/dist/transactions/concurrencyScopesAreEqual.d.ts.map +1 -0
- package/dist/transactions/concurrencyScopesAreEqual.js +19 -0
- package/dist/transactions/concurrencyScopesAreEqual.js.map +1 -0
- package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.d.ts +26 -0
- package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.d.ts.map +1 -0
- package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.js +31 -0
- package/dist/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.js.map +1 -0
- package/dist/transactions/index.d.ts +1 -0
- package/dist/transactions/index.d.ts.map +1 -1
- package/dist/transactions/index.js +1 -0
- package/dist/transactions/index.js.map +1 -1
- package/dist/transactions/toEventForEventSourceId.d.ts +13 -0
- package/dist/transactions/toEventForEventSourceId.d.ts.map +1 -0
- package/dist/transactions/toEventForEventSourceId.js +29 -0
- package/dist/transactions/toEventForEventSourceId.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/ITransactionalEventSequence.ts +2 -2
- package/eventSequences/for_TransactionalEventSequence/when_appending_many_with_options.spec.ts +29 -0
- package/events/constraints/ConstraintBuilder.ts +16 -0
- package/events/constraints/ConstraintEventSequences.ts +41 -0
- package/events/constraints/Constraints.ts +22 -3
- package/events/constraints/IConstraintBuilder.ts +20 -0
- package/events/constraints/UniqueOptions.ts +20 -0
- package/events/constraints/for_Constraints/against_a_kernel/when_appending_to_event_sequences_a_constraint_is_scoped_to.integration.spec.ts +118 -0
- package/events/constraints/for_Constraints/when_scoping_to_event_sequences/given/registered_constraints.ts +26 -0
- package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_fluent_builder.spec.ts +40 -0
- package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_legacy_decorators.legacy.spec.ts +47 -0
- package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_merged_declarations.spec.ts +36 -0
- package/events/constraints/for_Constraints/when_scoping_to_event_sequences/with_standard_decorators.standard.spec.ts +46 -0
- package/events/constraints/index.ts +2 -0
- package/events/constraints/unique.ts +22 -2
- package/package.json +2 -2
- package/testing/EventScenario.fixtures.spec.ts +1 -1
- package/testing/EventScenario.ts +10 -4
- package/testing/InProcessConstraints.ts +5 -1
- package/testing/InProcessEventSequence.ts +2 -1
- package/testing/for_EventScenario/when_appending_event_sequence_constraints/event_sequence_scenario_behaviors.fixture.ts +101 -0
- package/testing/for_EventScenario/when_appending_event_sequence_constraints/other_sequence_extended_key.legacy.spec.ts +28 -0
- package/testing/for_EventScenario/when_appending_event_sequence_constraints/other_sequence_extended_key.standard.spec.ts +27 -0
- package/testing/for_EventScenario/when_appending_event_sequence_constraints/other_sequence_extended_key_behaviors.fixture.ts +40 -0
- package/testing/for_EventScenario/when_appending_event_sequence_constraints/with_legacy_decorators.legacy.spec.ts +55 -0
- package/testing/for_EventScenario/when_appending_event_sequence_constraints/with_standard_decorators.standard.spec.ts +54 -0
- package/transactions/ConflictingConcurrencyScopesInUnitOfWork.ts +19 -0
- package/transactions/IUnitOfWork.ts +2 -1
- package/transactions/TransactionalAppendOptions.ts +30 -2
- package/transactions/UnitOfWork.ts +29 -13
- package/transactions/concurrencyScopesAreEqual.ts +23 -0
- package/transactions/for_UnitOfWork/against_a_kernel/when_committing_with_append_metadata.integration.spec.ts +70 -0
- package/transactions/for_UnitOfWork/given/a_unit_of_work_with_a_recording_store.ts +54 -0
- package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_event_type_filters_are_equivalent.spec.ts +32 -0
- package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_event_type_filters_differ.spec.ts +40 -0
- package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_scopes_are_equal_or_omitted.spec.ts +29 -0
- package/transactions/for_UnitOfWork/when_adding_events_with_concurrency_scopes/and_the_scopes_differ_for_one_event_source.spec.ts +29 -0
- package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_a_later_event_sequence_fails.spec.ts +30 -0
- package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_events_span_event_sequences.spec.ts +34 -0
- package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_options_differ_per_event.spec.ts +45 -0
- package/transactions/for_UnitOfWork/when_committing_events_with_append_metadata/and_the_append_is_rejected.spec.ts +26 -0
- package/transactions/for_UnitOfWork/when_rolling_back_events_with_append_metadata.spec.ts +24 -0
- package/transactions/index.ts +1 -0
- package/transactions/toEventForEventSourceId.ts +32 -0
- package/vitest.integration.config.ts +2 -0
- package/webhooks/for_Webhooks/against_a_kernel/given/ReceivedWebhookRequest.fixture.ts +21 -0
- package/webhooks/for_Webhooks/against_a_kernel/given/WebhookReceiver.fixture.ts +99 -0
- package/webhooks/for_Webhooks/against_a_kernel/when_delivering_events_to_a_failing_webhook.integration.spec.ts +101 -0
- package/webhooks/for_Webhooks/against_a_kernel/when_delivering_events_to_a_webhook.integration.spec.ts +130 -0
- package/webhooks/for_Webhooks/against_a_kernel/when_registering_a_webhook_that_is_not_replayable_and_not_active.integration.spec.ts +101 -0
- package/webhooks/for_Webhooks/when_registering_a_webhook_that_is_not_replayable_and_not_active.spec.ts +43 -0
|
@@ -0,0 +1,30 @@
|
|
|
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, successfulAppendResult, type RecordedAppend } from '../given/a_unit_of_work_with_a_recording_store.js';
|
|
7
|
+
import type { UnitOfWork } from '../../UnitOfWork.js';
|
|
8
|
+
|
|
9
|
+
chai.should();
|
|
10
|
+
|
|
11
|
+
describe('when committing events with append metadata and a later event sequence fails', () => {
|
|
12
|
+
let unitOfWork: UnitOfWork;
|
|
13
|
+
let appends: RecordedAppend[];
|
|
14
|
+
let error: unknown;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
const recording = a_unit_of_work_with_a_recording_store(async eventSequenceId => {
|
|
18
|
+
if (eventSequenceId === 'outbox') throw new Error('Kernel unavailable');
|
|
19
|
+
return [successfulAppendResult(0n)];
|
|
20
|
+
});
|
|
21
|
+
({ unitOfWork, appends } = recording);
|
|
22
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 0 }, { subject: 'first' });
|
|
23
|
+
unitOfWork.addEvent(new EventSequenceId('outbox'), 'a-1', { order: 1 }, { subject: 'second' });
|
|
24
|
+
error = await unitOfWork.commit().catch(caught => caught);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should reject the commit', () => (error as Error).message.should.equal('Kernel unavailable'));
|
|
28
|
+
it('should have written the earlier event sequence, since appends to separate sequences are not atomic', () => appends[0].events[0].subject!.should.equal('first'));
|
|
29
|
+
it('should not mark the unit of work completed', () => unitOfWork.isCompleted.should.be.false);
|
|
30
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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 type { AppendResult } from '../../../eventSequences/AppendResult.js';
|
|
7
|
+
import { a_unit_of_work_with_a_recording_store, successfulAppendResult, type RecordedAppend } from '../given/a_unit_of_work_with_a_recording_store.js';
|
|
8
|
+
|
|
9
|
+
chai.should();
|
|
10
|
+
|
|
11
|
+
describe('when committing events with append metadata and events span event sequences', () => {
|
|
12
|
+
const outbox = new EventSequenceId('outbox');
|
|
13
|
+
const scope = { sequenceNumber: 4n, eventSourceId: true };
|
|
14
|
+
let appends: RecordedAppend[];
|
|
15
|
+
let results: ReadonlyArray<AppendResult>;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
const recording = a_unit_of_work_with_a_recording_store(async (eventSequenceId, events) =>
|
|
19
|
+
events.map((_, index) => successfulAppendResult(BigInt(eventSequenceId === 'outbox' ? 100 + index : index))));
|
|
20
|
+
recording.unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 0 }, { subject: 'log-subject', concurrencyScope: scope });
|
|
21
|
+
recording.unitOfWork.addEvent(outbox, 'a-1', { order: 1 }, { subject: 'outbox-subject' });
|
|
22
|
+
recording.unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 2 });
|
|
23
|
+
await recording.unitOfWork.commit();
|
|
24
|
+
appends = recording.appends;
|
|
25
|
+
results = recording.unitOfWork.getAppendResults();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should append once per event sequence', () => appends.map(_ => _.eventSequenceId).should.deep.equal([EventSequenceId.eventLog.value, 'outbox']));
|
|
29
|
+
it('should keep the subject in the event log append', () => appends[0].events[0].subject!.should.equal('log-subject'));
|
|
30
|
+
it('should keep the subject in the outbox append', () => appends[1].events[0].subject!.should.equal('outbox-subject'));
|
|
31
|
+
it('should pass the scope to the event log append', () => appends[0].options.concurrencyScopes!.should.deep.equal({ 'a-1': scope }));
|
|
32
|
+
it('should not pass the scope to the outbox append', () => appends[1].options.should.not.have.property('concurrencyScopes'));
|
|
33
|
+
it('should report results in input order', () => results.map(_ => _.sequenceNumber.value).should.deep.equal([0n, 100n, 1n]));
|
|
34
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { Tag } from '../../../events/Tag.js';
|
|
6
|
+
import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
|
|
7
|
+
import type { EventForEventSourceId } from '../../../eventSequences/EventForEventSourceId.js';
|
|
8
|
+
import { a_unit_of_work_with_a_recording_store } from '../given/a_unit_of_work_with_a_recording_store.js';
|
|
9
|
+
|
|
10
|
+
const should = chai.should();
|
|
11
|
+
|
|
12
|
+
describe('when committing events with append metadata and options differ per event', () => {
|
|
13
|
+
const occurred = new Date('2026-01-02T03:04:05.000Z');
|
|
14
|
+
const first = { name: 'first' };
|
|
15
|
+
const second = { name: 'second' };
|
|
16
|
+
const third = { name: 'third' };
|
|
17
|
+
let batch: EventForEventSourceId[];
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
const { unitOfWork, appends } = a_unit_of_work_with_a_recording_store();
|
|
21
|
+
const tags = ['priority'];
|
|
22
|
+
const occurredInput = new Date(occurred.getTime());
|
|
23
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', first, {
|
|
24
|
+
eventStreamType: 'Orders', eventStreamId: 'order-1', eventSourceType: 'Customer',
|
|
25
|
+
subject: 'subject-1', occurred: occurredInput, tags
|
|
26
|
+
});
|
|
27
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'b-1', second, { tags: [new Tag('audit')] });
|
|
28
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', third);
|
|
29
|
+
tags.push('mutated-after-enrollment');
|
|
30
|
+
occurredInput.setUTCFullYear(1999);
|
|
31
|
+
await unitOfWork.commit();
|
|
32
|
+
batch = appends[0].events;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should append the events in input order', () => batch.map(_ => _.event).should.deep.equal([first, second, third]));
|
|
36
|
+
it('should keep the stream type of the first event', () => batch[0].eventStreamType!.should.equal('Orders'));
|
|
37
|
+
it('should keep the stream id of the first event', () => batch[0].eventStreamId!.should.equal('order-1'));
|
|
38
|
+
it('should keep the source type of the first event', () => batch[0].eventSourceType!.should.equal('Customer'));
|
|
39
|
+
it('should keep the subject of the first event', () => batch[0].subject!.should.equal('subject-1'));
|
|
40
|
+
it('should keep the occurrence time given at enrollment', () => batch[0].occurred!.toISOString().should.equal(occurred.toISOString()));
|
|
41
|
+
it('should keep the tags given at enrollment', () => batch[0].tags!.should.deep.equal(['priority']));
|
|
42
|
+
it('should keep the tags of the second event', () => batch[1].tags!.map(String).should.deep.equal(['audit']));
|
|
43
|
+
it('should not give the third event any metadata', () => Object.keys(batch[2]).sort().should.deep.equal(['event', 'eventSourceId']));
|
|
44
|
+
it('should not leak the subject of the first event to the second', () => should.not.exist(batch[1].subject));
|
|
45
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
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 { EventSequenceNumber } from '../../../eventSequences/EventSequenceNumber.js';
|
|
7
|
+
import { a_unit_of_work_with_a_recording_store, successfulAppendResult } 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 committing events with append metadata and the append is rejected', () => {
|
|
13
|
+
const violation = { eventSourceId: 'a-1', expectedSequenceNumber: new EventSequenceNumber(1n), actualSequenceNumber: new EventSequenceNumber(5n) };
|
|
14
|
+
let unitOfWork: UnitOfWork;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
({ unitOfWork } = a_unit_of_work_with_a_recording_store(async () => [
|
|
18
|
+
{ ...successfulAppendResult(0n), isSuccess: false, concurrencyViolation: violation }
|
|
19
|
+
]));
|
|
20
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 0 }, { concurrencyScope: { sequenceNumber: 1n } });
|
|
21
|
+
await unitOfWork.commit();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should not report success', () => unitOfWork.isSuccess.should.be.false);
|
|
25
|
+
it('should surface the concurrency violation', () => unitOfWork.getConcurrencyViolations().should.deep.equal([violation]));
|
|
26
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
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, type Mock } from 'vitest';
|
|
5
|
+
import { EventSequenceId } from '../../eventSequences/EventSequenceId.js';
|
|
6
|
+
import { a_unit_of_work_with_a_recording_store } from './given/a_unit_of_work_with_a_recording_store.js';
|
|
7
|
+
import type { UnitOfWork } from '../UnitOfWork.js';
|
|
8
|
+
|
|
9
|
+
chai.should();
|
|
10
|
+
|
|
11
|
+
describe('when rolling back events with append metadata', () => {
|
|
12
|
+
let unitOfWork: UnitOfWork;
|
|
13
|
+
let record: Mock;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
({ unitOfWork, record } = a_unit_of_work_with_a_recording_store());
|
|
17
|
+
unitOfWork.addEvent(EventSequenceId.eventLog, 'a-1', { order: 0 }, { subject: 'subject-1', concurrencyScope: { sequenceNumber: 1n } });
|
|
18
|
+
await unitOfWork.rollback();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should drop the buffered events', () => unitOfWork.getEvents().should.be.empty);
|
|
22
|
+
it('should not append anything', () => record.mock.calls.should.be.empty);
|
|
23
|
+
it('should reject a later commit', async () => (await unitOfWork.commit().catch(caught => caught)).should.be.instanceOf(Error));
|
|
24
|
+
});
|
package/transactions/index.ts
CHANGED
|
@@ -8,3 +8,4 @@ export { UnitOfWork } from './UnitOfWork.js';
|
|
|
8
8
|
export { UnitOfWorkManager } from './UnitOfWorkManager.js';
|
|
9
9
|
export type { TransactionalEventRouting } from './TransactionalEventRouting.js';
|
|
10
10
|
export type { TransactionalAppendOptions } from './TransactionalAppendOptions.js';
|
|
11
|
+
export { ConflictingConcurrencyScopesInUnitOfWork } from './ConflictingConcurrencyScopesInUnitOfWork.js';
|
|
@@ -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 type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
|
|
5
|
+
import type { TransactionalAppendOptions } from './TransactionalAppendOptions.js';
|
|
6
|
+
import { mergeNamedTags } from '../events/mergeNamedTags.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Captures an event and its transactional append metadata as the batch entry appended on commit.
|
|
10
|
+
* Only supplied values become keys, and tags and occurrence time are copied so later caller mutations do not leak in.
|
|
11
|
+
* The concurrency scope is not part of the entry; the unit of work passes it per event source identifier.
|
|
12
|
+
* @param eventSourceId - The event source identifier.
|
|
13
|
+
* @param event - The event payload.
|
|
14
|
+
* @param options - Optional transactional append metadata.
|
|
15
|
+
* @returns The batch entry.
|
|
16
|
+
*/
|
|
17
|
+
export function toEventForEventSourceId(eventSourceId: string, event: object, options?: TransactionalAppendOptions): EventForEventSourceId {
|
|
18
|
+
const namedTags = mergeNamedTags(options?.namedTags);
|
|
19
|
+
return {
|
|
20
|
+
eventSourceId,
|
|
21
|
+
event,
|
|
22
|
+
...(options?.eventSource !== undefined ? { eventSource: options.eventSource } : {}),
|
|
23
|
+
...(options?.eventStream !== undefined ? { eventStream: options.eventStream } : {}),
|
|
24
|
+
...(options?.eventStreamType !== undefined ? { eventStreamType: options.eventStreamType } : {}),
|
|
25
|
+
...(options?.eventStreamId !== undefined ? { eventStreamId: options.eventStreamId } : {}),
|
|
26
|
+
...(options?.eventSourceType !== undefined ? { eventSourceType: options.eventSourceType } : {}),
|
|
27
|
+
...(options?.occurred !== undefined ? { occurred: new Date(options.occurred.getTime()) } : {}),
|
|
28
|
+
...(options?.subject !== undefined ? { subject: options.subject } : {}),
|
|
29
|
+
...(options?.tags !== undefined ? { tags: [...options.tags] } : {}),
|
|
30
|
+
...(namedTags.length > 0 ? { namedTags } : {})
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -7,6 +7,8 @@ import baseConfig from './vitest.config.js';
|
|
|
7
7
|
// Kernel-backed specifications. They need a running Chronicle kernel and are skipped unless
|
|
8
8
|
// CHRONICLE_INTEGRATION_CONNECTION_STRING points at one, for example
|
|
9
9
|
// chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000
|
|
10
|
+
// The webhook delivery specifications also need the kernel to reach this process over HTTP, as
|
|
11
|
+
// host.docker.internal by default; set CHRONICLE_INTEGRATION_WEBHOOK_HOST when it reaches it differently.
|
|
10
12
|
export default defineConfig({
|
|
11
13
|
...baseConfig,
|
|
12
14
|
test: {
|
|
@@ -0,0 +1,21 @@
|
|
|
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 { IncomingHttpHeaders } from 'node:http';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A request the kernel delivered to a {@link WebhookReceiver}.
|
|
8
|
+
*/
|
|
9
|
+
export interface ReceivedWebhookRequest {
|
|
10
|
+
/** The HTTP method. */
|
|
11
|
+
readonly method: string;
|
|
12
|
+
|
|
13
|
+
/** The request path, including any query string. */
|
|
14
|
+
readonly path: string;
|
|
15
|
+
|
|
16
|
+
/** The request headers, with lowercase names. */
|
|
17
|
+
readonly headers: IncomingHttpHeaders;
|
|
18
|
+
|
|
19
|
+
/** The raw request body. */
|
|
20
|
+
readonly body: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { createServer, type Server } from 'node:http';
|
|
5
|
+
import type { AddressInfo } from 'node:net';
|
|
6
|
+
import type { ReceivedWebhookRequest } from './ReceivedWebhookRequest.fixture.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The host name the kernel container uses to reach this process. Docker Desktop resolves
|
|
10
|
+
* host.docker.internal natively; on Linux (CI) the kernel container maps it to the host gateway
|
|
11
|
+
* (--add-host=host.docker.internal:host-gateway, see .github/workflows/build.yml).
|
|
12
|
+
* Override with CHRONICLE_INTEGRATION_WEBHOOK_HOST when the kernel reaches this process differently.
|
|
13
|
+
*/
|
|
14
|
+
const receiverHost = process.env.CHRONICLE_INTEGRATION_WEBHOOK_HOST ?? 'host.docker.internal';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A local HTTP endpoint the kernel delivers webhook requests to. It records every request and
|
|
18
|
+
* answers each with a fixed status code.
|
|
19
|
+
*/
|
|
20
|
+
export class WebhookReceiver {
|
|
21
|
+
private readonly _requests: ReceivedWebhookRequest[] = [];
|
|
22
|
+
private _server: Server | undefined;
|
|
23
|
+
private _port = 0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initializes a new instance of the {@link WebhookReceiver} class.
|
|
27
|
+
* @param _statusCode - The status code to answer every request with.
|
|
28
|
+
*/
|
|
29
|
+
constructor(private readonly _statusCode: number) {}
|
|
30
|
+
|
|
31
|
+
/** Gets the requests received so far, in arrival order. */
|
|
32
|
+
get requests(): ReadonlyArray<ReceivedWebhookRequest> {
|
|
33
|
+
return this._requests;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Starts listening on all interfaces on a free port, so a kernel container can reach it.
|
|
38
|
+
*/
|
|
39
|
+
async start(): Promise<void> {
|
|
40
|
+
const server = createServer((request, response) => {
|
|
41
|
+
const chunks: Buffer[] = [];
|
|
42
|
+
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
43
|
+
request.on('end', () => {
|
|
44
|
+
this._requests.push({
|
|
45
|
+
method: request.method ?? '',
|
|
46
|
+
path: request.url ?? '',
|
|
47
|
+
headers: request.headers,
|
|
48
|
+
body: Buffer.concat(chunks).toString('utf8')
|
|
49
|
+
});
|
|
50
|
+
response.statusCode = this._statusCode;
|
|
51
|
+
response.end();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
await new Promise<void>((resolve, reject) => {
|
|
55
|
+
server.once('error', reject);
|
|
56
|
+
server.listen(0, '0.0.0.0', () => resolve());
|
|
57
|
+
});
|
|
58
|
+
this._server = server;
|
|
59
|
+
this._port = (server.address() as AddressInfo).port;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the URL the kernel should deliver to for the given path.
|
|
64
|
+
* @param path - The path, starting with a slash.
|
|
65
|
+
* @returns The URL as seen from the kernel.
|
|
66
|
+
*/
|
|
67
|
+
urlFor(path: string): string {
|
|
68
|
+
return `http://${receiverHost}:${this._port}${path}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Waits until at least the given number of requests have arrived.
|
|
73
|
+
* @param count - The number of requests to wait for.
|
|
74
|
+
* @param timeoutMs - How long to wait before failing.
|
|
75
|
+
* @returns The requests received.
|
|
76
|
+
*/
|
|
77
|
+
async waitForRequests(count: number, timeoutMs = 30_000): Promise<ReadonlyArray<ReceivedWebhookRequest>> {
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
while (this._requests.length < count) {
|
|
80
|
+
if (Date.now() > deadline) {
|
|
81
|
+
throw new Error(`Timed out waiting for ${count} webhook request(s); received ${this._requests.length}. ` +
|
|
82
|
+
`Is ${receiverHost} reachable from the kernel?`);
|
|
83
|
+
}
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
85
|
+
}
|
|
86
|
+
return this._requests;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stops listening.
|
|
91
|
+
*/
|
|
92
|
+
async stop(): Promise<void> {
|
|
93
|
+
const server = this._server;
|
|
94
|
+
if (!server) return;
|
|
95
|
+
this._server = undefined;
|
|
96
|
+
server.closeAllConnections();
|
|
97
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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 } from '../../../ChronicleClient.js';
|
|
8
|
+
import { ChronicleOptions } from '../../../ChronicleOptions.js';
|
|
9
|
+
import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
|
|
10
|
+
import { eventType } from '../../../events/index.js';
|
|
11
|
+
import type { IEventStore } from '../../../IEventStore.js';
|
|
12
|
+
import type { FailedPartition, ObserverInformation } from '../../../observation/index.js';
|
|
13
|
+
import { WebhookReceiver } from './given/WebhookReceiver.fixture.js';
|
|
14
|
+
|
|
15
|
+
chai.should();
|
|
16
|
+
|
|
17
|
+
// Runs against a real Chronicle kernel; skipped locally unless the connection string is set (see vitest.integration.config.ts).
|
|
18
|
+
// In CI it never skips, so a missing connection string or unreachable kernel fails instead of passing vacuously.
|
|
19
|
+
// The kernel must reach this process over HTTP; see WebhookReceiver for the address it uses.
|
|
20
|
+
const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
|
|
21
|
+
|
|
22
|
+
@eventType()
|
|
23
|
+
class ParcelLost {
|
|
24
|
+
@field(String) parcelId = '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const artifacts: IClientArtifactsProvider = {
|
|
28
|
+
eventTypes: [ParcelLost],
|
|
29
|
+
readModels: [],
|
|
30
|
+
reactors: [],
|
|
31
|
+
reducers: [],
|
|
32
|
+
seeders: [],
|
|
33
|
+
constraints: [],
|
|
34
|
+
projections: [],
|
|
35
|
+
webhooks: [],
|
|
36
|
+
eventTypeMigrations: [],
|
|
37
|
+
globalForHandlers: []
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function eventually<T>(read: () => Promise<T>, accept: (value: T) => boolean, timeoutMs = 30_000): Promise<T> {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
let value = await read();
|
|
43
|
+
while (!accept(value)) {
|
|
44
|
+
if (Date.now() > deadline) throw new Error('Timed out waiting for the kernel');
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
46
|
+
value = await read();
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe.skipIf(!connectionString && !process.env.CI)('when delivering events to a failing webhook against a kernel', () => {
|
|
52
|
+
const webhookId = randomUUID();
|
|
53
|
+
const eventSourceId = randomUUID();
|
|
54
|
+
const receiver = new WebhookReceiver(500);
|
|
55
|
+
let client: ChronicleClient;
|
|
56
|
+
let store: IEventStore;
|
|
57
|
+
let appendedSequenceNumber: bigint;
|
|
58
|
+
let failedPartition: FailedPartition;
|
|
59
|
+
let observer: ObserverInformation | undefined;
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
await receiver.start();
|
|
63
|
+
client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
|
|
64
|
+
discoveryPatterns: [],
|
|
65
|
+
clientArtifactsProvider: artifacts
|
|
66
|
+
}));
|
|
67
|
+
store = await client.getEventStore(`Webhooks${randomUUID().replaceAll('-', '').slice(0, 12)}`);
|
|
68
|
+
|
|
69
|
+
await store.webhooks.register(webhookId, receiver.urlFor('/parcels'), _ => _.withEventType(ParcelLost));
|
|
70
|
+
|
|
71
|
+
const parcel = new ParcelLost();
|
|
72
|
+
parcel.parcelId = randomUUID();
|
|
73
|
+
appendedSequenceNumber = (await store.eventLog.append(eventSourceId, parcel)).sequenceNumber.value;
|
|
74
|
+
|
|
75
|
+
await receiver.waitForRequests(1);
|
|
76
|
+
const failed = await eventually(
|
|
77
|
+
() => store.failedPartitions.getFailedPartitionsFor(webhookId),
|
|
78
|
+
_ => _.some(partition => partition.partition === eventSourceId));
|
|
79
|
+
failedPartition = failed.find(_ => _.partition === eventSourceId)!;
|
|
80
|
+
observer = (await store.observers.getAll()).find(_ => _.id === webhookId);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
try {
|
|
85
|
+
await store?.webhooks.remove(webhookId);
|
|
86
|
+
} finally {
|
|
87
|
+
try {
|
|
88
|
+
await receiver.stop();
|
|
89
|
+
} finally {
|
|
90
|
+
client?.dispose();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should have attempted the delivery', () => receiver.requests.length.should.be.greaterThan(0));
|
|
96
|
+
it('should record the attempt for the appended event', () => failedPartition.attempts[0].sequenceNumber.value.should.equal(appendedSequenceNumber));
|
|
97
|
+
// The kernel retries the failing status with backoff inside the delivery, so the recorded reason is usually the
|
|
98
|
+
// subscriber timeout rather than the status code; either way it must carry a reason.
|
|
99
|
+
it('should record why the attempt failed', () => failedPartition.attempts[0].messages.should.not.be.empty);
|
|
100
|
+
it('should not record the event as handled', () => observer!.lastHandledEventSequenceNumber.value.should.not.equal(appendedSequenceNumber));
|
|
101
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
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 } from '../../../ChronicleClient.js';
|
|
8
|
+
import { ChronicleOptions } from '../../../ChronicleOptions.js';
|
|
9
|
+
import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
|
|
10
|
+
import { eventType } from '../../../events/index.js';
|
|
11
|
+
import type { IEventStore } from '../../../IEventStore.js';
|
|
12
|
+
import type { FailedPartition } from '../../../observation/index.js';
|
|
13
|
+
import type { ReceivedWebhookRequest } from './given/ReceivedWebhookRequest.fixture.js';
|
|
14
|
+
import { WebhookReceiver } from './given/WebhookReceiver.fixture.js';
|
|
15
|
+
|
|
16
|
+
chai.should();
|
|
17
|
+
|
|
18
|
+
// Runs against a real Chronicle kernel; skipped locally unless the connection string is set (see vitest.integration.config.ts).
|
|
19
|
+
// In CI it never skips, so a missing connection string or unreachable kernel fails instead of passing vacuously.
|
|
20
|
+
// The kernel must reach this process over HTTP; see WebhookReceiver for the address it uses.
|
|
21
|
+
const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
|
|
22
|
+
|
|
23
|
+
@eventType()
|
|
24
|
+
class ParcelDelivered {
|
|
25
|
+
@field(String) parcelId = '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const artifacts: IClientArtifactsProvider = {
|
|
29
|
+
eventTypes: [ParcelDelivered],
|
|
30
|
+
readModels: [],
|
|
31
|
+
reactors: [],
|
|
32
|
+
reducers: [],
|
|
33
|
+
seeders: [],
|
|
34
|
+
constraints: [],
|
|
35
|
+
projections: [],
|
|
36
|
+
webhooks: [],
|
|
37
|
+
eventTypeMigrations: [],
|
|
38
|
+
globalForHandlers: []
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface DeliveredEvent {
|
|
42
|
+
readonly context: {
|
|
43
|
+
readonly eventType: { readonly id: string; readonly generation: number };
|
|
44
|
+
readonly eventSourceId: string;
|
|
45
|
+
readonly sequenceNumber: number;
|
|
46
|
+
};
|
|
47
|
+
readonly content: { readonly parcelId: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface DeliveredBody {
|
|
51
|
+
readonly partition: string;
|
|
52
|
+
readonly events: ReadonlyArray<DeliveredEvent>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function eventually<T>(read: () => Promise<T>, accept: (value: T) => boolean, timeoutMs = 30_000): Promise<T> {
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
let value = await read();
|
|
58
|
+
while (!accept(value)) {
|
|
59
|
+
if (Date.now() > deadline) throw new Error('Timed out waiting for the kernel');
|
|
60
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
61
|
+
value = await read();
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe.skipIf(!connectionString && !process.env.CI)('when delivering events to a webhook against a kernel', () => {
|
|
67
|
+
const webhookId = randomUUID();
|
|
68
|
+
const eventSourceId = randomUUID();
|
|
69
|
+
const parcelId = randomUUID();
|
|
70
|
+
const receiver = new WebhookReceiver(200);
|
|
71
|
+
let client: ChronicleClient;
|
|
72
|
+
let store: IEventStore;
|
|
73
|
+
let appendedSequenceNumber: bigint;
|
|
74
|
+
let request: ReceivedWebhookRequest;
|
|
75
|
+
let body: DeliveredBody;
|
|
76
|
+
let failedPartitions: FailedPartition[];
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
await receiver.start();
|
|
80
|
+
client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
|
|
81
|
+
discoveryPatterns: [],
|
|
82
|
+
clientArtifactsProvider: artifacts
|
|
83
|
+
}));
|
|
84
|
+
store = await client.getEventStore(`Webhooks${randomUUID().replaceAll('-', '').slice(0, 12)}`);
|
|
85
|
+
|
|
86
|
+
await store.webhooks.register(webhookId, receiver.urlFor('/parcels'), _ => _
|
|
87
|
+
.withEventType(ParcelDelivered)
|
|
88
|
+
.withHeader('X-Source', 'specs'));
|
|
89
|
+
|
|
90
|
+
const parcel = new ParcelDelivered();
|
|
91
|
+
parcel.parcelId = parcelId;
|
|
92
|
+
const result = await store.eventLog.append(eventSourceId, parcel);
|
|
93
|
+
appendedSequenceNumber = result.sequenceNumber.value;
|
|
94
|
+
|
|
95
|
+
request = (await receiver.waitForRequests(1))[0];
|
|
96
|
+
body = JSON.parse(request.body) as DeliveredBody;
|
|
97
|
+
|
|
98
|
+
// Once the kernel records the event as handled it has acted on the receiver's answer.
|
|
99
|
+
await eventually(
|
|
100
|
+
() => store.observers.getAll(),
|
|
101
|
+
_ => _.some(observer => observer.id === webhookId && observer.lastHandledEventSequenceNumber.value === appendedSequenceNumber));
|
|
102
|
+
failedPartitions = await store.failedPartitions.getFailedPartitionsFor(webhookId);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(async () => {
|
|
106
|
+
try {
|
|
107
|
+
await store?.webhooks.remove(webhookId);
|
|
108
|
+
} finally {
|
|
109
|
+
try {
|
|
110
|
+
await receiver.stop();
|
|
111
|
+
} finally {
|
|
112
|
+
client?.dispose();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should post to the target path', () => `${request.method} ${request.path}`.should.equal('POST /parcels'));
|
|
118
|
+
it('should send json', () => request.headers['content-type']!.should.match(/^application\/json/));
|
|
119
|
+
// Kernels up to at least 19.30.0 drop basic, bearer and OAuth authorization for webhooks outside the System event store
|
|
120
|
+
// while reporting the registration as successful, so no Authorization header is ever sent (Cratis/Chronicle#4567).
|
|
121
|
+
it.todo('should authorize with the configured credentials once Cratis/Chronicle#4567 is fixed');
|
|
122
|
+
it('should send the configured header', () => request.headers['x-source']!.should.equal('specs'));
|
|
123
|
+
it('should deliver the event source as the partition', () => body.partition.should.equal(eventSourceId));
|
|
124
|
+
it('should deliver the appended event only', () => body.events.length.should.equal(1));
|
|
125
|
+
it('should deliver the event content', () => body.events[0].content.parcelId.should.equal(parcelId));
|
|
126
|
+
it('should deliver the event type', () => body.events[0].context.eventType.id.should.equal('ParcelDelivered'));
|
|
127
|
+
it('should deliver the event source id', () => body.events[0].context.eventSourceId.should.equal(eventSourceId));
|
|
128
|
+
it('should deliver the sequence number', () => BigInt(body.events[0].context.sequenceNumber).should.equal(appendedSequenceNumber));
|
|
129
|
+
it('should not report the webhook as failed', () => failedPartitions.should.be.empty);
|
|
130
|
+
});
|