@cratis/chronicle 6.29.0 → 6.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/compliance/for_PIIManager/against_a_kernel/when_erasing_a_subject.integration.spec.ts +132 -0
  2. package/dist/testing/ReactorScenario.d.ts +3 -0
  3. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  4. package/dist/testing/ReactorScenario.js +2 -0
  5. package/dist/testing/ReactorScenario.js.map +1 -1
  6. package/dist/testing/ReadModelScenario.d.ts +11 -0
  7. package/dist/testing/ReadModelScenario.d.ts.map +1 -1
  8. package/dist/testing/ReadModelScenario.js +37 -8
  9. package/dist/testing/ReadModelScenario.js.map +1 -1
  10. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  11. package/dist/testing/projections/ProjectionCapabilities.js +8 -2
  12. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  13. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts +33 -0
  14. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts.map +1 -0
  15. package/dist/testing/projections/ProjectionChildrenCapabilities.js +108 -0
  16. package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -0
  17. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +6 -0
  18. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  19. package/dist/testing/projections/ProjectionReadModelProcessor.js +94 -36
  20. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  21. package/dist/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +2 -1
  23. package/testing/ReactorScenario.ts +2 -0
  24. package/testing/ReadModelScenario.childrenFrom.spec.ts +31 -5
  25. package/testing/ReadModelScenario.composition.spec.ts +111 -0
  26. package/testing/ReadModelScenario.ts +41 -7
  27. package/testing/projections/ProjectionCapabilities.ts +10 -3
  28. package/testing/projections/ProjectionChildrenCapabilities.ts +119 -0
  29. package/testing/projections/ProjectionReadModelProcessor.ts +105 -36
  30. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +73 -0
  31. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +0 -3
  32. package/vitest.config.ts +2 -1
  33. package/vitest.integration.config.ts +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.29.0",
3
+ "version": "6.31.0",
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",
@@ -167,6 +167,7 @@
167
167
  "build": "tsc -b",
168
168
  "clean": "rm -rf dist",
169
169
  "test": "vitest run",
170
+ "test:integration": "vitest run --config vitest.integration.config.ts",
170
171
  "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",
171
172
  "test:native": "node ./scripts/check-native-esm.mjs"
172
173
  },
