@cratis/chronicle 6.31.4 → 6.32.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 (45) hide show
  1. package/dist/schemas/JsonSchemaGenerator.d.ts +4 -4
  2. package/dist/schemas/JsonSchemaGenerator.d.ts.map +1 -1
  3. package/dist/schemas/JsonSchemaGenerator.js +23 -8
  4. package/dist/schemas/JsonSchemaGenerator.js.map +1 -1
  5. package/dist/testing/ConstraintScope.d.ts +9 -0
  6. package/dist/testing/ConstraintScope.d.ts.map +1 -0
  7. package/dist/testing/ConstraintScope.js +36 -0
  8. package/dist/testing/ConstraintScope.js.map +1 -0
  9. package/dist/testing/EventScenario.d.ts.map +1 -1
  10. package/dist/testing/EventScenario.js +10 -0
  11. package/dist/testing/EventScenario.js.map +1 -1
  12. package/dist/testing/InProcessConstraints.d.ts +1 -1
  13. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  14. package/dist/testing/InProcessConstraints.js +23 -13
  15. package/dist/testing/InProcessConstraints.js.map +1 -1
  16. package/dist/testing/InProcessEventSequence.d.ts +1 -0
  17. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  18. package/dist/testing/InProcessEventSequence.js +23 -8
  19. package/dist/testing/InProcessEventSequence.js.map +1 -1
  20. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts +2 -1
  21. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts.map +1 -1
  22. package/dist/testing/projections/ProjectionChildrenCapabilities.js +9 -3
  23. package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -1
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +2 -2
  26. package/schemas/JsonSchemaGenerator.ts +22 -7
  27. package/schemas/for_JsonSchemaGenerator/when_generating_children/with_legacy_decorators.legacy.spec.ts +143 -0
  28. package/schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts +66 -0
  29. package/testing/ConstraintScope.ts +32 -0
  30. package/testing/EventScenario.batches.spec.ts +2 -5
  31. package/testing/EventScenario.constraints.spec.ts +1 -4
  32. package/testing/EventScenario.fixtures.spec.ts +1 -1
  33. package/testing/EventScenario.ts +12 -0
  34. package/testing/InProcessConstraints.ts +29 -16
  35. package/testing/InProcessEventSequence.ts +24 -8
  36. package/testing/ReadModelScenario.childrenFrom.spec.ts +70 -3
  37. package/testing/for_EventScenario/when_appending_scoped_constraints/scope_scenario_behaviors.fixture.ts +139 -0
  38. package/testing/for_EventScenario/when_appending_scoped_constraints/with_legacy_decorators.legacy.spec.ts +57 -0
  39. package/testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts +56 -0
  40. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_cycles.spec.ts +48 -0
  41. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_operations.spec.ts +119 -0
  42. package/testing/for_ReactorScenario/when_enforcing_scoped_constraints/with_default_input_routes.spec.ts +70 -0
  43. package/testing/for_ReactorScenario/when_enforcing_scoped_constraints/with_routed_service_appends.spec.ts +65 -0
  44. package/testing/projections/ProjectionChildrenCapabilities.ts +9 -2
  45. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +34 -0
@@ -0,0 +1,143 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { childrenFrom } from '../../../projections/modelBound/childrenFrom.js';
8
+ import { JsonSchemaGenerator } from '../../JsonSchemaGenerator.js';
9
+
10
+ chai.should();
11
+
12
+ class ItemAdded {
13
+ itemId = '';
14
+ name = '';
15
+ }
16
+
17
+ class Item {
18
+ id = '';
19
+ name = '';
20
+ quantity = 0;
21
+ }
22
+
23
+ class Order {
24
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
25
+ options: Item[] = [];
26
+
27
+ @childrenFrom(ItemAdded, Item)
28
+ positional: Item[] = [];
29
+
30
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
31
+ withoutInitializer!: Item[];
32
+
33
+ @field(Array, { genericArguments: [Item] })
34
+ @childrenFrom(ItemAdded, 'itemId')
35
+ fieldMetadata: Item[] = [];
36
+
37
+ @childrenFrom(ItemAdded, { key: 'itemId', identifiedBy: 'id' })
38
+ unknown: Item[] = [];
39
+
40
+ @field(Array, { genericArguments: [Item] })
41
+ unrelated: Item[] = [];
42
+ }
43
+
44
+ class DeclaredItem {
45
+ @field(String) id!: string;
46
+ @field(String) name!: string;
47
+ }
48
+
49
+ class DeclaredOrder {
50
+ @childrenFrom(ItemAdded, { childType: DeclaredItem, key: 'itemId' })
51
+ items!: DeclaredItem[];
52
+ }
53
+
54
+ class UnresolvedItem {
55
+ id!: string;
56
+ name!: string;
57
+ }
58
+
59
+ class PartiallyResolvedItem {
60
+ id = '';
61
+ name!: string;
62
+ }
63
+
64
+ class MissingIdentifierItem {
65
+ id!: string;
66
+ name = '';
67
+ }
68
+
69
+ class CustomIdentifierItem {
70
+ itemId = '';
71
+ name!: string;
72
+ }
73
+
74
+ class CapitalizedIdentifierItem {
75
+ Id = '';
76
+ name!: string;
77
+ }
78
+
79
+ class FallbackOrder {
80
+ @childrenFrom(ItemAdded, { childType: UnresolvedItem, key: 'itemId', identifiedBy: 'id' })
81
+ unresolved!: UnresolvedItem[];
82
+
83
+ @childrenFrom(ItemAdded, { childType: MissingIdentifierItem, key: 'itemId', identifiedBy: 'id' })
84
+ missingIdentifier!: MissingIdentifierItem[];
85
+
86
+ @childrenFrom(ItemAdded, { childType: PartiallyResolvedItem, key: 'itemId' })
87
+ partial!: PartiallyResolvedItem[];
88
+
89
+ @childrenFrom(ItemAdded, { childType: CustomIdentifierItem, key: 'itemId' })
90
+ custom!: CustomIdentifierItem[];
91
+
92
+ @childrenFrom(ItemAdded, { childType: CapitalizedIdentifierItem, key: 'itemId' })
93
+ capitalized!: CapitalizedIdentifierItem[];
94
+
95
+ @childrenFrom(ItemAdded, { childType: MissingIdentifierItem, key: 'itemId' })
96
+ undiscoverableIdentifier!: MissingIdentifierItem[];
97
+ }
98
+
99
+ const schema = JsonSchemaGenerator.generate(Order);
100
+
101
+ describe('for JsonSchemaGenerator', () => {
102
+ describe('when generating children with legacy decorators without design metadata', () => {
103
+ for (const property of ['options', 'positional', 'withoutInitializer', 'fieldMetadata']) {
104
+ it(`should register typed items for ${property}`, () => {
105
+ chai.should().not.exist(Reflect.getMetadata('design:type', Order.prototype, property));
106
+ schema.properties![property].should.deep.equal({ type: 'array', items: JsonSchemaGenerator.generate(Item) });
107
+ schema.properties![property].items!.properties!.should.deep.equal({
108
+ id: { type: 'string' }, name: { type: 'string' }, quantity: { type: 'number' }
109
+ });
110
+ });
111
+ }
112
+
113
+ it('should resolve child properties from explicit field metadata without initializers', () => {
114
+ JsonSchemaGenerator.generate(DeclaredOrder).properties!.items.items!.properties!.should.deep.equal({
115
+ id: { type: 'string' }, name: { type: 'string' }
116
+ });
117
+ });
118
+
119
+ it('should retain untyped items when the child type is unknown', () => {
120
+ schema.properties!.unknown.should.deep.equal({ type: 'array', items: { type: 'object' } });
121
+ });
122
+
123
+ it('should retain untyped items for child members declared only with definite assignment', () => {
124
+ JsonSchemaGenerator.generate(FallbackOrder).properties!.unresolved.should.deep.equal({ type: 'array', items: { type: 'object' } });
125
+ });
126
+
127
+ for (const property of ['missingIdentifier', 'undiscoverableIdentifier']) {
128
+ it(`should retain untyped items for ${property} despite a resolved non-identifier property`, () => {
129
+ JsonSchemaGenerator.generate(FallbackOrder).properties![property].should.deep.equal({ type: 'array', items: { type: 'object' } });
130
+ });
131
+ }
132
+
133
+ for (const [property, childType] of [['partial', PartiallyResolvedItem], ['custom', CustomIdentifierItem], ['capitalized', CapitalizedIdentifierItem]] as const) {
134
+ it(`should retain typed items for ${property} with a resolved identifier`, () => {
135
+ JsonSchemaGenerator.generate(FallbackOrder).properties![property].should.deep.equal({ type: 'array', items: JsonSchemaGenerator.generate(childType) });
136
+ });
137
+ }
138
+
139
+ it('should preserve legacy array schemas outside children collections', () => {
140
+ schema.properties!.unrelated.should.deep.equal({ type: 'array', items: { type: 'object' } });
141
+ });
142
+ });
143
+ });
@@ -0,0 +1,66 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { field } from '@cratis/fundamentals';
5
+ import { chai, describe, it } from 'vitest';
6
+ import { childrenFrom } from '../../../projections/modelBound/childrenFrom.js';
7
+ import { JsonSchemaGenerator } from '../../JsonSchemaGenerator.js';
8
+
9
+ chai.should();
10
+
11
+ function should(value: unknown): ReturnType<typeof chai.expect> {
12
+ return (value as { should: ReturnType<typeof chai.expect> }).should;
13
+ }
14
+
15
+ class ItemAdded {
16
+ @field(String) itemId!: string;
17
+ @field(String) name!: string;
18
+ }
19
+
20
+ class Item {
21
+ @field(String) id!: string;
22
+ @field(String) name!: string;
23
+ @field(Number) quantity!: number;
24
+ }
25
+
26
+ class Order {
27
+ @field(Array)
28
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
29
+ options!: Item[];
30
+
31
+ @field(Array)
32
+ @childrenFrom(ItemAdded, Item)
33
+ positional!: Item[];
34
+
35
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
36
+ withoutField!: Item[];
37
+
38
+ @field(Array, { genericArguments: [Item] })
39
+ @childrenFrom(ItemAdded, 'itemId')
40
+ fieldMetadata!: Item[];
41
+ }
42
+
43
+ class UnknownOrder {
44
+ @field(Array)
45
+ @childrenFrom(ItemAdded, { key: 'itemId', identifiedBy: 'id' })
46
+ items!: Item[];
47
+ }
48
+
49
+ const schema = JsonSchemaGenerator.generate(Order);
50
+
51
+ describe('for JsonSchemaGenerator', () => {
52
+ describe('when generating children with standard decorators', () => {
53
+ for (const property of ['options', 'positional', 'withoutField', 'fieldMetadata']) {
54
+ it(`should register typed items for ${property}`, () => {
55
+ should(schema.properties![property]).deep.equal({ type: 'array', items: JsonSchemaGenerator.generate(Item) });
56
+ should(schema.properties![property].items!.properties!).deep.equal({
57
+ id: { type: 'string' }, name: { type: 'string' }, quantity: { type: 'number' }
58
+ });
59
+ });
60
+ }
61
+
62
+ it('should still reject an unresolved child type', () => {
63
+ should(() => JsonSchemaGenerator.generate(UnknownOrder)).throw(TypeError, 'Cannot determine the element type of UnknownOrder.items');
64
+ });
65
+ });
66
+ });
@@ -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 { EventContext } from '../events/EventContext.js';
5
+ import type { ConstraintScopeCapture } from '../events/constraints/ConstraintBuilder.js';
6
+
7
+ /** Property indexes use the kernel's unescaped flattened key, not a tuple. */
8
+ export function propertyScopeKey(scope: ConstraintScopeCapture, context: EventContext): string {
9
+ const parts: string[] = [];
10
+ if (scope.perEventSourceType) parts.push(`est:${context.eventSourceType}`);
11
+ if (scope.perEventStreamType) parts.push(`estt:${context.eventStreamType}`);
12
+ if (scope.perEventStreamId) parts.push(`esid:${context.eventStreamId}`);
13
+ return parts.join('|');
14
+ }
15
+
16
+ /** Event-type cycles compare selected dimensions exactly. Never flatten them like property keys. */
17
+ export function eventTypeScopeKey(scope: ConstraintScopeCapture, context: EventContext): string {
18
+ return JSON.stringify([
19
+ scope.perEventSourceType ? context.eventSourceType : null,
20
+ scope.perEventStreamType ? context.eventStreamType : null,
21
+ scope.perEventStreamId ? context.eventStreamId : null
22
+ ]);
23
+ }
24
+
25
+ /** Keep definition names, scope keys and source IDs in separate map levels. */
26
+ export function scopeIndex<T>(indexes: Map<string, Map<string, T>>, name: string, scope: string, create: () => T): T {
27
+ let scopes = indexes.get(name);
28
+ if (!scopes) { scopes = new Map<string, T>(); indexes.set(name, scopes); }
29
+ let index = scopes.get(scope);
30
+ if (index === undefined) { index = create(); scopes.set(scope, index); }
31
+ return index;
32
+ }
@@ -138,8 +138,8 @@ describe('when appending batches against committed kernel fixtures', () => {
138
138
  expected: { sequences: string[]; routes: Array<{ source: string; sourceType: string; streamType: string; streamId: string }> };
139
139
  };
140
140
  const scenario = create();
141
- // Explicit empty route values are outside the scenario's domain; omit only those fields here.
142
- const supported = routeCases.filter(item => item.sourceType !== '' && item.streamType !== '' && item.streamId !== '');
141
+ // Scoped wire fixtures additionally prove ts-proto omission of explicit empty routes.
142
+ const supported = routeCases;
143
143
  const results = await scenario.appendMany(supported.map(item => ({ eventSourceId: item.source,
144
144
  event: new OracleEventRecorded(item.name, item.active), eventSourceType: item.sourceType,
145
145
  eventStreamType: item.streamType, eventStreamId: item.streamId })));
@@ -450,9 +450,6 @@ 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.sourceType', () => scenario.appendMany([entry({ eventSourceType: '' })], { sourceType: 'Group' })],
454
- ['appendMany.streamType', () => scenario.appendMany('B', [event()], { streamType: '' })],
455
- ['appendMany.streamId', () => scenario.appendMany([entry({ eventStreamId: '' })], { streamId: 'stream1' })],
456
453
  ['appendMany.subject', () => scenario.appendMany([entry({ subject: '' })], { subject: 'shared' })],
457
454
  ['appendMany.streamType', () => scenario.appendMany([entry({ eventStreamType: 'bad stream' })])],
458
455
  ['appendMany.streamId', () => scenario.appendMany([entry({ eventStreamId: 'bad stream' })])],
@@ -355,7 +355,7 @@ describe('fixture-backed unscoped constraints', () => {
355
355
  subject.results.length.should.equal(1);
356
356
  });
357
357
 
358
- it('rejects scoped, composite and case-insensitive configurations and unsupported keys before mutation', async () => {
358
+ it('rejects malformed or overlapping configurations and unsupported keys before mutation', async () => {
359
359
  const scoped = (configure: (builder: IConstraintBuilder) => void) => {
360
360
  class InvalidConstraint implements IConstraint {
361
361
  define(builder: IConstraintBuilder) { configure(builder); }
@@ -363,9 +363,6 @@ describe('fixture-backed unscoped constraints', () => {
363
363
  constraint('ScopedOrComposite')(InvalidConstraint);
364
364
  unsupported(() => new EventScenario({ artifacts: { eventTypes: types, constraints: [InvalidConstraint] } }), 'artifacts.constraints');
365
365
  };
366
- scoped(builder => builder.perEventStreamId().unique(key => key.on(OracleKeyClaimed, event => event.key)));
367
- scoped(builder => builder.perEventStreamType().unique(key => key.on(OracleKeyClaimed, event => event.key)));
368
- scoped(builder => builder.perEventSourceType().unique(key => key.on(OracleKeyClaimed, event => event.key)));
369
366
  scoped(builder => builder.unique(key => key.on(OracleKeyClaimed, event => event.key, event => event.key)));
370
367
  scoped(builder => builder.perEventStreamId().unique(key => key.on(OracleKeyClaimed, event => event.key).ignoreCasing()));
371
368
  scoped(builder => builder.unique(key => key.on(OracleKeyClaimed, event => event.key))
@@ -59,7 +59,7 @@ const fixtures = readdirSync(directory).filter(name => name.endsWith('.json') &&
59
59
  !['batches.json', 'batch-omitted-routes.json', 'batch-rollback.json', 'builders.json', 'constraints.json', 'constraints-isolation.json', 'constraints-key-domain.json',
60
60
  'constraints-property-lifecycle.json', 'constraints-property-covered-removal.json', 'constraints-event-type-cycles.json',
61
61
  'constraints-event-type-siblings.json', 'constraints-composite.json',
62
- 'constraints-ignore-casing.json'].includes(name)).map(name => ({
62
+ 'constraints-ignore-casing.json', 'constraints-scopes.json'].includes(name)).map(name => ({
63
63
  name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
64
64
  }));
65
65
  const artifacts = { eventTypes: [OracleEventRecorded, AlternateRecorded, BoundaryRecorded], constraints: [] };
@@ -96,9 +96,21 @@ export class EventScenario {
96
96
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
97
97
  'Every constrained event type must be in the selected catalog. Claiming and removal references must be complete.');
98
98
  }
99
+ const scoped = Object.values(capture.scope).some(Boolean);
100
+ if (scoped && removedWith.some(id => {
101
+ const type = eventTypes.find(type => getEventTypeFor(type).id.value === id);
102
+ return type && Object.keys(getEventTypeMetadata(type)?.schema.properties ?? {}).length === 0;
103
+ })) {
104
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
105
+ 'Scoped fieldless removal events are not fixture-backed.');
106
+ }
99
107
  for (const entry of capture.uniqueConstraint?.eventDefinitions ?? []) {
100
108
  const type = eventTypes.find(type => getEventTypeFor(type).id.value === entry.eventTypeId)!;
101
109
  const schemaTypes = entry.properties.map(property => getEventTypeMetadata(type)?.schema.properties?.[property]?.type ?? '');
110
+ if (scoped && schemaTypes.some(schemaType => schemaType !== 'string')) {
111
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
112
+ 'Scoped property keys must be schema-backed strings.');
113
+ }
102
114
  if (entry.properties.length === 1 && !['string', 'boolean'].includes(schemaTypes[0])) {
103
115
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
104
116
  'The constrained property must be a schema-backed string or boolean.');
@@ -8,6 +8,7 @@ import type { ConstraintCapture } from '../events/constraints/ConstraintBuilder.
8
8
  import { resolveConstraintMessage } from '../events/constraints/Constraints.js';
9
9
  import type { ConstraintViolation } from '../eventSequences/ConstraintViolation.js';
10
10
  import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
11
+ import { eventTypeScopeKey, propertyScopeKey, scopeIndex } from './ConstraintScope.js';
11
12
 
12
13
  export type WireConstraintViolation = ContractsConstraintViolation;
13
14
 
@@ -49,7 +50,7 @@ function claimOf(name: string, unique: UniqueCapture, eventTypeId: string, conte
49
50
  return { key: keyHash(joined.replace(/[A-Z]/g, letter => String.fromCharCode(letter.charCodeAt(0) + 32))), parts };
50
51
  }
51
52
 
52
- /** Narrow, fixture-backed unscoped constraint validation over serialized event snapshots. */
53
+ /** Narrow, fixture-backed constraint validation over serialized event snapshots. */
53
54
  export class InProcessConstraints {
54
55
  private readonly _constrainedProperties = new Map<string, Set<string>>();
55
56
  private readonly _removalTypes = new Set<string>();
@@ -62,11 +63,15 @@ export class InProcessConstraints {
62
63
  if (capture.uniqueConstraint && capture.uniqueEventType) {
63
64
  throw this.unsupported(name, 'A definition with both constraint kinds is not fixture-backed.');
64
65
  }
65
- if (capture.scope.perEventSourceType || capture.scope.perEventStreamType || capture.scope.perEventStreamId) {
66
- throw this.unsupported(name, 'Scoped constraints are not fixture-backed.');
66
+ const scoped = Object.values(capture.scope).some(Boolean);
67
+ if (scoped && _definitions.size !== 1) {
68
+ throw this.unsupported(name, 'Scoped constraints alongside other definitions are not fixture-backed.');
67
69
  }
68
70
  if (capture.uniqueConstraint) {
69
71
  const unique = capture.uniqueConstraint;
72
+ if (scoped && (unique.ignoreCasing || unique.eventDefinitions.some(entry => entry.properties.length !== 1))) {
73
+ throw this.unsupported(name, 'Scoped composite or case-insensitive property keys are not fixture-backed.');
74
+ }
70
75
  // constraints-composite.json installs one, two and three flat properties per event type.
71
76
  // The kernel keys properties by path with ToDictionary, so duplicate paths are not a key shape.
72
77
  if (unique.eventDefinitions.length === 0 || unique.eventDefinitions.some(entry =>
@@ -85,6 +90,10 @@ export class InProcessConstraints {
85
90
  this._removalTypes.add(id);
86
91
  this._propertyRemovalTypes.add(id);
87
92
  }
93
+ if (scoped && unique.eventDefinitions.some(entry =>
94
+ entry.eventTypeId === unique.removedWithEventTypeId || unique.removedWithEventTypeIds?.includes(entry.eventTypeId))) {
95
+ throw this.unsupported(name, 'Scoped covered-and-removal property events are not fixture-backed.');
96
+ }
88
97
  for (const entry of unique.eventDefinitions) {
89
98
  const properties = this._constrainedProperties.get(entry.eventTypeId) ?? new Set<string>();
90
99
  entry.properties.forEach(property => properties.add(property));
@@ -104,6 +113,9 @@ export class InProcessConstraints {
104
113
  !(ids.length === 3 && removals.length === 3 && shared === 1)) {
105
114
  throw this.unsupported(name, 'This unique event type set and removal combination is not fixture-backed.');
106
115
  }
116
+ if (scoped && shared !== 0) {
117
+ throw this.unsupported(name, 'Scoped covered-and-removal event cycles are not fixture-backed.');
118
+ }
107
119
  // Each cycle fixture installs its definition alone; interaction with other definitions is unproven.
108
120
  if (removals.length && _definitions.size !== 1) {
109
121
  throw this.unsupported(name, 'Unique event cycles alongside other definitions are not fixture-backed.');
@@ -145,15 +157,16 @@ export class InProcessConstraints {
145
157
  const violations: WireConstraintViolation[] = [];
146
158
  // Durable ownership is one claim per source, not one entry per historical value.
147
159
  // Rebuild it from committed history; batch claims below remain independent and are never released.
148
- const owners = new Map<string, Map<string, { key: string; sequence: string }>>();
160
+ const owners = new Map<string, Map<string, Map<string, { key: string; sequence: string }>>>();
149
161
  for (const [name, definition] of this._definitions) {
150
162
  if (!definition.uniqueConstraint) continue;
151
- const claims = new Map<string, { key: string; sequence: string }>();
152
163
  const unique = definition.uniqueConstraint;
153
164
  const removals = new Set([unique.removedWithEventTypeId, ...(unique.removedWithEventTypeIds ?? [])]);
154
165
  for (const prior of history) {
155
166
  const type = prior.eventType.id.value;
156
167
  const source = prior.context.eventSourceId;
168
+ const claims = scopeIndex(owners, name, propertyScopeKey(definition.scope, prior.context),
169
+ () => new Map<string, { key: string; sequence: string }>());
157
170
  if (removals.has(type)) {
158
171
  claims.delete(source);
159
172
  continue;
@@ -166,10 +179,9 @@ export class InProcessConstraints {
166
179
  claims.set(source, { key: claimOf(name, unique, type, prior.content)!.key,
167
180
  sequence: prior.context.sequenceNumber.toString() });
168
181
  }
169
- owners.set(name, claims);
170
182
  }
171
- const stagedKeys = new Map<string, Map<string, string>>();
172
- const stagedCycles = new Map<string, Map<string, 'open' | 'released'>>();
183
+ const stagedKeys = new Map<string, Map<string, Map<string, string>>>();
184
+ const stagedCycles = new Map<string, Map<string, Map<string, 'open' | 'released'>>>();
173
185
  for (const event of incoming) {
174
186
  const type = event.eventType.id.value;
175
187
  const source = event.context.eventSourceId;
@@ -178,8 +190,10 @@ export class InProcessConstraints {
178
190
  const claim = definition.uniqueConstraint && claimOf(name, definition.uniqueConstraint, type, event.content);
179
191
  if (claim) {
180
192
  const key = claim.key;
181
- const existing = [...owners.get(name)!.entries()].find(([, claim]) => claim.key === key);
182
- const batchClaims = stagedKeys.get(name) ?? new Map<string, string>();
193
+ const scope = propertyScopeKey(definition.scope, event.context);
194
+ const durableClaims = owners.get(name)?.get(scope);
195
+ const existing = [...(durableClaims?.entries() ?? [])].find(([, claim]) => claim.key === key);
196
+ const batchClaims = scopeIndex(stagedKeys, name, scope, () => new Map<string, string>());
183
197
  const batchOwner = batchClaims.get(key);
184
198
  if (existing && existing[0] !== source || batchOwner !== undefined && batchOwner !== source) {
185
199
  // One violation per declared property, in declared order, each with its own original value.
@@ -192,7 +206,6 @@ export class InProcessConstraints {
192
206
  }
193
207
  } else {
194
208
  batchClaims.set(key, source);
195
- stagedKeys.set(name, batchClaims);
196
209
  }
197
210
  }
198
211
  const uniqueType = definition.uniqueEventType;
@@ -200,14 +213,15 @@ export class InProcessConstraints {
200
213
  const covered = uniqueType.eventTypeIds ?? [uniqueType.eventTypeId];
201
214
  const removals = uniqueType.removedWithEventTypeIds ?? [];
202
215
  // Durable answer: the earliest covered event after the latest removal for this source.
216
+ const scope = eventTypeScopeKey(definition.scope, event.context);
203
217
  let first: AppendedEvent | undefined;
204
218
  for (const prior of history) {
205
- if (prior.context.eventSourceId !== source) continue;
219
+ if (prior.context.eventSourceId !== source || eventTypeScopeKey(definition.scope, prior.context) !== scope) continue;
206
220
  const priorType = prior.eventType.id.value;
207
221
  if (removals.includes(priorType)) first = undefined;
208
222
  else if (!first && covered.includes(priorType)) first = prior;
209
223
  }
210
- const cycles = stagedCycles.get(name) ?? new Map<string, 'open' | 'released'>();
224
+ const cycles = scopeIndex(stagedCycles, name, scope, () => new Map<string, 'open' | 'released'>());
211
225
  const state = cycles.get(source);
212
226
  // A cycle state recorded earlier in this append overrides durable history.
213
227
  if (state === 'open' || state === undefined && first) {
@@ -219,7 +233,6 @@ export class InProcessConstraints {
219
233
  Details: {} });
220
234
  } else {
221
235
  cycles.set(source, 'open');
222
- stagedCycles.set(name, cycles);
223
236
  }
224
237
  }
225
238
  }
@@ -228,9 +241,9 @@ export class InProcessConstraints {
228
241
  if (violations.length === violationCount) {
229
242
  for (const [name, definition] of this._definitions) {
230
243
  if (definition.uniqueEventType?.removedWithEventTypeIds?.includes(type)) {
231
- const cycles = stagedCycles.get(name) ?? new Map<string, 'open' | 'released'>();
244
+ const cycles = scopeIndex(stagedCycles, name, eventTypeScopeKey(definition.scope, event.context),
245
+ () => new Map<string, 'open' | 'released'>());
232
246
  cycles.set(source, 'released');
233
- stagedCycles.set(name, cycles);
234
247
  }
235
248
  }
236
249
  }
@@ -169,12 +169,7 @@ export class InProcessEventSequence implements IEventSequence {
169
169
  try {
170
170
  if (!event || typeof event !== 'object') throw this.unsupported('append.event', this.id.value, 'Only registered event instances are supported.');
171
171
  if (!/^[A-Za-z0-9_-]+$/.test(eventSourceId)) throw this.unsupported('append.source', eventSourceId, 'Only simple source identifiers are fixture-backed.');
172
- if (options && (Reflect.ownKeys(options).length || options.correlationId !== undefined || options.sourceType !== undefined ||
173
- options.streamType !== undefined || options.streamId !== undefined || options.subject !== undefined ||
174
- options.occurred !== undefined || options.eventSourceId !== undefined || options.concurrencyScope !== undefined ||
175
- options.tags !== undefined || options.concurrencyScopes !== undefined)) {
176
- throw this.unsupported('append.options', event.constructor.name, 'Append metadata, routing and concurrency are not fixture-backed.');
177
- }
172
+ const route = this.singleAppendRoute(options, event.constructor.name);
178
173
  const metadata = this._catalog.get(event.constructor);
179
174
  if (!metadata) throw this.unsupported('append.event', event.constructor.name, 'Event is not in the selected, validated catalog.');
180
175
  let prepared: ReturnType<typeof prepareSingleAppend>;
@@ -212,7 +207,7 @@ export class InProcessEventSequence implements IEventSequence {
212
207
  context: {
213
208
  eventStore: this._store, namespace: this._namespace,
214
209
  sequenceNumber: sequenceNumber.value, eventSourceId,
215
- eventSourceType: 'Default', eventStreamType: 'All', eventStreamId: 'Default',
210
+ eventSourceType: route.sourceType, eventStreamType: route.streamType, eventStreamId: route.streamId,
216
211
  subject: eventSourceId, hash, causedBy: prepared.identity, observationState: EventObservationState.Initial,
217
212
  eventType: prepared.eventType, occurred, correlationId: prepared.correlationId.toString(),
218
213
  causation: prepared.causationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
@@ -295,7 +290,8 @@ export class InProcessEventSequence implements IEventSequence {
295
290
  const wire = eventsToAppend[index];
296
291
  for (const [name, value] of Object.entries({ sourceType: wire.EventSourceType, streamType: wire.EventStreamType,
297
292
  streamId: wire.EventStreamId, subject: wire.Subject })) {
298
- if (value !== undefined && (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value))) {
293
+ const identifiers = name === 'subject' ? /^[A-Za-z0-9_-]+$/ : /^[A-Za-z0-9_-]*$/;
294
+ if (value !== undefined && (typeof value !== 'string' || !identifiers.test(value))) {
299
295
  throw this.unsupported(`appendMany.${name}`, String(value), 'Only simple metadata identifiers are fixture-backed.');
300
296
  }
301
297
  }
@@ -403,6 +399,26 @@ export class InProcessEventSequence implements IEventSequence {
403
399
  }
404
400
  }
405
401
 
402
+ private singleAppendRoute(options: AppendOptions | undefined, artifact: string): { sourceType: string; streamType: string; streamId: string } {
403
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) {
404
+ throw this.unsupported('append.options', artifact, 'Append options must be an object.');
405
+ }
406
+ if (options && (Reflect.ownKeys(options).some(key => !['sourceType', 'streamType', 'streamId'].includes(String(key))) ||
407
+ options.correlationId !== undefined || options.subject !== undefined || options.occurred !== undefined ||
408
+ options.eventSourceId !== undefined || options.concurrencyScope !== undefined || options.tags !== undefined ||
409
+ options.concurrencyScopes !== undefined)) {
410
+ throw this.unsupported('append.options', artifact, 'Only sourceType, streamType and streamId routing options are fixture-backed for single append.');
411
+ }
412
+ const route = { sourceType: options?.sourceType, streamType: options?.streamType, streamId: options?.streamId };
413
+ for (const [name, value] of Object.entries(route)) {
414
+ if (value !== undefined && (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value))) {
415
+ throw this.unsupported(`append.${name}`, String(value), 'Only simple routing identifiers or empty defaults are fixture-backed.');
416
+ }
417
+ }
418
+ // ts-proto omits empty strings; the append pipeline resolves omissions to these exact values.
419
+ return { sourceType: route.sourceType || 'Default', streamType: route.streamType || 'All', streamId: route.streamId || 'Default' };
420
+ }
421
+
406
422
  private validateBatchOptions(options?: AppendOptions): void {
407
423
  if (options === undefined) return;
408
424
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
@@ -1,11 +1,13 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
+ import { readFileSync } from 'node:fs';
4
5
  import { chai, describe, it } from 'vitest';
5
6
  import { eventType } from '../events/eventTypeDecorator.js';
6
7
  import { childrenFrom } from '../projections/modelBound/childrenFrom.js';
7
8
  import { fromEvent } from '../projections/modelBound/fromEvent.js';
8
9
  import { setFrom } from '../projections/modelBound/setFrom.js';
10
+ import { JsonSchemaGenerator } from '../schemas/JsonSchemaGenerator.js';
9
11
  import { ReadModelScenario, UnsupportedProjectionOperation } from './index.js';
10
12
 
11
13
  chai.should();
@@ -27,6 +29,18 @@ class ScenarioOrder {
27
29
  childrenFrom(ScenarioItemAdded, { childType: ScenarioItem, key: 'itemId' })(ScenarioOrder.prototype, 'items');
28
30
  fromEvent(ScenarioItemAdded)(ScenarioOrder);
29
31
 
32
+ class ScenarioKeyedItem {
33
+ itemId = '';
34
+ name = '';
35
+ }
36
+
37
+ class ScenarioKeyedOrder {
38
+ id = '';
39
+ items: ScenarioKeyedItem[] = [];
40
+ }
41
+ childrenFrom(ScenarioItemAdded, { childType: ScenarioKeyedItem, key: 'itemId' })(ScenarioKeyedOrder.prototype, 'items');
42
+ fromEvent(ScenarioItemAdded)(ScenarioKeyedOrder);
43
+
30
44
  class ScenarioNamedItem {
31
45
  id = '';
32
46
  name = '';
@@ -40,17 +54,70 @@ class ScenarioNamedOrder {
40
54
  childrenFrom(ScenarioItemAdded, { childType: ScenarioNamedItem, key: 'itemId' })(ScenarioNamedOrder.prototype, 'items');
41
55
  fromEvent(ScenarioItemAdded)(ScenarioNamedOrder);
42
56
 
57
+ class ScenarioUnknownOrder {
58
+ id = '';
59
+ items: ScenarioItem[] = [];
60
+ }
61
+ childrenFrom(ScenarioItemAdded, { key: 'itemId', identifiedBy: 'id' })(ScenarioUnknownOrder.prototype, 'items');
62
+ fromEvent(ScenarioItemAdded)(ScenarioUnknownOrder);
63
+
64
+ class ScenarioUnresolvedItem {
65
+ id!: string;
66
+ name!: string;
67
+ }
68
+
69
+ class ScenarioUnresolvedOrder {
70
+ id = '';
71
+ items: ScenarioUnresolvedItem[] = [];
72
+ }
73
+ childrenFrom(ScenarioItemAdded, { childType: ScenarioUnresolvedItem, key: 'itemId', identifiedBy: 'id' })(ScenarioUnresolvedOrder.prototype, 'items');
74
+ fromEvent(ScenarioItemAdded)(ScenarioUnresolvedOrder);
75
+
43
76
  const artifacts = { eventTypes: [ScenarioItemAdded], reducers: [], projections: [] };
44
77
 
45
78
  describe('when a read model scenario evaluates keyed children from the options form', () => {
46
- it('should add one child per key and update an existing child in place (children-untyped-items)', async () => {
79
+ it('should add one child per key and update an existing child in place (children-typed-items)', async () => {
47
80
  const scenario = new ReadModelScenario(ScenarioOrder, artifacts);
48
81
  scenario.given.forEventSource('order-1').events(
49
82
  new ScenarioItemAdded('a', 'first'), new ScenarioItemAdded('b', 'second'), new ScenarioItemAdded('a', 'again'));
50
83
  const order = await scenario.instanceForEventSourceId('order-1');
51
84
  order!.id.should.equal('order-1');
52
- // The kernel's AutoMap is schema-driven, and legacy decorators register an untyped child item schema.
53
- order!.items.map(item => ({ id: item.id, name: item.name })).should.deep.equal([{ id: 'a', name: '' }, { id: 'b', name: '' }]);
85
+ // The explicit child type registers a typed item schema, so AutoMap preserves the event's name.
86
+ order!.items.map(item => ({ id: item.id, name: item.name })).should.deep.equal([{ id: 'a', name: 'again' }, { id: 'b', name: 'second' }]);
87
+ });
88
+
89
+ it('should register the same typed items schema as the oracle fixture', () => {
90
+ const fixture = JSON.parse(readFileSync(new URL('./projections/fixtures/children-typed-items.json', import.meta.url), 'utf8'));
91
+ JsonSchemaGenerator.generate(ScenarioOrder).properties!.items.should.deep.equal(fixture.readModel.schema.properties.items);
92
+ });
93
+
94
+ it('should add and update typed legacy children whose discovered identifier equals the event key (children-identifier-equals-key)', async () => {
95
+ const fixture = JSON.parse(readFileSync(new URL('./projections/fixtures/children-identifier-equals-key.json', import.meta.url), 'utf8'));
96
+ JsonSchemaGenerator.generate(ScenarioKeyedOrder).properties!.items.should.deep.equal(fixture.readModel.schema.properties.items);
97
+ const scenario = new ReadModelScenario(ScenarioKeyedOrder, artifacts);
98
+ for (let step = 0; step < 3; step++) {
99
+ const { itemId, name } = fixture.events[step].content;
100
+ scenario.given.forEventSource('order-1').events(new ScenarioItemAdded(itemId, name));
101
+ const order = await scenario.instanceForEventSourceId('order-1');
102
+ order!.should.deep.equal(fixture.expected[step].publicRead['order-1']);
103
+ }
104
+ });
105
+
106
+ it('should retain only the identifier when the child type is unknown (children-untyped-items)', async () => {
107
+ const scenario = new ReadModelScenario(ScenarioUnknownOrder, artifacts);
108
+ scenario.given.forEventSource('order-1').events(new ScenarioItemAdded('a', 'first'));
109
+ const order = await scenario.instanceForEventSourceId('order-1');
110
+ order!.items.should.deep.equal([{ id: 'a' }]);
111
+ });
112
+
113
+ it('should retain only the identifier when the declared child members have no runtime types (children-untyped-items)', async () => {
114
+ const fixture = JSON.parse(readFileSync(new URL('./projections/fixtures/children-untyped-items.json', import.meta.url), 'utf8'));
115
+ JsonSchemaGenerator.generate(ScenarioUnresolvedOrder).properties!.items.should.deep.equal(fixture.readModel.schema.properties.items);
116
+ const scenario = new ReadModelScenario(ScenarioUnresolvedOrder, artifacts);
117
+ scenario.given.forEventSource('order-1').events(
118
+ new ScenarioItemAdded('a', 'first'), new ScenarioItemAdded('b', 'second'), new ScenarioItemAdded('a', 'again'));
119
+ const order = await scenario.instanceForEventSourceId('order-1');
120
+ order!.items.map(item => ({ id: item.id, name: item.name })).should.deep.equal([{ id: 'a', name: undefined }, { id: 'b', name: undefined }]);
54
121
  });
55
122
 
56
123
  it('should reject explicit child property mappings', () => {