@cratis/chronicle 6.41.0 → 6.41.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.
Files changed (52) hide show
  1. package/compliance/for_PIIManager/against_a_kernel/when_erasing_a_subject.integration.spec.ts +4 -4
  2. package/compliance/resolveEventSubject.ts +19 -0
  3. package/compliance/subject.ts +2 -2
  4. package/dist/compliance/resolveEventSubject.d.ts +3 -0
  5. package/dist/compliance/resolveEventSubject.d.ts.map +1 -0
  6. package/dist/compliance/resolveEventSubject.js +21 -0
  7. package/dist/compliance/resolveEventSubject.js.map +1 -0
  8. package/dist/compliance/subject.d.ts +2 -2
  9. package/dist/compliance/subject.js +2 -2
  10. package/dist/eventSequences/AppendOptions.d.ts +1 -1
  11. package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
  12. package/dist/eventSequences/EventForEventSourceId.d.ts +1 -1
  13. package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
  14. package/dist/eventSequences/EventSequence.js +2 -2
  15. package/dist/eventSequences/EventSequence.js.map +1 -1
  16. package/dist/eventSequences/prepareBatchAppend.d.ts.map +1 -1
  17. package/dist/eventSequences/prepareBatchAppend.js +2 -1
  18. package/dist/eventSequences/prepareBatchAppend.js.map +1 -1
  19. package/dist/eventSequences/prepareSingleAppend.d.ts +1 -0
  20. package/dist/eventSequences/prepareSingleAppend.d.ts.map +1 -1
  21. package/dist/eventSequences/prepareSingleAppend.js +3 -1
  22. package/dist/eventSequences/prepareSingleAppend.js.map +1 -1
  23. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  24. package/dist/testing/InProcessEventSequence.js +16 -6
  25. package/dist/testing/InProcessEventSequence.js.map +1 -1
  26. package/dist/testing/ReadModelScenario.d.ts.map +1 -1
  27. package/dist/testing/ReadModelScenario.js +4 -0
  28. package/dist/testing/ReadModelScenario.js.map +1 -1
  29. package/dist/testing/singleAppendRoute.d.ts +1 -1
  30. package/dist/testing/singleAppendRoute.d.ts.map +1 -1
  31. package/dist/testing/singleAppendRoute.js +8 -4
  32. package/dist/testing/singleAppendRoute.js.map +1 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/eventSequences/AppendOptions.ts +1 -1
  35. package/eventSequences/EventForEventSourceId.ts +1 -1
  36. package/eventSequences/EventSequence.ts +2 -2
  37. package/eventSequences/for_EventSequence/when_appending/subject_behaviors.fixture.ts +239 -0
  38. package/eventSequences/for_EventSequence/when_appending/with_subject_annotations.spec.ts +34 -0
  39. package/eventSequences/for_EventSequence/when_appending/with_subject_annotations.standard.spec.ts +38 -0
  40. package/eventSequences/prepareBatchAppend.ts +2 -1
  41. package/eventSequences/prepareSingleAppend.ts +3 -1
  42. package/package.json +2 -2
  43. package/testing/EventScenario.batches.spec.ts +2 -2
  44. package/testing/EventScenario.fixtures.spec.ts +2 -2
  45. package/testing/EventScenario.rejections.spec.ts +2 -2
  46. package/testing/InProcessEventSequence.ts +17 -6
  47. package/testing/ReadModelScenario.spec.ts +31 -0
  48. package/testing/ReadModelScenario.ts +4 -0
  49. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_operations.spec.ts +1 -1
  50. package/testing/for_ReadModelScenario/when_seeding_with_options/with_route_validation.spec.ts +11 -1
  51. package/testing/singleAppendRoute.spec.ts +19 -0
  52. package/testing/singleAppendRoute.ts +9 -4
@@ -0,0 +1,239 @@
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 { resolveEventSubject } from '../../../compliance/resolveEventSubject.js';
5
+ import { Guid, type Constructor } from '@cratis/fundamentals';
6
+ import { beforeEach, chai, describe, it, vi, type Assertion } from 'vitest';
7
+ import type { ChronicleConnection } from '../../../connection/index.js';
8
+ import { EventSequence } from '../../EventSequence.js';
9
+ import { EventSequenceId } from '../../EventSequenceId.js';
10
+ import { EventScenario } from '../../../testing/EventScenario.js';
11
+ import { UnitOfWork } from '../../../transactions/UnitOfWork.js';
12
+ import type { IUnitOfWorkManager } from '../../../transactions/IUnitOfWorkManager.js';
13
+ import type { IEventStore } from '../../../IEventStore.js';
14
+
15
+ const should = chai.should();
16
+ function check(value: unknown): Assertion { return (value as { should: Assertion }).should; }
17
+
18
+ export function subjectBehaviors(fixtures: {
19
+ types: Constructor[];
20
+ annotated: (value: string) => object;
21
+ inherited: (value: string) => object;
22
+ concept: (value: string) => object;
23
+ unannotated: () => object;
24
+ }): void {
25
+ function connected() {
26
+ const append = vi.fn().mockResolvedValue({ Response: { SequenceNumber: 0n } });
27
+ const appendManyForEventSources = vi.fn().mockImplementation(async (request: { Events: object[] }) => ({
28
+ Response: { SequenceNumbers: request.Events.map((_, index) => BigInt(index)) }
29
+ }));
30
+ const manager = {} as IUnitOfWorkManager;
31
+ const sequence = new EventSequence(EventSequenceId.eventLog, 'store', 'Default',
32
+ { eventSequences: { append, appendManyForEventSources } } as unknown as ChronicleConnection, manager);
33
+ const unitOfWork = new UnitOfWork(Guid.create(), () => {},
34
+ { getEventSequence: () => sequence } as unknown as IEventStore);
35
+ Object.assign(manager, { current: unitOfWork });
36
+ return { sequence, unitOfWork, append, appendManyForEventSources };
37
+ }
38
+ function scenario() {
39
+ return new EventScenario({ artifacts: { eventTypes: fixtures.types, constraints: [] }, constraints: 'disabled' });
40
+ }
41
+
42
+ const cases = [
43
+ { name: 'an annotated subject', event: () => fixtures.annotated('person'), expected: 'person', stored: 'person' },
44
+ { name: 'an inherited annotated subject', event: () => fixtures.inherited('person'), expected: 'person', stored: 'person' },
45
+ { name: 'a concept subject', event: () => fixtures.concept('person'), expected: 'person', stored: 'person' },
46
+ { name: 'an empty annotated string', event: () => fixtures.annotated(''), expected: '', stored: 'source' },
47
+ { name: 'a whitespace annotated string', event: () => fixtures.annotated(' '), expected: ' ', stored: 'source' },
48
+ { name: 'an empty concept subject', event: () => fixtures.concept(''), expected: 'source', stored: 'source' },
49
+ { name: 'no annotation despite an id property', event: fixtures.unannotated, expected: 'source', stored: 'source' }
50
+ ];
51
+ for (const testCase of cases) {
52
+ describe(`when appending with ${testCase.name}`, () => {
53
+ let wireSubjects: string[];
54
+ let scenarioSubjects: (string | undefined)[];
55
+ beforeEach(async () => {
56
+ const client = connected();
57
+ const memory = scenario();
58
+ await client.sequence.append('source', testCase.event());
59
+ await memory.append('source', testCase.event());
60
+ await client.sequence.appendMany('source', [testCase.event()]);
61
+ await memory.appendMany('source', [testCase.event()]);
62
+ await client.sequence.appendMany([{ eventSourceId: 'source', event: testCase.event() }]);
63
+ await memory.appendMany([{ eventSourceId: 'source', event: testCase.event() }]);
64
+ wireSubjects = [client.append.mock.calls[0][0].Subject,
65
+ ...client.appendManyForEventSources.mock.calls.map(call => call[0].Events[0].Subject)];
66
+ scenarioSubjects = memory.appendedEvents.map(event => event.context.subject);
67
+ });
68
+ it('should select the wire subject using .NET value semantics in every append overload', () => {
69
+ check(wireSubjects).deep.equal([testCase.expected, testCase.expected, testCase.expected]);
70
+ });
71
+ it('should record the kernel subject in every scenario append overload', () => {
72
+ check(scenarioSubjects).deep.equal([testCase.stored, testCase.stored, testCase.stored]);
73
+ });
74
+ });
75
+ }
76
+ for (const testCase of [
77
+ { value: undefined, expected: 'source' },
78
+ { value: 0, expected: '0' },
79
+ { value: false, expected: 'false' },
80
+ { value: Guid.as('00000000-0000-0000-0000-000000000001'), expected: '00000000-0000-0000-0000-000000000001' },
81
+ { value: { toString: () => '' }, expected: 'source' },
82
+ { value: { toString: () => null }, expected: 'source' }
83
+ ]) {
84
+ describe(`when an annotated property contains ${String(testCase.value)}`, () => {
85
+ let subjects: string[];
86
+ beforeEach(async () => {
87
+ const client = connected();
88
+ const event = fixtures.annotated('person');
89
+ Reflect.set(event, 'personId', testCase.value);
90
+ await client.sequence.append('source', event);
91
+ await client.sequence.appendMany('source', [event]);
92
+ await client.sequence.appendMany([{ eventSourceId: 'source', event }]);
93
+ subjects = [client.append.mock.calls[0][0].Subject,
94
+ ...client.appendManyForEventSources.mock.calls.map(call => call[0].Events[0].Subject)];
95
+ });
96
+ it('should preserve .NET conversion and null fallback in every append overload', () => {
97
+ check(subjects).deep.equal([testCase.expected, testCase.expected, testCase.expected]);
98
+ });
99
+ });
100
+ }
101
+ describe('when resolving a null annotated property before serialization', () => {
102
+ let selected: string | undefined;
103
+ beforeEach(() => {
104
+ const event = fixtures.annotated('person');
105
+ Reflect.set(event, 'personId', null);
106
+ selected = resolveEventSubject(event);
107
+ });
108
+ it('should leave the subject unresolved for source fallback', () => should.equal(selected, undefined));
109
+ });
110
+ for (const { subject, stored } of [
111
+ { subject: 'explicit', stored: 'explicit' },
112
+ { subject: '', stored: 'source' },
113
+ { subject: ' ', stored: 'source' },
114
+ { subject: '\t\r\n', stored: 'source' },
115
+ { subject: '\u0085\u00a0', stored: 'source' },
116
+ { subject: '\ufeff', stored: '\ufeff' },
117
+ { subject: ' person ', stored: ' person ' }
118
+ ]) {
119
+ describe(`when overriding the annotation with ${JSON.stringify(subject)}`, () => {
120
+ let wireSubject: string;
121
+ let scenarioSubject: string | undefined;
122
+ beforeEach(async () => {
123
+ const client = connected();
124
+ const memory = scenario();
125
+ await client.sequence.append('source', fixtures.annotated('person'), { subject });
126
+ await memory.append('source', fixtures.annotated('person'), { subject });
127
+ wireSubject = client.append.mock.calls[0][0].Subject;
128
+ scenarioSubject = memory.appendedEvents[0].context.subject;
129
+ });
130
+ it('should prefer the explicit subject', () => should.equal(wireSubject, subject));
131
+ it('should record the kernel subject in the scenario', () => should.equal(scenarioSubject, stored));
132
+ });
133
+ }
134
+ for (const overload of ['single source', 'multiple sources'] as const) {
135
+ for (const subject of [undefined, 'shared', '', ' ']) {
136
+ describe(`when appending a ${overload} batch with shared subject ${JSON.stringify(subject)}`, () => {
137
+ let wireSubjects: string[];
138
+ let scenarioSubjects: (string | undefined)[];
139
+ beforeEach(async () => {
140
+ const client = connected();
141
+ const memory = scenario();
142
+ const events = [fixtures.annotated('person'), fixtures.concept('other'), fixtures.unannotated()];
143
+ if (overload === 'single source') {
144
+ await client.sequence.appendMany('source', events, { subject });
145
+ await memory.appendMany('source', events, { subject });
146
+ } else {
147
+ const entries = events.map((event, index) => ({ eventSourceId: 'source', event, subject: index === 0 ? 'entry' : undefined }));
148
+ await client.sequence.appendMany(entries, { subject });
149
+ await memory.appendMany(entries, { subject });
150
+ }
151
+ wireSubjects = client.appendManyForEventSources.mock.calls[0][0].Events.map((entry: { Subject: string }) => entry.Subject);
152
+ scenarioSubjects = memory.appendedEvents.map(event => event.context.subject);
153
+ });
154
+ it('should prefer entry then shared then annotation then source', () => {
155
+ check(wireSubjects).deep.equal([overload === 'multiple sources' ? 'entry' : subject ?? 'person', subject ?? 'other', subject ?? 'source']);
156
+ });
157
+ it('should record the kernel subjects in the scenario', () => {
158
+ const stored = subject === '' || subject === ' ' ? 'source' : subject;
159
+ check(scenarioSubjects).deep.equal([overload === 'multiple sources' ? 'entry' : stored ?? 'person',
160
+ stored ?? 'other', stored ?? 'source']);
161
+ });
162
+ });
163
+ }
164
+ }
165
+ for (const subject of ['', ' ', '\t\r\n', '\u0085\u00a0']) {
166
+ describe(`when an entry has an explicit blank subject ${JSON.stringify(subject)}`, () => {
167
+ let wireSubjects: string[];
168
+ let scenarioSubjects: (string | undefined)[];
169
+ beforeEach(async () => {
170
+ const client = connected();
171
+ const memory = scenario();
172
+ const entries = [{ eventSourceId: 'source', event: fixtures.annotated('person'), subject }];
173
+ await client.sequence.appendMany(entries, { subject: 'shared' });
174
+ await memory.appendMany(entries, { subject: 'shared' });
175
+ wireSubjects = client.appendManyForEventSources.mock.calls[0][0].Events.map((entry: { Subject: string }) => entry.Subject);
176
+ scenarioSubjects = memory.appendedEvents.map(event => event.context.subject);
177
+ });
178
+ it('should not replace the blank entry subject on the wire', () => check(wireSubjects).deep.equal([subject]));
179
+ it('should record the event source subject in the scenario', () => check(scenarioSubjects).deep.equal(['source']));
180
+ });
181
+ }
182
+ for (const testCase of cases) {
183
+ describe(`when using routed scenario builders with ${testCase.name}`, () => {
184
+ let memory: EventScenario;
185
+ const route = { sourceType: 'people', streamType: 'personal', streamId: 'details' };
186
+ beforeEach(async () => {
187
+ memory = scenario();
188
+ await memory.given.forEventSource('source', route).events(testCase.event());
189
+ await memory.when.forEventSource('source', route).event(testCase.event());
190
+ await memory.when.forEventSource('source', route).events(testCase.event());
191
+ });
192
+ it('should record the kernel subject for seeds, single appends and batch appends', () => {
193
+ check(memory.appendedEvents.map(event => event.context.subject)).deep.equal(Array(3).fill(testCase.stored));
194
+ });
195
+ it('should retain the selected route on every append path', () => {
196
+ check(memory.appendedEvents.map(event => ({ sourceType: event.context.eventSourceType,
197
+ streamType: event.context.eventStreamType, streamId: event.context.eventStreamId }))).deep.equal(Array(3).fill(route));
198
+ });
199
+ });
200
+ }
201
+ for (const subject of ['explicit', '', ' ']) {
202
+ describe(`when routed scenario builders override an annotation with ${JSON.stringify(subject)}`, () => {
203
+ let subjects: (string | undefined)[];
204
+ beforeEach(async () => {
205
+ const memory = scenario();
206
+ const options = { sourceType: 'people', streamType: 'personal', streamId: 'details', subject };
207
+ await memory.given.forEventSource('source', options).events(fixtures.annotated('person'));
208
+ await memory.when.forEventSource('source', options).event(fixtures.annotated('person'));
209
+ await memory.when.forEventSource('source', options).events(fixtures.annotated('person'));
210
+ subjects = memory.appendedEvents.map(event => event.context.subject);
211
+ });
212
+ it('should prefer the call subject and apply the kernel fallback on every path', () => {
213
+ check(subjects).deep.equal(Array(3).fill(subject.trim() ? subject : 'source'));
214
+ });
215
+ });
216
+ }
217
+ describe('when committing transactional single and batch appends', () => {
218
+ let subjects: string[];
219
+ beforeEach(async () => {
220
+ const client = connected();
221
+ await client.sequence.transactional.append('source', fixtures.annotated('person'));
222
+ await client.sequence.transactional.appendMany('source', [fixtures.concept('other'), fixtures.unannotated()]);
223
+ await client.unitOfWork.commit();
224
+ subjects = client.appendManyForEventSources.mock.calls[0][0].Events.map((entry: { Subject: string }) => entry.Subject);
225
+ });
226
+ it('should resolve each buffered event\'s annotation when the unit of work commits', () => check(subjects).deep.equal(['person', 'other', 'source']));
227
+ });
228
+ for (const testCase of cases) {
229
+ describe(`when seeding a scenario with ${testCase.name}`, () => {
230
+ let subject: string | undefined;
231
+ beforeEach(async () => {
232
+ const memory = scenario();
233
+ await memory.given.forEventSource('source').events(testCase.event());
234
+ subject = memory.appendedEvents[0].context.subject;
235
+ });
236
+ it('should record the kernel subject through the append boundary', () => should.equal(subject, testCase.stored));
237
+ });
238
+ }
239
+ }
@@ -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 { ConceptAs, field } from '@cratis/fundamentals';
5
+ import { describe } from 'vitest';
6
+ import { subject } from '../../../compliance/subject.js';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { subjectBehaviors } from './subject_behaviors.fixture.js';
9
+
10
+ class PersonId extends ConceptAs<string> { static readonly valueType = String; }
11
+ class PersonalData { constructor(readonly personId: string) {} }
12
+ class DerivedPersonalData extends PersonalData {}
13
+ class ConceptPersonalData { constructor(readonly personId: PersonId) {} }
14
+ class UnannotatedData { id = 'not-the-subject'; }
15
+
16
+ subject()(PersonalData.prototype, 'personId');
17
+ field(String)(PersonalData.prototype, 'personId');
18
+ eventType('subject-legacy-string')(PersonalData);
19
+ eventType('subject-legacy-derived')(DerivedPersonalData);
20
+ subject()(ConceptPersonalData.prototype, 'personId');
21
+ field(PersonId)(ConceptPersonalData.prototype, 'personId');
22
+ eventType('subject-legacy-concept')(ConceptPersonalData);
23
+ field(String)(UnannotatedData.prototype, 'id');
24
+ eventType('subject-legacy-unannotated')(UnannotatedData);
25
+
26
+ describe('when appending subject annotations with legacy decorators', () => {
27
+ subjectBehaviors({
28
+ types: [PersonalData, DerivedPersonalData, ConceptPersonalData, UnannotatedData],
29
+ annotated: value => new PersonalData(value),
30
+ inherited: value => new DerivedPersonalData(value),
31
+ concept: value => new ConceptPersonalData(new PersonId(value)),
32
+ unannotated: () => new UnannotatedData()
33
+ });
34
+ });
@@ -0,0 +1,38 @@
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 { ConceptAs, field } from '@cratis/fundamentals';
5
+ import { describe } from 'vitest';
6
+ import { subject } from '../../../compliance/subject.js';
7
+ import { eventType } from '../../../events/eventTypeDecorator.js';
8
+ import { subjectBehaviors } from './subject_behaviors.fixture.js';
9
+
10
+ class PersonId extends ConceptAs<string> { static readonly valueType = String; }
11
+
12
+ @eventType('subject-standard-string')
13
+ class PersonalData {
14
+ @subject() @field(String) personId: string;
15
+ constructor(personId: string) { this.personId = personId; }
16
+ }
17
+
18
+ @eventType('subject-standard-derived')
19
+ class DerivedPersonalData extends PersonalData {}
20
+
21
+ @eventType('subject-standard-concept')
22
+ class ConceptPersonalData {
23
+ @subject() @field(PersonId) personId: PersonId;
24
+ constructor(personId: string) { this.personId = new PersonId(personId); }
25
+ }
26
+
27
+ @eventType('subject-standard-unannotated')
28
+ class UnannotatedData { @field(String) id = 'not-the-subject'; }
29
+
30
+ describe('when appending subject annotations with standard decorators', () => {
31
+ subjectBehaviors({
32
+ types: [PersonalData, DerivedPersonalData, ConceptPersonalData, UnannotatedData],
33
+ annotated: value => new PersonalData(value),
34
+ inherited: value => new DerivedPersonalData(value),
35
+ concept: value => new ConceptPersonalData(value),
36
+ unannotated: () => new UnannotatedData()
37
+ });
38
+ });
@@ -8,6 +8,7 @@ import { getEventTypeFor } from '../events/eventTypeDecorator.js';
8
8
  import { getTagsFor } from '../events/tagDecorator.js';
9
9
  import { mergeTags } from '../events/mergeTags.js';
10
10
  import { identityProvider } from '../identity/index.js';
11
+ import { resolveEventSubject } from '../compliance/resolveEventSubject.js';
11
12
  import type { AppendOptions } from './AppendOptions.js';
12
13
  import type { EventForEventSourceId } from './EventForEventSourceId.js';
13
14
  import type { ConcurrencyScope } from './ConcurrencyScope.js';
@@ -56,7 +57,7 @@ export function prepareBatchAppend(
56
57
  EventType: { Id: eventType.id.value, Generation: eventType.generation.value, Tombstone: eventType.tombstone },
57
58
  Content: JsonSerializer.serialize(event), Tags: tags,
58
59
  Occurred: occurrenceTime === undefined ? undefined : { Value: occurrenceTime.toISOString() },
59
- Subject: subject ?? appendOptions?.subject ?? eventSourceId
60
+ Subject: subject ?? appendOptions?.subject ?? resolveEventSubject(event) ?? eventSourceId
60
61
  };
61
62
  });
62
63
  return { eventsForEventSourceIds, appendOptions, correlationId, batchCausationChain, identity, concurrencyScopes, eventsToAppend };
@@ -8,6 +8,7 @@ import { mergeTags } from '../events/mergeTags.js';
8
8
  import { identityProvider } from '../identity/index.js';
9
9
  import { causationManager, CausationType } from '../auditing/index.js';
10
10
  import { correlationIdManager } from '../correlation/index.js';
11
+ import { resolveEventSubject } from '../compliance/resolveEventSubject.js';
11
12
  import type { AppendOptions } from './AppendOptions.js';
12
13
 
13
14
  /** The production client and in-process scenario share the single-append serialization boundary. */
@@ -16,10 +17,11 @@ export function prepareSingleAppend(event: object, options?: AppendOptions) {
16
17
  const correlationId = options?.correlationId === undefined
17
18
  ? Guid.as(correlationIdManager.current.value)
18
19
  : Guid.as(options.correlationId);
20
+ const subject = options?.subject ?? resolveEventSubject(event);
19
21
  const content = JsonSerializer.serialize(event);
20
22
  const tags = mergeTags(getTagsFor(event.constructor as Function), options?.tags);
21
23
  const causationChain = causationManager.run(CausationType.appendEvent, { eventType: eventType.id.value },
22
24
  () => causationManager.getCurrentChain());
23
25
  const identity = identityProvider.getCurrent();
24
- return { eventType, correlationId, content, tags, causationChain, identity };
26
+ return { eventType, correlationId, content, tags, causationChain, identity, subject };
25
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.41.0",
3
+ "version": "6.41.1",
4
4
  "description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -173,7 +173,7 @@
173
173
  "clean": "rm -rf dist",
174
174
  "test": "vitest run",
175
175
  "test:integration": "vitest run --config vitest.integration.config.ts",
176
- "test:standard-types": "tsc --ignoreConfig --noEmit --target es2022 --module es2022 --moduleResolution bundler --lib es2022,esnext.decorators --types node --skipLibCheck --strict schemas/decorators.standard.spec.ts projections/declarative/addChildCompatibility.standard.spec.ts projections/modelBound/decorators.standard.spec.ts events/classDecorators.standard.spec.ts reactors/decorators.standard.spec.ts readModels/standardFallbacks.standard.spec.ts readModels/registration.standard.spec.ts for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts artifacts/validateArtifactSchemas.spec.ts testing/for_EventScenario/when_appending_concept_fields/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts events/for_handles/when_decorating_a_method.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts",
176
+ "test:standard-types": "tsc --ignoreConfig --noEmit --target es2022 --module es2022 --moduleResolution bundler --lib es2022,esnext.decorators --types node --skipLibCheck --strict schemas/decorators.standard.spec.ts projections/declarative/addChildCompatibility.standard.spec.ts projections/modelBound/decorators.standard.spec.ts events/classDecorators.standard.spec.ts reactors/decorators.standard.spec.ts readModels/standardFallbacks.standard.spec.ts readModels/registration.standard.spec.ts for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts artifacts/validateArtifactSchemas.spec.ts testing/for_EventScenario/when_appending_concept_fields/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts events/for_handles/when_decorating_a_method.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts eventSequences/for_EventSequence/when_appending/with_subject_annotations.standard.spec.ts",
177
177
  "test:native": "node ./scripts/check-native-esm.mjs"
178
178
  },
179
179
  "dependencies": {
@@ -265,7 +265,7 @@ describe('when appending batches against committed kernel fixtures', () => {
265
265
  error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
266
266
  );
267
267
  await first;
268
- for (const options of [{ subject: 'bad subject' }, { tags: ['invalid|tag'] },
268
+ for (const options of [{ subject: 42 as unknown as string }, { tags: ['invalid|tag'] },
269
269
  { concurrencyScope: {} as never }]) {
270
270
  await scenario.appendMany('B', [new OracleEventRecorded('second', false)], options).then(
271
271
  () => { throw new Error('Unproven metadata accepted'); },
@@ -450,7 +450,7 @@ describe('when appending batches against committed kernel fixtures', () => {
450
450
  ['appendMany.correlationId', () => scenario.appendMany('B', [event()], { correlationId: 'bad-guid' })],
451
451
  ['appendMany.entry', () => scenario.appendMany([entry({ unknown: 'value' })])],
452
452
  ['appendMany.sourceType', () => scenario.appendMany([entry({ eventSourceType: 'bad source' })])],
453
- ['appendMany.subject', () => scenario.appendMany([entry({ subject: '' })], { subject: 'shared' })],
453
+ ['appendMany.subject', () => scenario.appendMany([entry({ subject: 42 })], { subject: 'shared' })],
454
454
  ['appendMany.streamType', () => scenario.appendMany([entry({ eventStreamType: 'bad stream' })])],
455
455
  ['appendMany.streamId', () => scenario.appendMany([entry({ eventStreamId: 'bad stream' })])],
456
456
  ['appendMany.event', () => scenario.appendMany('B', [new UnregisteredRecorded()])],
@@ -219,11 +219,11 @@ describe('when appending against committed kernel event fixtures', () => {
219
219
  it('should reject prototype getters and non-enumerable append metadata without mutation', async () => {
220
220
  const scenario = makeScenario();
221
221
  class OptionsWithGetters {
222
- get subject() { return 'different-subject'; }
222
+ get occurred() { return new Date(); }
223
223
  get streamId() { return 'different-stream'; }
224
224
  }
225
225
  await unsupportedAsync(() => scenario.append('A', new OracleEventRecorded('one', true), new OptionsWithGetters()), 'append.options');
226
- const hidden = Object.defineProperty({}, 'subject', { value: 'different-subject', enumerable: false });
226
+ const hidden = Object.defineProperty({}, 'occurred', { value: new Date(), enumerable: false });
227
227
  await unsupportedAsync(() => scenario.append('A', new OracleEventRecorded('one', true), hidden), 'append.options');
228
228
  scenario.appendedEvents.length.should.equal(0);
229
229
  scenario.results.length.should.equal(0);
@@ -233,9 +233,9 @@ describe('when the fixture-bounded event sequence encounters unproven operations
233
233
  await rejects(() => subject.append('A', new Recorded(), { correlationId: '00000000-0000-0000-0000-000000000001' }), 'append.options');
234
234
  clean(subject);
235
235
  });
236
- it('should reject plain subject options', async () => {
236
+ it('should reject non-string subject options', async () => {
237
237
  const subject = scenario();
238
- await rejects(() => subject.append('A', new Recorded(), { subject: 'other' }), 'append.options');
238
+ await rejects(() => subject.append('A', new Recorded(), { subject: 42 as unknown as string }), 'append.subject');
239
239
  clean(subject);
240
240
  });
241
241
  it('should reject empty source/type-read event types', async () => {
@@ -37,6 +37,11 @@ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOpe
37
37
  // JS trim() omits U+0085, which the kernel trims. Reject unproven non-ASCII whitespace and controls.
38
38
  const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space}/u;
39
39
 
40
+ function resolveStoredSubject(subject: string | undefined, eventSourceId: string): string {
41
+ // Match the kernel's string.IsNullOrWhiteSpace, including U+0085 but not U+FEFF.
42
+ return subject && !/^\p{White_Space}*$/u.test(subject) ? subject : eventSourceId;
43
+ }
44
+
40
45
  function matchesConstrainedValue(value: unknown, content: unknown): boolean {
41
46
  // Use production serialization for concepts, not their wrapper object's runtime type.
42
47
  // Keep rejecting other unproven conversions, such as a Date assigned to a string field.
@@ -168,13 +173,13 @@ export class InProcessEventSequence implements IEventSequence {
168
173
  try {
169
174
  if (!event || typeof event !== 'object') throw this.unsupported('append.event', this.id.value, 'Only registered event instances are supported.');
170
175
  if (!/^[A-Za-z0-9_-]+$/.test(eventSourceId)) throw this.unsupported('append.source', eventSourceId, 'Only simple source identifiers are fixture-backed.');
171
- const route = singleAppendRoute(options, event.constructor.name, (operation, artifact, reason) => this.unsupported(operation, artifact, reason));
176
+ const route = singleAppendRoute(options, event.constructor.name, (operation, artifact, reason) => this.unsupported(operation, artifact, reason), true);
172
177
  const metadata = this._catalog.get(event.constructor);
173
178
  if (!metadata) throw this.unsupported('append.event', event.constructor.name, 'Event is not in the selected, validated catalog.');
174
179
  let prepared: ReturnType<typeof prepareSingleAppend>;
175
180
  let content: Record<string, unknown>;
176
181
  try {
177
- prepared = prepareSingleAppend(event, this._correlationId ? { correlationId: this._correlationId() } : undefined);
182
+ prepared = prepareSingleAppend(event, { subject: options?.subject, correlationId: this._correlationId?.() });
178
183
  content = this.checkedContent(event, prepared.content, 'append.content',
179
184
  'Content differs from the event schema; null, missing and extra values are not proven.');
180
185
  } catch (error) {
@@ -207,7 +212,7 @@ export class InProcessEventSequence implements IEventSequence {
207
212
  eventStore: this._store, namespace: this._namespace,
208
213
  sequenceNumber: sequenceNumber.value, eventSourceId,
209
214
  eventSourceType: route.sourceType, eventStreamType: route.streamType, eventStreamId: route.streamId,
210
- subject: eventSourceId, hash, causedBy: prepared.identity, observationState: EventObservationState.Initial,
215
+ subject: resolveStoredSubject(prepared.subject, eventSourceId), hash, causedBy: prepared.identity, observationState: EventObservationState.Initial,
211
216
  eventType: prepared.eventType, occurred, correlationId: prepared.correlationId.toString(),
212
217
  causation: prepared.causationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
213
218
  tags: prepared.tags.map(tag => new Tag(tag))
@@ -254,6 +259,9 @@ export class InProcessEventSequence implements IEventSequence {
254
259
  }
255
260
  const individual = entry as EventForEventSourceId;
256
261
  if (individual.occurred !== undefined) this.checkedDate(individual.occurred, 'appendMany.occurred');
262
+ if (individual.subject !== undefined && individual.subject !== null && typeof individual.subject !== 'string') {
263
+ throw this.unsupported('appendMany.subject', this.id.value, 'Subject must be a string.');
264
+ }
257
265
  const tags = individual.tags;
258
266
  if (tags && (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string'))) {
259
267
  throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
@@ -288,8 +296,8 @@ export class InProcessEventSequence implements IEventSequence {
288
296
  this.validateSource(eventSourceId, 'appendMany.source');
289
297
  const wire = eventsToAppend[index];
290
298
  for (const [name, value] of Object.entries({ sourceType: wire.EventSourceType, streamType: wire.EventStreamType,
291
- streamId: wire.EventStreamId, subject: wire.Subject })) {
292
- const identifiers = name === 'subject' ? /^[A-Za-z0-9_-]+$/ : /^[A-Za-z0-9_-]*$/;
299
+ streamId: wire.EventStreamId })) {
300
+ const identifiers = /^[A-Za-z0-9_-]*$/;
293
301
  if (value !== undefined && (typeof value !== 'string' || !identifiers.test(value))) {
294
302
  throw this.unsupported(`appendMany.${name}`, String(value), 'Only simple metadata identifiers are fixture-backed.');
295
303
  }
@@ -307,7 +315,7 @@ export class InProcessEventSequence implements IEventSequence {
307
315
  const stored: AppendedEvent = { eventType, content, context: {
308
316
  eventStore: this._store, namespace: this._namespace, sequenceNumber: sequenceNumber.value, eventSourceId,
309
317
  eventSourceType: wire.EventSourceType || 'Default', eventStreamType: wire.EventStreamType || 'All',
310
- eventStreamId: wire.EventStreamId || 'Default', subject: wire.Subject, hash, causedBy: identity,
318
+ eventStreamId: wire.EventStreamId || 'Default', subject: resolveStoredSubject(wire.Subject, eventSourceId), hash, causedBy: identity,
311
319
  observationState: EventObservationState.Initial, eventType, occurred, correlationId: correlationId.toString(),
312
320
  causation: batchCausationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
313
321
  tags: wire.Tags.map(tag => new Tag(tag))
@@ -408,6 +416,9 @@ export class InProcessEventSequence implements IEventSequence {
408
416
  options.concurrencyScope !== undefined || options.concurrencyScopes !== undefined || options.eventSourceId !== undefined) {
409
417
  throw this.unsupported('appendMany.options', this.id.value, 'Concurrency and unrecognized metadata are not fixture-backed.');
410
418
  }
419
+ if (options.subject !== undefined && options.subject !== null && typeof options.subject !== 'string') {
420
+ throw this.unsupported('appendMany.subject', this.id.value, 'Subject must be a string.');
421
+ }
411
422
  if (options.tags && (!Array.isArray(options.tags) || options.tags.some(tag => typeof tag !== 'string'))) {
412
423
  throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
413
424
  }
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
5
5
  import { ConceptAs, field, Guid } from '@cratis/fundamentals';
6
6
  import type { Constructor } from '@cratis/fundamentals';
7
7
  import { pii } from '../compliance/pii.js';
8
+ import { subject } from '../compliance/subject.js';
8
9
  import { eventType } from '../events/eventTypeDecorator.js';
9
10
  import type { EventContext } from '../events/EventContext.js';
10
11
  import { fromEvent } from '../projections/modelBound/fromEvent.js';
@@ -219,6 +220,36 @@ describe('ReadModelScenario', () => {
219
220
  expect(await scenario.instance).toMatchObject({ subject: 'source-a' });
220
221
  });
221
222
 
223
+ it('maps a seeded event subject annotation to the projection Subject context', async () => {
224
+ class PersonalData {
225
+ @field(String) personId = 'person-a';
226
+ }
227
+ subject()(PersonalData.prototype, 'personId');
228
+ eventType('scenario-personal-data')(PersonalData);
229
+ class SubjectState { @field(String) id = ''; @field(String) subject = ''; }
230
+ fromEvent(PersonalData)(SubjectState);
231
+ setFromContext(PersonalData, 'subject')(SubjectState.prototype, 'subject');
232
+ const scenario = new ReadModelScenario(SubjectState, { ...artifacts, eventTypes: [PersonalData] });
233
+ scenario.given.forEventSource('source-a').events(new PersonalData());
234
+ expect(await scenario.instance).toMatchObject({ subject: 'person-a' });
235
+ });
236
+
237
+ it.each([undefined, null, '', ' \t\n', '\u0085\u2003'])('defaults an annotated subject %j to the event source', async value => {
238
+ class PersonalData {
239
+ // Keep null out of serialized content while exercising subject resolution.
240
+ get personId(): string | null | undefined { return value; }
241
+ }
242
+ field(String)(PersonalData.prototype, 'personId');
243
+ subject()(PersonalData.prototype, 'personId');
244
+ eventType('scenario-empty-subject')(PersonalData);
245
+ class SubjectState { @field(String) id = ''; @field(String) subject = ''; }
246
+ fromEvent(PersonalData)(SubjectState);
247
+ setFromContext(PersonalData, 'subject')(SubjectState.prototype, 'subject');
248
+ const scenario = new ReadModelScenario(SubjectState, { ...artifacts, eventTypes: [PersonalData] });
249
+ scenario.given.forEventSource('source-a').events(new PersonalData());
250
+ expect(await scenario.instance).toMatchObject({ subject: 'source-a' });
251
+ });
252
+
222
253
  it('defaults a projection Hash and CausedBy mapping to kernel sentinels when the seed omits them', async () => {
223
254
  class ContextDefaults { @field(String) id = ''; @field(String) hash = ''; @field(String) causedByName = ''; }
224
255
  setFromContext(ItemAdded, 'hash')(ContextDefaults.prototype, 'hash');
@@ -7,6 +7,7 @@ import type { Constructor } from '@cratis/fundamentals';
7
7
  import { DefaultClientArtifactsProvider } from '../artifacts/DefaultClientArtifactsProvider.js';
8
8
  import type { IClientArtifactsProvider } from '../artifacts/IClientArtifactsProvider.js';
9
9
  import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
10
+ import { resolveEventSubject } from '../compliance/resolveEventSubject.js';
10
11
  import { getFromEventMetadata } from '../projections/modelBound/fromEvent.js';
11
12
  import { getRemovedWithClassMetadata } from '../projections/modelBound/removedWith.js';
12
13
  import { getRemovedWithJoinClassMetadata } from '../projections/modelBound/removedWithJoin.js';
@@ -138,11 +139,14 @@ export class ReadModelScenario<TReadModel extends object> {
138
139
  if (!metadata) throw new Error(`Event '${event.constructor.name}' has no @eventType metadata.`);
139
140
  // The kernel delivers JSON objects, not instances of decorated event classes.
140
141
  const content: unknown = JSON.parse(JsonSerializer.serialize(event));
142
+ const subject = resolveEventSubject(event);
141
143
  this._events.push({
142
144
  sourceId: id,
143
145
  content,
144
146
  context: {
145
147
  eventSourceId: id,
148
+ // Match the kernel's string.IsNullOrWhiteSpace, including U+0085 but not U+FEFF.
149
+ subject: subject && !/^\p{White_Space}*$/u.test(subject) ? subject : id,
146
150
  eventSourceType: 'Default',
147
151
  eventStreamType: 'All',
148
152
  eventStreamId: 'Default',
@@ -83,7 +83,7 @@ describe('when a scoped append uses unsupported routes or metadata', () => {
83
83
  await unsupported(() => scenario.appendMany('B', [new Claim('Beta')], options), `appendMany.${dimension}`);
84
84
  }
85
85
  }
86
- for (const options of [{ tags: [] }, { subject: '' }, { occurred: new Date() }, { correlationId: '00000000-0000-0000-0000-000000000001' },
86
+ for (const options of [{ tags: [] }, { occurred: new Date() }, { correlationId: '00000000-0000-0000-0000-000000000001' },
87
87
  { concurrencyScopes: {} }, { eventSourceId: 'other' }, { arbitrary: true }]) {
88
88
  await unsupported(() => scenario.append('B', new Claim('Beta'), options as AppendOptions), 'append.options');
89
89
  }
@@ -25,7 +25,7 @@ describe('when read-model setup receives route options', () => {
25
25
  it('should reuse single-append rejection messages without collecting unsupported input', async () => {
26
26
  const scenario = create();
27
27
  const events = createEvents();
28
- for (const value of [{ sourceType: ' ' }, { streamType: null }, { streamId: 1 }, { tags: [] }, { arbitrary: true }, null, []]) {
28
+ for (const value of [{ sourceType: ' ' }, { streamType: null }, { streamId: 1 }, null, []]) {
29
29
  const options = value as AppendOptions;
30
30
  const error = await rejection(() => events.append('A', new Claim('Other'), options));
31
31
  (await rejection(() => scenario.given.forEventSource('A', options).events(new Claim('Other')))).message.should.equal(error.message);
@@ -33,6 +33,16 @@ describe('when read-model setup receives route options', () => {
33
33
  ((await scenario.instance) === null).should.be.true;
34
34
  });
35
35
 
36
+ it('should list only supported seed options without collecting unsupported input', async () => {
37
+ const scenario = create();
38
+ for (const value of [{ tags: [] }, { arbitrary: true }, { subject: 'person-a' }]) {
39
+ const options = value as AppendOptions;
40
+ (await rejection(() => scenario.given.forEventSource('A', options).events(new Claim('Other')))).message.should.equal(
41
+ 'UnsupportedEventSequenceOperation: append.options (Claim): Only sourceType, streamType, streamId options are supported for single append. Use a kernel-backed test.');
42
+ }
43
+ ((await scenario.instance) === null).should.be.true;
44
+ });
45
+
36
46
  it('should retain the unproven routed read-model boundary instead of simulating projection routing', async () => {
37
47
  const scenario = create();
38
48
  (() => scenario.given.forEventSource('A', route).events(new Claim('Other'))).should.throw(UnsupportedProjectionOperation, 'routed events');
@@ -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
+ import { chai, describe, it } from 'vitest';
5
+ import { singleAppendRoute } from './singleAppendRoute.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when single-append routing rejects unsupported options', () => {
10
+ it('should list only routing options when subject is not allowed', () => {
11
+ (() => singleAppendRoute({ subject: 'person-a' }, 'SeededEvent'))
12
+ .should.throw('Only sourceType, streamType, streamId options are supported for single append.');
13
+ });
14
+
15
+ it('should include subject in the allowed options for single append', () => {
16
+ (() => singleAppendRoute({ tags: [] }, 'AppendedEvent', undefined, true))
17
+ .should.throw('Only sourceType, streamType, streamId, subject options are supported for single append.');
18
+ });
19
+ });