@cratis/chronicle 6.30.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 (22) hide show
  1. package/compliance/for_PIIManager/against_a_kernel/when_erasing_a_subject.integration.spec.ts +132 -0
  2. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  3. package/dist/testing/projections/ProjectionCapabilities.js +8 -2
  4. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  5. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts +33 -0
  6. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts.map +1 -0
  7. package/dist/testing/projections/ProjectionChildrenCapabilities.js +108 -0
  8. package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -0
  9. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +6 -0
  10. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  11. package/dist/testing/projections/ProjectionReadModelProcessor.js +94 -36
  12. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  13. package/dist/tsconfig.tsbuildinfo +1 -1
  14. package/package.json +2 -1
  15. package/testing/ReadModelScenario.childrenFrom.spec.ts +31 -5
  16. package/testing/projections/ProjectionCapabilities.ts +10 -3
  17. package/testing/projections/ProjectionChildrenCapabilities.ts +119 -0
  18. package/testing/projections/ProjectionReadModelProcessor.ts +105 -36
  19. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +73 -0
  20. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +0 -3
  21. package/vitest.config.ts +2 -1
  22. package/vitest.integration.config.ts +19 -0
@@ -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
+ }
@@ -6,6 +6,7 @@ import type { Constructor } from '@cratis/fundamentals';
6
6
  import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js';
7
7
  import { eventContractPath } from '../../projections/eventContractPath.js';
8
8
  import type { FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
9
+ import type { ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js';
9
10
  import { deserializeReadModel } from '../../readModels/deserializeReadModel.js';
10
11
  import type { JsonSchema } from '../../schemas/JsonSchema.js';
11
12
  import type { IReadModelProcessor } from '../IReadModelProcessor.js';
@@ -16,12 +17,21 @@ import { ProjectionExpressionEvaluator } from './ProjectionExpressionEvaluator.j
16
17
  import { ProjectionValueConverter } from './ProjectionValueConverter.js';
17
18
  import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
18
19
 
20
+ interface ChildBinding {
21
+ readonly property: string;
22
+ readonly identifiedBy: string;
23
+ readonly key: string;
24
+ readonly from: FromRecord | undefined;
25
+ readonly items: JsonSchema;
26
+ }
27
+
19
28
  /** In-process interpreter of the validated, registered projection contract subset. */
20
29
  export class ProjectionReadModelProcessor<TReadModel extends object> implements IReadModelProcessor<TReadModel> {
21
30
  private readonly _schema: JsonSchema;
22
31
  private readonly _initial: Record<string, unknown>;
23
32
  private readonly _from: ReadonlyMap<string, FromRecord>;
24
33
  private readonly _removed: ReadonlySet<string>;
34
+ private readonly _children: ReadonlyMap<string, ChildBinding>;
25
35
  private readonly _eventSchemas: ReadonlyMap<string, JsonSchema>;
26
36
  private readonly _bindings: ReadonlyMap<string, { generation: number; path: string; declaration: string }>;
27
37
  private _engineState: Record<string, Record<string, unknown>> = {};
@@ -32,15 +42,29 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
32
42
  const registration = compiled.readModels.find(entry => entry.Type.Identifier === _definition.ReadModel)!;
33
43
  this._schema = JSON.parse(registration.Schema) as JsonSchema;
34
44
  this._initial = JSON.parse(_definition.InitialModelState || '{}') as Record<string, unknown>;
35
- const wire = _definition as unknown as { From: FromRecord[]; RemovedWith: RemovedWithRecord[] };
45
+ const wire = _definition as unknown as { From: FromRecord[]; RemovedWith: RemovedWithRecord[]; Children?: Record<string, ChildrenDefinitionLike> };
36
46
  this._from = new Map((wire.From ?? []).map(entry => [entry.Key.Id, entry]));
37
47
  this._removed = new Set((wire.RemovedWith ?? []).map(entry => entry.Key.Id));
38
- this._bindings = new Map([...(wire.From ?? []), ...(wire.RemovedWith ?? [])].map(entry => {
48
+ const declarationFor = (path: string) => compiled.provenance.get(_definition)?.find(provenance => provenance.contractPath === path)?.declaration;
49
+ const bindings = [...(wire.From ?? []), ...(wire.RemovedWith ?? [])].map(entry => {
39
50
  const section = (wire.From ?? []).includes(entry as FromRecord) ? 'From' : 'RemovedWith';
40
51
  const path = eventContractPath(section, entry.Key);
41
- const declaration = compiled.provenance.get(_definition)?.find(provenance => provenance.contractPath === path)?.declaration ?? 'contract';
42
- return [entry.Key.Id, { generation: entry.Key.Generation, path, declaration }];
43
- }));
52
+ return [entry.Key.Id, { generation: entry.Key.Generation, path, declaration: declarationFor(path) ?? 'contract' }] as const;
53
+ });
54
+ const children = new Map<string, ChildBinding>();
55
+ for (const [property, child] of Object.entries(wire.Children ?? {})) {
56
+ const items = this._schema.properties![property].items!;
57
+ for (const entry of [...child.From, ...child.RemovedWith]) {
58
+ const from = child.From.includes(entry as FromRecord) ? entry as FromRecord : undefined;
59
+ const path = `Children.${property}.${eventContractPath(from ? 'From' : 'RemovedWith', entry.Key)}`;
60
+ children.set(entry.Key.Id, { property, identifiedBy: child.IdentifiedBy, key: entry.Value.Key, from, items });
61
+ if (!bindings.some(([id]) => id === entry.Key.Id)) {
62
+ bindings.push([entry.Key.Id, { generation: entry.Key.Generation, path, declaration: declarationFor(`Children.${property}`) ?? 'contract' }]);
63
+ }
64
+ }
65
+ }
66
+ this._children = children;
67
+ this._bindings = new Map(bindings);
44
68
  this._eventSchemas = new Map([...(compiled.eventSchemas.get(_definition) ?? new Map()).values()]
45
69
  .map(entry => [entry.eventType.Id, entry.schema]));
46
70
  }
@@ -57,41 +81,36 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
57
81
  const typeId = event.context.eventType.id.value;
58
82
  const from = this._from.get(typeId);
59
83
  const removed = this._removed.has(typeId);
60
- if (!from && !removed) continue;
84
+ const child = this._children.get(typeId);
85
+ if (!from && !removed && !child) continue;
61
86
  const binding = this._bindings.get(typeId)!;
62
87
  if (event.context.eventType.generation.value !== binding.generation) {
63
88
  throw new UnsupportedProjectionOperation(String(this._definition.ReadModel), binding.path, binding.declaration,
64
89
  `seeded event generation ${event.context.eventType.generation.value} differs from subscribed generation ${binding.generation}; multi-generation history requires a kernel-backed test`);
65
90
  }
66
- // A root removal has the final say when an event is registered for both operations.
67
91
  const key = this.keyFor(event.sourceId);
68
92
  if (removed) {
69
93
  delete engine[key];
70
94
  states.set(event.sourceId, { instance: null, deleted: true });
71
95
  continue;
72
96
  }
73
- if (!from) continue;
74
- const state = engine[key] ?? this.initialState(key, event);
75
- const properties = { ...from.Value.Properties };
76
- const schema = this._eventSchemas.get(typeId);
77
- const content = ProjectionValueConverter.eventContent(event.content, schema!);
78
- if (this._definition.AutoMap !== AutoMap.Disabled && schema) {
79
- const explicit = new Set(Object.keys(properties).map(name => name.toLowerCase()));
80
- const excluded = new Set((this._definition.NoAutoMapProperties ?? []).map(name => name.toLowerCase()));
81
- for (const source of Object.keys(schema.properties ?? {})) {
82
- const destination = Object.keys(this._schema.properties ?? {}).find(name => name.toLowerCase() === source.toLowerCase());
83
- if (destination && !explicit.has(destination.toLowerCase()) && !excluded.has(destination.toLowerCase())) {
84
- properties[destination] = source;
85
- explicit.add(destination.toLowerCase());
86
- }
87
- }
97
+ const schema = this._eventSchemas.get(typeId)!;
98
+ const content = ProjectionValueConverter.eventContent(event.content, schema);
99
+ let state = engine[key];
100
+ if (!state) {
101
+ // SetInitialState: an event only a child subscribes to creates an uninitialized parent.
102
+ state = from ? this.initialState(key, event) : this.identityState(key, event, false);
103
+ } else if (state.__initialized === false) {
104
+ for (const [name, value] of Object.entries(this.initialValues())) if (!(name in state)) state[name] = value;
105
+ state.__initialized = true;
88
106
  }
89
- for (const [destination, expression] of Object.entries(properties)) {
90
- const target = this._schema.properties![destination];
91
- const value = ProjectionExpressionEvaluator.value(expression, content, event.context, target);
92
- // The kernel compares old and new null values; null on an absent member is no change.
93
- if (value !== null || state[destination] !== undefined) state[destination] = value;
107
+ if (from) {
108
+ const explicit = { ...from.Value.Properties };
109
+ const excluded = (this._definition.NoAutoMapProperties ?? []);
110
+ const properties = this._definition.AutoMap !== AutoMap.Disabled ? this.withAutoMap(explicit, schema, this._schema, excluded) : explicit;
111
+ this.apply(properties, content, event, this._schema, state);
94
112
  }
113
+ if (child) this.applyChild(child, content, event, schema, state, binding);
95
114
  // InMemorySink.ApplyChanges always restores lowercase id after applying mappings.
96
115
  state.id = ProjectionValueConverter.convert(key, this._schema.properties!.id);
97
116
  engine[key] = state;
@@ -112,22 +131,72 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
112
131
  return canonical;
113
132
  }
114
133
 
115
- private initialState(key: string, event: ScenarioEvent): Record<string, unknown> {
134
+ private applyChild(child: ChildBinding, content: unknown, event: ScenarioEvent, eventSchema: JsonSchema, state: Record<string, unknown>,
135
+ binding: { path: string; declaration: string }): void {
136
+ const identity = ProjectionExpressionEvaluator.pathValue(content, child.key);
137
+ if (typeof identity !== 'string') {
138
+ throw new UnsupportedProjectionOperation(String(this._definition.ReadModel), binding.path, binding.declaration,
139
+ `child key '${child.key}' has no string value in the seeded event; missing child keys require a kernel-backed test`);
140
+ }
141
+ const collection = (state[child.property] ??= []) as Record<string, unknown>[];
142
+ const index = collection.findIndex(item => item[child.identifiedBy] === identity);
143
+ if (!child.from) {
144
+ if (index >= 0) collection.splice(index, 1);
145
+ return;
146
+ }
147
+ const item = index >= 0 ? collection[index] : { [child.identifiedBy]: identity };
148
+ if (index < 0) collection.push(item);
149
+ // Validation admits only the identifier mapping explicitly; other fields come from same-name AutoMap.
150
+ // Kernel AutoMap is schema-driven: an untyped child item schema maps nothing (children-untyped-items).
151
+ const properties = child.items.properties ? this.withAutoMap({}, eventSchema, child.items, [child.identifiedBy]) : {};
152
+ this.apply(properties, content, event, child.items, item);
153
+ }
154
+
155
+ private withAutoMap(explicit: Record<string, string>, eventSchema: JsonSchema, target: JsonSchema, exclusions: readonly string[]): Record<string, string> {
156
+ const properties = { ...explicit };
157
+ const mapped = new Set(Object.keys(properties).map(name => name.toLowerCase()));
158
+ const excluded = new Set(exclusions.map(name => name.toLowerCase()));
159
+ for (const source of Object.keys(eventSchema.properties ?? {})) {
160
+ const destination = Object.keys(target.properties ?? {}).find(name => name.toLowerCase() === source.toLowerCase());
161
+ if (destination && !mapped.has(destination.toLowerCase()) && !excluded.has(destination.toLowerCase())) {
162
+ properties[destination] = source;
163
+ mapped.add(destination.toLowerCase());
164
+ }
165
+ }
166
+ return properties;
167
+ }
168
+
169
+ private apply(properties: Record<string, string>, content: unknown, event: ScenarioEvent, target: JsonSchema, state: Record<string, unknown>): void {
170
+ for (const [destination, expression] of Object.entries(properties)) {
171
+ const value = ProjectionExpressionEvaluator.value(expression, content, event.context, target.properties![destination]);
172
+ // The kernel compares old and new null values; null on an absent member is no change.
173
+ if (value !== null || state[destination] !== undefined) state[destination] = value;
174
+ }
175
+ }
176
+
177
+ private initialValues(): Record<string, unknown> {
116
178
  const initial: Record<string, unknown> = Object.create(null);
117
179
  if (Object.keys(this._initial).length) {
118
- Object.assign(initial, ProjectionValueConverter.convert(structuredClone(this._initial), this._schema, false, false));
180
+ return Object.assign(initial, ProjectionValueConverter.convert(structuredClone(this._initial), this._schema, false, false));
119
181
  }
120
- if (!Object.keys(this._initial).length) {
121
- for (const [name, property] of Object.entries(this._schema.properties ?? {})) {
122
- if (property.type === 'array') initial[name] = [];
123
- }
182
+ for (const [name, property] of Object.entries(this._schema.properties ?? {})) {
183
+ if (property.type === 'array') initial[name] = [];
124
184
  }
125
- initial.id = ProjectionValueConverter.convert(key, this._schema.properties!.id);
126
- initial.__subject = event.context.subject ?? event.sourceId;
127
- initial.__initialized = true;
128
185
  return initial;
129
186
  }
130
187
 
188
+ private initialState(key: string, event: ScenarioEvent): Record<string, unknown> {
189
+ return Object.assign(this.initialValues(), this.identityState(key, event, true));
190
+ }
191
+
192
+ private identityState(key: string, event: ScenarioEvent, initialized: boolean): Record<string, unknown> {
193
+ const state: Record<string, unknown> = Object.create(null);
194
+ state.id = ProjectionValueConverter.convert(key, this._schema.properties!.id);
195
+ state.__subject = event.context.subject ?? event.sourceId;
196
+ state.__initialized = initialized;
197
+ return state;
198
+ }
199
+
131
200
  private normalize(state: Record<string, unknown>): Record<string, unknown> {
132
201
  const result: Record<string, unknown> = Object.create(null);
133
202
  for (const [name, schema] of Object.entries(this._schema.properties ?? {})) {
@@ -0,0 +1,73 @@
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 { AutoMap } from '@cratis/chronicle.contracts';
6
+ import { field } from '@cratis/fundamentals';
7
+ import { chai, describe, it } from 'vitest';
8
+ import { eventType } from '../../../events/eventTypeDecorator.js';
9
+ import type { ChildrenDefinitionLike } from '../../../projections/modelBound/childrenAndNestedBuilder.js';
10
+ import { childrenFrom } from '../../../projections/modelBound/childrenFrom.js';
11
+ import { ProjectionCapabilities } from '../ProjectionCapabilities.js';
12
+ import { UnsupportedProjectionOperation } from '../UnsupportedProjectionOperation.js';
13
+ import { Changed } from './given/Changed.js';
14
+ import { compileDeclarative, compileModelBound } from './given/compile.js';
15
+
16
+ chai.should();
17
+
18
+ class ChildAdded { itemId!: string; name!: string; }
19
+ field(String)(ChildAdded.prototype, 'itemId');
20
+ field(String)(ChildAdded.prototype, 'name');
21
+ eventType('capability-child-added')(ChildAdded);
22
+
23
+ type Wire = { Children: Record<string, ChildrenDefinitionLike>; RemovedWith: unknown[] };
24
+
25
+ function compileChildren() {
26
+ const result = compileDeclarative(builder => {
27
+ builder.from(Changed).children<{ lineId: string }>(model => model.labels as never, child => child
28
+ .identifiedBy(line => line.lineId)
29
+ .from(ChildAdded, from => from.usingKey(event => event.itemId)));
30
+ }, [ChildAdded]);
31
+ const wire = result.definition as unknown as Wire;
32
+ return { ...result, wire, child: wire.Children.labels };
33
+ }
34
+
35
+ describe('when rejecting unsupported children operations before any event is seeded', () => {
36
+ it('should accept the fixture-backed keyed children shape', () => {
37
+ const { compiled, definition } = compileChildren();
38
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.not.throw();
39
+ });
40
+
41
+ const cases: Array<{ name: string; change: (child: ChildrenDefinitionLike, wire: Wire) => void; path: string; reason: string }> = [
42
+ { name: 'a join inside children', change: child => { child.Join = [{}]; }, path: 'Children.labels.Join', reason: 'joins require a kernel-backed test' },
43
+ { name: 'nested children collections', change: child => { child.Children = { inner: child }; }, path: 'Children.labels.Children', reason: 'nested children collections' },
44
+ { name: 'nested projections inside children', change: child => { child.Nested = { inner: child }; }, path: 'Children.labels.Nested', reason: 'nested projections inside children' },
45
+ { name: 'removedWithJoin inside children', change: child => { child.RemovedWithJoin = [child.RemovedWithJoin[0] ?? { Key: child.From[0].Key, Value: { Key: '' } }]; }, path: 'Children.labels.RemovedWithJoin', reason: 'removedWithJoin inside children' },
46
+ { name: 'value children from an event property', change: child => { (child as unknown as Record<string, unknown>).FromEventProperty = { PropertyExpression: 'name' }; }, path: 'Children.labels.FromEventProperty', reason: 'value children from an event property' },
47
+ { name: 'disabled child AutoMap', change: child => { child.AutoMap = AutoMap.Disabled; }, path: 'Children.labels.AutoMap', reason: 'AutoMap disabled' },
48
+ { name: 'a child without an identifying property', change: child => { child.IdentifiedBy = '*NotSet*'; }, path: 'Children.labels.IdentifiedBy', reason: 'without a direct identifying property' },
49
+ { name: 'an event-source-id child key', change: child => { child.From[0].Value.Key = '$eventSourceId'; }, path: 'Children.labels.From[capability-child-added:1].Key', reason: 'only a child key read from a string event property' },
50
+ { name: 'an event-property parent key', change: child => { child.From[0].Value.ParentKey = 'itemId'; }, path: 'Children.labels.From[capability-child-added:1].ParentKey', reason: 'parent keys other than the event source id' },
51
+ { name: 'an explicit mapping into a child', change: child => { child.From[0].Value.Properties = { name: 'name' }; }, path: 'Children.labels.From[capability-child-added:1].Properties.name', reason: 'explicit child property mappings' },
52
+ { name: 'a renamed child mapping', change: child => { child.From[0].Value.Properties = { title: 'name' }; }, path: 'Children.labels.From[capability-child-added:1].Properties.title', reason: 'only same-name AutoMap into typed children' },
53
+ { name: 'a child mapping from the event context', change: child => { child.From[0].Value.Properties = { name: '$eventContext(occurred)' }; }, path: 'Children.labels.From[capability-child-added:1].Properties.name', reason: 'explicit child property mappings' },
54
+ { name: 'children combined with initial model state', change: (_child, wire) => { (wire as unknown as Record<string, unknown>).InitialModelState = '{"name":"initial"}'; }, path: 'InitialModelState', reason: 'children with initial model state' },
55
+ { name: 'a child identifier mapped from another value', change: child => { child.From[0].Value.Properties = { lineId: 'name' }; }, path: 'Children.labels.From[capability-child-added:1].Properties.lineId', reason: 'mapping the child identifier from anything but the child key' },
56
+ { name: 'a child event that also removes the parent', change: (child, wire) => { wire.RemovedWith = [{ Key: child.From[0].Key, Value: { Key: '$eventSourceId', ParentKey: '' } }]; }, path: 'Children.labels.From[capability-child-added:1]', reason: 'both removes and changes' },
57
+ { name: 'one event that both adds and removes a child', change: child => { child.RemovedWith = [{ Key: child.From[0].Key, Value: { Key: 'itemId', ParentKey: '' } }]; }, path: 'Children.labels.RemovedWith[capability-child-added:1]', reason: 'more than one children operation' }
58
+ ];
59
+ for (const testCase of cases) {
60
+ it(`should reject ${testCase.name} with its contract path and reason`, () => {
61
+ const { compiled, definition, wire, child } = compileChildren();
62
+ testCase.change(child, wire);
63
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
64
+ .with.property('message').that.includes(testCase.path).and.includes(testCase.reason);
65
+ });
66
+ }
67
+
68
+ it('should reject a children collection that does not target an array of objects', () => {
69
+ const { compiled, definition } = compileModelBound(model => childrenFrom(ChildAdded, { key: 'itemId' })(model.prototype, 'name'), [ChildAdded]);
70
+ (() => ProjectionCapabilities.validate(compiled, definition)).should.throw(UnsupportedProjectionOperation)
71
+ .with.property('message').that.includes('Children.name (@childrenFrom)').and.includes('must target an array of objects');
72
+ });
73
+ });
@@ -6,7 +6,6 @@ import { field } from '@cratis/fundamentals';
6
6
  import { chai, describe, it } from 'vitest';
7
7
  import { eventType } from '../../../events/eventTypeDecorator.js';
8
8
  import type { IProjectionBuilderFor } from '../../../projections/declarative/IProjectionBuilderFor.js';
9
- import { childrenFrom } from '../../../projections/modelBound/childrenFrom.js';
10
9
  import { entersOn } from '../../../projections/modelBound/entersOn.js';
11
10
  import { eventSequence } from '../../../projections/modelBound/eventSequence.js';
12
11
  import { fromEvent } from '../../../projections/modelBound/fromEvent.js';
@@ -30,7 +29,6 @@ chai.should();
30
29
 
31
30
  describe('when rejecting unsupported operations before any event is seeded', () => {
32
31
  const modelBound = [
33
- { name: 'children', configure: (model: Function) => childrenFrom(Removed)(model.prototype, 'name'), path: 'Children.name (@childrenFrom)', reason: 'children projections' },
34
32
  { name: 'nested', configure: (model: Function) => nested(model.prototype, 'name'), path: 'Nested.name (@nested)', reason: 'nested projections' },
35
33
  { name: 'join', configure: (model: Function) => join(Removed, 'name')(model.prototype, 'name'), path: 'Join[capability-removed:1] (@join)', reason: 'joins' },
36
34
  { name: 'removedWithJoin', configure: (model: Function) => removedWithJoin(Removed)(model), path: 'RemovedWithJoin[capability-removed:1] (@removedWithJoin)', reason: 'removedWithJoin' },
@@ -77,7 +75,6 @@ describe('when rejecting unsupported operations before any event is seeded', ()
77
75
  { name: 'composite key', define: builder => { builder.from(Changed, from => from.usingCompositeKey<{ name: string }>(key => key.set(target => target.name, event => event.name))); }, path: 'From[capability-changed:1].Key (.from().usingCompositeKey)' },
78
76
  { name: 'parent key', define: builder => { builder.from(Changed, from => from.usingParentKey(event => event.name)); }, path: 'From[capability-changed:1].ParentKey (.from().usingParentKey)' },
79
77
  { name: 'join', define: builder => { builder.from(Changed).join(Removed, join => join.on(model => model.id)); }, path: 'Join[capability-removed:1] (.join)' },
80
- { name: 'children', define: builder => { builder.from(Changed).children(model => model.labels, child => child.from(Removed)); }, path: 'Children.labels (.children)' },
81
78
  { name: 'nested', define: builder => { builder.from(Changed).nested(model => model.details, child => child.clearWith(Removed)); }, path: 'Nested.details (.nested)' },
82
79
  { name: 'removedWithJoin', define: builder => { builder.from(Changed).removedWithJoin(Removed); }, path: 'RemovedWithJoin[capability-removed:1] (.removedWithJoin)' },
83
80
  { name: 'removal key', define: builder => { builder.from(Removed).removedWith(Changed, removal => removal.usingKey(event => event.name)); }, path: 'RemovedWith[capability-changed:1].Key (.removedWith)' },
package/vitest.config.ts CHANGED
@@ -23,7 +23,8 @@ export default defineConfig({
23
23
  }],
24
24
  test: {
25
25
  include: ['**/*.spec.ts', '**/*.spec.js'],
26
- exclude: ['node_modules', 'dist'],
26
+ // Kernel-backed specifications run through vitest.integration.config.ts.
27
+ exclude: ['node_modules', 'dist', '**/*.integration.spec.ts'],
27
28
  environment: 'node'
28
29
  }
29
30
  });
@@ -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 { defineConfig } from 'vitest/config';
5
+ import baseConfig from './vitest.config.js';
6
+
7
+ // Kernel-backed specifications. They need a running Chronicle kernel and are skipped unless
8
+ // CHRONICLE_INTEGRATION_CONNECTION_STRING points at one, for example
9
+ // chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000
10
+ export default defineConfig({
11
+ ...baseConfig,
12
+ test: {
13
+ include: ['**/*.integration.spec.ts'],
14
+ exclude: ['node_modules', 'dist'],
15
+ environment: 'node',
16
+ testTimeout: 60_000,
17
+ hookTimeout: 60_000
18
+ }
19
+ });