@@ -140,6 +140,8 @@ export class ReactorScenario {
140
140
  get produced(): readonly unknown[] { return Object.freeze(this._effects.map(effect => effect.value)); }
141
141
  get sideEffects(): readonly RecordedReactorSideEffect[] { return Object.freeze([...this._effects]); }
142
142
  get results(): readonly ReactorDeliveryResult[] { return Object.freeze([...this._results]); }
143
+ /** Committed input and explicit service appends; recorded returned effects are not part of this history. */
144
+ get appendedEvents(): readonly AppendedEvent[] { return this._events.appendedEvents; }
143
145
  /** Assertion view, deliberately not a callable Promise-like `then`. */
144
146
  get then(): { readonly produced: readonly unknown[]; readonly sideEffects: readonly RecordedReactorSideEffect[];
145
147
  readonly results: readonly ReactorDeliveryResult[] } {
@@ -5,13 +5,13 @@ import { chai, describe, it } from 'vitest';
5
5
  import { eventType } from '../events/eventTypeDecorator.js';
6
6
  import { childrenFrom } from '../projections/modelBound/childrenFrom.js';
7
7
  import { fromEvent } from '../projections/modelBound/fromEvent.js';
8
+ import { setFrom } from '../projections/modelBound/setFrom.js';
8
9
  import { ReadModelScenario, UnsupportedProjectionOperation } from './index.js';
9
10
 
10
11
  chai.should();
11
12
 
12
13
  class ScenarioItemAdded {
13
- itemId = '';
14
- name = '';
14
+ constructor(public itemId = '', public name = '') {}
15
15
  }
16
16
  eventType('scenario-keyed-item-added')(ScenarioItemAdded);
17
17
 
@@ -21,15 +21,41 @@ class ScenarioItem {
21
21
  }
22
22
 
23
23
  class ScenarioOrder {
24
+ id = '';
24
25
  items: ScenarioItem[] = [];
25
26
  }
26
27
  childrenFrom(ScenarioItemAdded, { childType: ScenarioItem, key: 'itemId' })(ScenarioOrder.prototype, 'items');
27
28
  fromEvent(ScenarioItemAdded)(ScenarioOrder);
28
29
 
30
+ class ScenarioNamedItem {
31
+ id = '';
32
+ name = '';
33
+ }
34
+ setFrom(ScenarioItemAdded, 'name')(ScenarioNamedItem.prototype, 'name');
35
+
36
+ class ScenarioNamedOrder {
37
+ id = '';
38
+ items: ScenarioNamedItem[] = [];
39
+ }
40
+ childrenFrom(ScenarioItemAdded, { childType: ScenarioNamedItem, key: 'itemId' })(ScenarioNamedOrder.prototype, 'items');
41
+ fromEvent(ScenarioItemAdded)(ScenarioNamedOrder);
42
+
43
+ const artifacts = { eventTypes: [ScenarioItemAdded], reducers: [], projections: [] };
44
+
29
45
  describe('when a read model scenario evaluates keyed children from the options form', () => {
30
- it('should reject children projections explicitly when the scenario is created', () => {
31
- (() => new ReadModelScenario(ScenarioOrder, { eventTypes: [ScenarioItemAdded], reducers: [], projections: [] }))
46
+ it('should add one child per key and update an existing child in place (children-untyped-items)', async () => {
47
+ const scenario = new ReadModelScenario(ScenarioOrder, artifacts);
48
+ scenario.given.forEventSource('order-1').events(
49
+ new ScenarioItemAdded('a', 'first'), new ScenarioItemAdded('b', 'second'), new ScenarioItemAdded('a', 'again'));
50
+ const order = await scenario.instanceForEventSourceId('order-1');
51
+ 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: '' }]);
54
+ });
55
+
56
+ it('should reject explicit child property mappings', () => {
57
+ (() => new ReadModelScenario(ScenarioNamedOrder, artifacts))
32
58
  .should.throw(UnsupportedProjectionOperation)
33
- .with.property('message').that.includes('Children.items (@childrenFrom)').and.includes('children projections');
59
+ .with.property('message').that.includes('Children.items').and.includes('explicit child property mappings');
34
60
  });
35
61
  });
@@ -0,0 +1,111 @@
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 { eventType } from '../events/eventTypeDecorator.js';
7
+ import { fromEvent } from '../projections/modelBound/fromEvent.js';
8
+ import { reactor } from '../reactors/reactor.js';
9
+ import type { ReactorServices } from '../reactors/ReactorServices.js';
10
+ import { reducer } from '../reducers/reducer.js';
11
+ import { EventScenario, ReactorScenario, ReadModelScenario, UnsupportedProjectionOperation } from './index.js';
12
+
13
+ chai.should();
14
+
15
+ @eventType('composition-member-joined')
16
+ class MemberJoined {
17
+ @field(String) name: string;
18
+ constructor(name: string) { this.name = name; }
19
+ }
20
+
21
+ @eventType('composition-member-welcomed')
22
+ class MemberWelcomed {
23
+ @field(String) name: string;
24
+ constructor(name: string) { this.name = name; }
25
+ }
26
+
27
+ @fromEvent(MemberJoined)
28
+ class Member {
29
+ @field(String) id = '';
30
+ @field(String) name = '';
31
+ }
32
+
33
+ class MemberCount { joined = 0; }
34
+ class MemberCountReducer {
35
+ memberJoined(_event: MemberJoined, current: MemberCount | undefined): MemberCount {
36
+ return { joined: (current?.joined ?? 0) + 1 };
37
+ }
38
+ }
39
+ reducer('composition-member-count', undefined, MemberCount)(MemberCountReducer);
40
+
41
+ @fromEvent(MemberWelcomed)
42
+ class Welcome {
43
+ @field(String) id = '';
44
+ @field(String) name = '';
45
+ }
46
+
47
+ @reactor('composition-welcomer')
48
+ class Welcomer {
49
+ async memberJoined(event: MemberJoined, context: { eventSourceId: string }, services: ReactorServices) {
50
+ await services.eventStore.eventLog.append(context.eventSourceId, new MemberWelcomed(event.name));
51
+ return new MemberWelcomed(`returned ${event.name}`);
52
+ }
53
+ }
54
+
55
+ const eventTypes = [MemberJoined, MemberWelcomed];
56
+ const catalog = { eventTypes, reducers: [MemberCountReducer], projections: [] };
57
+
58
+ describe('ReadModelScenario observing an EventScenario', () => {
59
+ it('projects the committed history at the time of each read', async () => {
60
+ const events = new EventScenario({ artifacts: { eventTypes }, constraints: 'disabled' });
61
+ const members = new ReadModelScenario(Member, catalog).observe(events);
62
+ await events.append('member-1', new MemberJoined('Ada'));
63
+ (await members.instanceForEventSourceId('member-1'))!.name.should.equal('Ada');
64
+ await events.appendMany('member-2', [new MemberJoined('Grace')]);
65
+ (await members.instanceForEventSourceId('member-2'))!.name.should.equal('Grace');
66
+ });
67
+
68
+ it('folds setup and action events through a reducer', async () => {
69
+ const events = new EventScenario({ artifacts: { eventTypes }, constraints: 'disabled' });
70
+ const counts = new ReadModelScenario(MemberCount, catalog).observe(events);
71
+ await events.given.forEventSource('club').events(new MemberJoined('Ada'));
72
+ await events.when.forEventSource('club').events(new MemberJoined('Grace'));
73
+ (await counts.instance)!.joined.should.equal(2);
74
+ });
75
+
76
+ it('rejects an explicitly routed event instead of guessing its projection semantics', async () => {
77
+ const events = new EventScenario({ artifacts: { eventTypes }, constraints: 'disabled' });
78
+ const members = new ReadModelScenario(Member, catalog).observe(events);
79
+ await events.appendMany([{ eventSourceId: 'member-1', event: new MemberJoined('Ada'), eventSourceType: 'Members' }]);
80
+ const error = await members.instanceForEventSourceId('member-1').then(() => undefined, (reason: unknown) => reason);
81
+ error.should.be.instanceOf(UnsupportedProjectionOperation);
82
+ (error as Error).message.should.contain('routed events');
83
+ });
84
+
85
+ it('rejects seeding events into an observing scenario', () => {
86
+ const members = new ReadModelScenario(Member, catalog).observe(new EventScenario({ artifacts: { eventTypes }, constraints: 'disabled' }));
87
+ (() => members.given.forEventSource('member-1').events(new MemberJoined('Ada'))).should.throw(UnsupportedProjectionOperation, 'not both');
88
+ });
89
+
90
+ it('rejects observing after seeding or observing twice', () => {
91
+ const events = new EventScenario({ artifacts: { eventTypes }, constraints: 'disabled' });
92
+ const seeded = new ReadModelScenario(Member, catalog);
93
+ seeded.given.forEventSource('member-1').events(new MemberJoined('Ada'));
94
+ (() => seeded.observe(events)).should.throw(UnsupportedProjectionOperation, 'not both');
95
+ const observing = new ReadModelScenario(Member, catalog).observe(events);
96
+ (() => observing.observe(events)).should.throw(UnsupportedProjectionOperation, 'not both');
97
+ });
98
+ });
99
+
100
+ describe('ReadModelScenario observing a ReactorScenario', () => {
101
+ it('reads input events and the reactor\'s explicit appends, but not its recorded returned effects', async () => {
102
+ const reactorScenario = new ReactorScenario(Welcomer, { artifacts: { eventTypes }, constraints: 'disabled' });
103
+ const welcomes = new ReadModelScenario(Welcome, catalog).observe(reactorScenario);
104
+ const members = new ReadModelScenario(Member, catalog).observe(reactorScenario);
105
+ await reactorScenario.when.forEventSource('member-1').events(new MemberJoined('Ada'));
106
+ (await members.instanceForEventSourceId('member-1'))!.name.should.equal('Ada');
107
+ (await welcomes.instanceForEventSourceId('member-1'))!.name.should.equal('Ada');
108
+ reactorScenario.appendedEvents.length.should.equal(2);
109
+ reactorScenario.shouldHaveProduced(MemberWelcomed, welcome => welcome.name === 'returned Ada');
110
+ });
111
+ });
@@ -23,6 +23,8 @@ import type { IReadModelProcessor } from './IReadModelProcessor.js';
23
23
  import type { ReadModelState } from './ReadModelState.js';
24
24
  import { ReducerReadModelProcessor } from './ReducerReadModelProcessor.js';
25
25
  import type { ScenarioEvent } from './ScenarioEvent.js';
26
+ import type { EventScenario } from './EventScenario.js';
27
+ import type { ReactorScenario } from './ReactorScenario.js';
26
28
  import { ProjectionReadModelProcessor } from './projections/ProjectionReadModelProcessor.js';
27
29
  import { UnsupportedProjectionOperation } from './projections/UnsupportedProjectionOperation.js';
28
30
 
@@ -39,6 +41,7 @@ export class ReadModelScenario<TReadModel extends object> {
39
41
  private readonly _modelName: string;
40
42
  private readonly _events: ScenarioEvent[] = [];
41
43
  private _results: Promise<Map<string, ReadModelState<TReadModel>>> | undefined;
44
+ private _observed: EventScenario | ReactorScenario | undefined;
42
45
 
43
46
  /** Selects the reducer (when present) or a single applicable compiled projection. */
44
47
  constructor(readModelType: Constructor<TReadModel>, artifacts?: ScenarioArtifacts) {
@@ -109,8 +112,20 @@ export class ReadModelScenario<TReadModel extends object> {
109
112
  return (await this.process()).get(id)?.deleted ?? false;
110
113
  }
111
114
 
115
+ /**
116
+ * Reads the committed history of an EventScenario or ReactorScenario instead of seeded events.
117
+ * Every read replays that shared sequence as it is at the time of the read.
118
+ */
119
+ observe(source: EventScenario | ReactorScenario): this {
120
+ if (this._observed || this._events.length) throw this.mixedHistory();
121
+ this._observed = source;
122
+ this._results = undefined;
123
+ return this;
124
+ }
125
+
112
126
  /** Collects events for a source; subsequent reads replay the complete seeded history. */
113
127
  collectEventsFor(id: string, events: readonly object[]): void {
128
+ if (this._observed) throw this.mixedHistory();
114
129
  for (const event of events) {
115
130
  const metadata = getEventTypeMetadata(event.constructor);
116
131
  if (!metadata) throw new Error(`Event '${event.constructor.name}' has no @eventType metadata.`);
@@ -139,13 +154,32 @@ export class ReadModelScenario<TReadModel extends object> {
139
154
  }
140
155
 
141
156
  private process(): Promise<Map<string, ReadModelState<TReadModel>>> {
142
- if (!this._results) {
143
- const events = [...this._events];
144
- this._results = this._processor.process(events).catch(error => {
145
- if (!(this._processor instanceof ProjectionReadModelProcessor) || error instanceof UnsupportedProjectionOperation) throw error;
146
- throw new Error(`Projection replay for read model '${this._modelName}' failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
147
- });
148
- }
157
+ if (this._observed) return this.replay(this.observedEvents(this._observed));
158
+ this._results ??= this.replay([...this._events]);
149
159
  return this._results;
150
160
  }
161
+
162
+ private replay(events: ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>> {
163
+ return this._processor.process(events).catch(error => {
164
+ if (!(this._processor instanceof ProjectionReadModelProcessor) || error instanceof UnsupportedProjectionOperation) throw error;
165
+ throw new Error(`Projection replay for read model '${this._modelName}' failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
166
+ });
167
+ }
168
+
169
+ private observedEvents(source: EventScenario | ReactorScenario): ScenarioEvent[] {
170
+ return source.appendedEvents.map(event => {
171
+ const { eventSourceType = 'Default', eventStreamType = 'All', eventStreamId = 'Default' } = event.context;
172
+ // No fixture establishes how the evaluator should treat an explicitly routed event; the kernel decides.
173
+ if (eventSourceType !== 'Default' || eventStreamType !== 'All' || eventStreamId !== 'Default') {
174
+ throw new UnsupportedProjectionOperation(this._modelName, 'observe', `${eventSourceType}/${eventStreamType}/${eventStreamId}`,
175
+ 'observed events must use the default event source type and stream; routed events require a kernel-backed test');
176
+ }
177
+ return { sourceId: event.context.eventSourceId, content: event.content, context: event.context };
178
+ });
179
+ }
180
+
181
+ private mixedHistory(): UnsupportedProjectionOperation {
182
+ return new UnsupportedProjectionOperation(this._modelName, 'observe', 'given.events',
183
+ 'a scenario replays either its own seeded events or one observed event sequence, not both');
184
+ }
151
185
  }
@@ -6,8 +6,9 @@ import { EventSequenceId } from '../../eventSequences/EventSequenceId.js';
6
6
  import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js';
7
7
  import { eventContractPath } from '../../projections/eventContractPath.js';
8
8
  import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
9
- import { getEventTypeMapKey } from '../../projections/modelBound/childrenAndNestedBuilder.js';
9
+ import { getEventTypeMapKey, type ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js';
10
10
  import type { JsonSchema } from '../../schemas/JsonSchema.js';
11
+ import { ProjectionChildrenCapabilities } from './ProjectionChildrenCapabilities.js';
11
12
  import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
12
13
 
13
14
  /** Validates *all* subscribed operations against the bounded scenario subset, before replay. */
@@ -36,11 +37,11 @@ export class ProjectionCapabilities {
36
37
  if (wire.IsActive === false) reject('IsActive', 'passive projections require a kernel-backed test');
37
38
  if (wire.SubscribesToAllEvents === true) reject('SubscribesToAllEvents', 'subscribe-to-all projections require a kernel-backed test');
38
39
  if (wire.EventSequenceId !== EventSequenceId.eventLog.value) reject('EventSequenceId', 'non-default event sequences require a kernel-backed test');
39
- for (const section of ['Join', 'Children', 'Nested', 'RemovedWithJoin'] as const) {
40
+ for (const section of ['Join', 'Nested', 'RemovedWithJoin'] as const) {
40
41
  const value = wire[section];
41
42
  const paths = Array.isArray(value) ? value.map((entry: { Key: ContractEventType }) => eventContractPath(section, entry.Key))
42
43
  : Object.keys(value as Record<string, unknown> ?? {}).map(property => `${section}.${property}`);
43
- if (paths.length) reject(paths[0], `${section === 'Children' ? 'children projections' : section === 'Nested' ? 'nested projections' : section === 'Join' ? 'joins' : 'removedWithJoin'} require a kernel-backed test`);
44
+ if (paths.length) reject(paths[0], `${section === 'Nested' ? 'nested projections' : section === 'Join' ? 'joins' : 'removedWithJoin'} require a kernel-backed test`);
44
45
  }
45
46
  if ((wire.FromEvery as unknown[] | undefined)?.length) reject('FromEvery', 'derivative projections require a kernel-backed test');
46
47
  if (wire.FromEventProperty) reject('FromEventProperty', 'event-property subscriptions require a kernel-backed test');
@@ -110,6 +111,12 @@ export class ProjectionCapabilities {
110
111
  this.checkAutoMap(schema!, eventSchema, properties, wire.NoAutoMapProperties as string[] ?? [], path, reject);
111
112
  }
112
113
  }
114
+ ProjectionChildrenCapabilities.validate(wire.Children as Record<string, ChildrenDefinitionLike> ?? {}, {
115
+ schema: schema!, rootAutoMap: wire.AutoMap as AutoMap, hasInitialModelState: Object.keys(initial as object).length > 0, from, removedWith, requireEventSchema, reject,
116
+ declarationFor: path => provenance.find(entry => entry.contractPath === path)?.declaration,
117
+ checkMapping: (modelSchema, eventSchema, destination, expression, path) =>
118
+ this.checkMapping(modelSchema, eventSchema, destination, expression, path, reject)
119
+ });
113
120
  }
114
121
 
115
122
  private static checkAutoMap(
@@ -0,0 +1,119 @@
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 { AutoMap } from '@cratis/chronicle.contracts';
5
+ import { eventContractPath } from '../../projections/eventContractPath.js';
6
+ import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
7
+ import type { ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js';
8
+ import type { JsonSchema } from '../../schemas/JsonSchema.js';
9
+
10
+ /** Rejects an operation at a contract path, optionally naming the responsible declaration. */
11
+ export type RejectOperation = (path: string, reason: string, fallback?: string) => never;
12
+
13
+ /** Validates a scalar mapping into a target schema, rejecting anything outside the fixture-backed subset. */
14
+ export type CheckMapping = (modelSchema: JsonSchema, eventSchema: JsonSchema, destination: string, expression: string, path: string) => void;
15
+
16
+ /** The inputs children validation shares with root validation. */
17
+ export interface ChildrenValidationContext {
18
+ readonly schema: JsonSchema;
19
+ readonly rootAutoMap: AutoMap;
20
+ readonly hasInitialModelState: boolean;
21
+ readonly from: readonly FromRecord[];
22
+ readonly removedWith: readonly RemovedWithRecord[];
23
+ readonly declarationFor: (path: string) => string | undefined;
24
+ readonly requireEventSchema: (eventType: ContractEventType, path: string) => JsonSchema;
25
+ readonly checkMapping: CheckMapping;
26
+ readonly reject: RejectOperation;
27
+ }
28
+
29
+ const kernelBacked = 'require a kernel-backed test (ChronicleKernelScenario / live kernel)';
30
+
31
+ /**
32
+ * Validates one level of children collections against the packaged-kernel fixtures
33
+ * (`children-from-keyed`, `children-identified-removed`, `children-untyped-items`).
34
+ */
35
+ export class ProjectionChildrenCapabilities {
36
+ /**
37
+ * Throws for the first children operation outside the fixture-backed subset.
38
+ * @param children - The wire `Children` section of the root definition.
39
+ * @param context - Shared root validation state.
40
+ */
41
+ static validate(children: Record<string, ChildrenDefinitionLike>, context: ChildrenValidationContext): void {
42
+ if (Object.keys(children).length && context.hasInitialModelState) {
43
+ context.reject('InitialModelState', `children with initial model state ${kernelBacked}`);
44
+ }
45
+ const owners = new Map<string, string>();
46
+ for (const [property, child] of Object.entries(children)) {
47
+ const base = `Children.${property}`;
48
+ const declaration = context.declarationFor(base);
49
+ const reject = (path: string, reason: string): never => context.reject(path, reason, declaration);
50
+ const wire = child as unknown as Record<string, unknown>;
51
+ if ((child.Join ?? []).length) reject(`${base}.Join`, 'joins require a kernel-backed test');
52
+ if (Object.keys(child.Children ?? {}).length) reject(`${base}.Children`, `nested children collections ${kernelBacked}`);
53
+ if (Object.keys(child.Nested ?? {}).length) reject(`${base}.Nested`, `nested projections inside children ${kernelBacked}`);
54
+ if ((child.RemovedWithJoin ?? []).length) reject(`${base}.RemovedWithJoin`, `removedWithJoin inside children ${kernelBacked}`);
55
+ if (wire.FromEventProperty) reject(`${base}.FromEventProperty`, `value children from an event property ${kernelBacked}`);
56
+ if (Object.keys(child.All?.Properties ?? {}).length || child.All?.IncludeChildren || (child.All?.AutoMap ?? AutoMap.Inherit) !== AutoMap.Inherit) {
57
+ reject(`${base}.All`, `fromEvery/all inside children ${kernelBacked}`);
58
+ }
59
+ if ((child.NoAutoMapProperties ?? []).length) reject(`${base}.NoAutoMapProperties`, `child AutoMap exclusions ${kernelBacked}`);
60
+ const autoMap = child.AutoMap === AutoMap.Inherit ? context.rootAutoMap : child.AutoMap;
61
+ if (autoMap === AutoMap.Disabled) reject(`${base}.AutoMap`, `children with AutoMap disabled ${kernelBacked}`);
62
+
63
+ const collection = context.schema.properties?.[property];
64
+ const items = collection?.items;
65
+ if (collection?.type !== 'array' || items?.type !== 'object') {
66
+ reject(base, `children collections must target an array of objects; other targets ${kernelBacked}`);
67
+ }
68
+ const typed = items!.properties !== undefined;
69
+ const identifiedBy = child.IdentifiedBy;
70
+ if (!identifiedBy || identifiedBy === '*NotSet*' || identifiedBy.includes('.')) {
71
+ reject(`${base}.IdentifiedBy`, `children without a direct identifying property ${kernelBacked}`);
72
+ }
73
+ const identity = items!.properties?.[identifiedBy];
74
+ if (typed && (identity?.type !== 'string' || identity.format)) {
75
+ reject(`${base}.IdentifiedBy`, `only a string child identifier is fixture-backed; other identifiers ${kernelBacked}`);
76
+ }
77
+
78
+ for (const entry of [...(child.From ?? []), ...(child.RemovedWith ?? [])]) {
79
+ const section = (child.From ?? []).includes(entry as FromRecord) ? 'From' : 'RemovedWith';
80
+ const path = `${base}.${eventContractPath(section, entry.Key)}`;
81
+ const owner = owners.get(entry.Key.Id);
82
+ if (owner) reject(path, `an event subscribed by more than one children operation (${owner}) ${kernelBacked}`);
83
+ owners.set(entry.Key.Id, path);
84
+ if (context.removedWith.some(removal => removal.Key.Id === entry.Key.Id) ||
85
+ (section === 'RemovedWith' && context.from.some(root => root.Key.Id === entry.Key.Id))) {
86
+ reject(path, `an event that both removes and changes the parent or a child ${kernelBacked}`);
87
+ }
88
+ const eventSchema = context.requireEventSchema(entry.Key, path);
89
+ const key = eventSchema.properties?.[entry.Value.Key];
90
+ if (!/^[A-Za-z_]\w*$/.test(entry.Value.Key) || key?.type !== 'string' || key.format) {
91
+ reject(`${path}.Key`, `only a child key read from a string event property is fixture-backed; other child keys ${kernelBacked}`);
92
+ }
93
+ if (entry.Value.ParentKey && entry.Value.ParentKey !== '$eventSourceId') {
94
+ reject(`${path}.ParentKey`, `parent keys other than the event source id ${kernelBacked}`);
95
+ }
96
+ if (section === 'RemovedWith') continue;
97
+ const properties = (entry as FromRecord).Value.Properties ?? {};
98
+ for (const [destination, expression] of Object.entries(properties)) {
99
+ const mappingPath = `${path}.Properties.${destination}`;
100
+ if (destination === identifiedBy) {
101
+ if (expression !== entry.Value.Key) reject(mappingPath, `mapping the child identifier from anything but the child key ${kernelBacked}`);
102
+ continue;
103
+ }
104
+ reject(mappingPath, `explicit child property mappings (renames, event context, constants, nulls) ${kernelBacked}; only same-name AutoMap into typed children is supported`);
105
+ }
106
+ if (!typed) continue;
107
+ for (const destination of Object.keys(items!.properties ?? {})) {
108
+ if (Object.keys(properties).some(name => name.toLowerCase() === destination.toLowerCase())) continue;
109
+ const candidates = Object.keys(eventSchema.properties ?? {}).filter(source => source.toLowerCase() === destination.toLowerCase());
110
+ if (!candidates.length) continue;
111
+ const mappingPath = `${path}.AutoMap.${destination}`;
112
+ if (destination === identifiedBy) reject(mappingPath, `AutoMap into the child identifier ${kernelBacked}`);
113
+ if (candidates.length > 1) reject(mappingPath, `inferred AutoMap source is ambiguous: ${candidates.join(', ')}`);
114
+ context.checkMapping(items!, eventSchema, destination, candidates[0], mappingPath);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }