@cratis/chronicle 6.50.0 → 6.51.1
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/ChronicleClient.ts +127 -99
- package/ChronicleOptions.ts +23 -0
- package/dist/ChronicleClient.d.ts +7 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +115 -95
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/ChronicleOptions.d.ts +14 -0
- package/dist/ChronicleOptions.d.ts.map +1 -1
- package/dist/ChronicleOptions.js +16 -0
- package/dist/ChronicleOptions.js.map +1 -1
- package/dist/eventSequences/EventSequence.d.ts +12 -0
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +202 -175
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/ITransactionalEventSequence.d.ts +2 -2
- package/dist/eventSequences/PreparedBatchAppend.d.ts +4 -0
- package/dist/eventSequences/PreparedBatchAppend.d.ts.map +1 -0
- package/dist/eventSequences/PreparedBatchAppend.js +4 -0
- package/dist/eventSequences/PreparedBatchAppend.js.map +1 -0
- package/dist/eventSequences/PreparedSingleAppend.d.ts +4 -0
- package/dist/eventSequences/PreparedSingleAppend.d.ts.map +1 -0
- package/dist/eventSequences/PreparedSingleAppend.js +4 -0
- package/dist/eventSequences/PreparedSingleAppend.js.map +1 -0
- package/dist/eventSequences/WireBatchEvent.d.ts +6 -0
- package/dist/eventSequences/WireBatchEvent.d.ts.map +1 -0
- package/dist/eventSequences/WireBatchEvent.js +4 -0
- package/dist/eventSequences/WireBatchEvent.js.map +1 -0
- package/dist/eventSequences/toContractsCausation.d.ts +16 -0
- package/dist/eventSequences/toContractsCausation.d.ts.map +1 -0
- package/dist/eventSequences/toContractsCausation.js +15 -0
- package/dist/eventSequences/toContractsCausation.js.map +1 -0
- 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/reducers/ReducerObservation.d.ts +18 -0
- package/dist/reducers/ReducerObservation.d.ts.map +1 -0
- package/dist/reducers/ReducerObservation.js +4 -0
- package/dist/reducers/ReducerObservation.js.map +1 -0
- package/dist/reducers/ReducerObservationSession.d.ts +11 -0
- package/dist/reducers/ReducerObservationSession.d.ts.map +1 -0
- package/dist/reducers/ReducerObservationSession.js +4 -0
- package/dist/reducers/ReducerObservationSession.js.map +1 -0
- package/dist/reducers/Reducers.d.ts +9 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +149 -154
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/reducers/ReductionOutcome.d.ts +25 -0
- package/dist/reducers/ReductionOutcome.d.ts.map +1 -0
- package/dist/reducers/ReductionOutcome.js +66 -0
- package/dist/reducers/ReductionOutcome.js.map +1 -0
- 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/EventSequence.ts +265 -186
- package/eventSequences/ITransactionalEventSequence.ts +2 -2
- package/eventSequences/PreparedBatchAppend.ts +7 -0
- package/eventSequences/PreparedSingleAppend.ts +7 -0
- package/eventSequences/WireBatchEvent.ts +7 -0
- package/eventSequences/for_TransactionalEventSequence/when_appending_many_with_options.spec.ts +29 -0
- package/eventSequences/toContractsCausation.ts +17 -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/for_ChronicleClient/messageSizes.spec.ts +37 -0
- package/for_ChronicleOptions/when_configuring_message_sizes/with_message_sizes.spec.ts +29 -0
- package/package.json +2 -2
- package/reducers/ReducerObservation.ts +25 -0
- package/reducers/ReducerObservationSession.ts +15 -0
- package/reducers/Reducers.ts +185 -171
- package/reducers/ReductionOutcome.ts +75 -0
- 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,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
|
+
});
|
|
@@ -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
|
+
}
|