@cratis/chronicle 6.30.0 → 6.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/compliance/for_PIIManager/against_a_kernel/when_erasing_a_subject.integration.spec.ts +132 -0
- package/connection/ChronicleConnection.ts +1 -0
- package/connection/encodeExplicitFalse.ts +56 -0
- package/connection/for_encodeExplicitFalse/when_encoding_reactor_definition.spec.ts +34 -0
- package/connection/for_encodeExplicitFalse/when_encoding_webhook_definition.spec.ts +39 -0
- package/connection/for_encodeExplicitFalse/wireFields.ts +24 -0
- package/dist/connection/ChronicleConnection.d.ts +1 -0
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +1 -0
- package/dist/connection/ChronicleConnection.js.map +1 -1
- package/dist/connection/encodeExplicitFalse.d.ts +6 -0
- package/dist/connection/encodeExplicitFalse.d.ts.map +1 -0
- package/dist/connection/encodeExplicitFalse.js +45 -0
- package/dist/connection/encodeExplicitFalse.js.map +1 -0
- package/dist/connection/for_encodeExplicitFalse/wireFields.d.ts +6 -0
- package/dist/connection/for_encodeExplicitFalse/wireFields.d.ts.map +1 -0
- package/dist/connection/for_encodeExplicitFalse/wireFields.js +24 -0
- package/dist/connection/for_encodeExplicitFalse/wireFields.js.map +1 -0
- package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.js +8 -2
- package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
- package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts +33 -0
- package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionChildrenCapabilities.js +108 -0
- package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +6 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.js +94 -36
- package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/events/constraints/for_Constraints/against_a_kernel/when_appending_unique_values_concurrently.integration.spec.ts +189 -0
- package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +156 -0
- package/jobs/for_Jobs/against_a_kernel/when_managing_a_replay_job.integration.spec.ts +114 -0
- package/package.json +2 -1
- package/reactors/for_Reactors/against_a_kernel/when_replaying_once_only_reactors.integration.spec.ts +111 -0
- package/testing/ReadModelScenario.childrenFrom.spec.ts +31 -5
- package/testing/projections/ProjectionCapabilities.ts +10 -3
- package/testing/projections/ProjectionChildrenCapabilities.ts +119 -0
- package/testing/projections/ProjectionReadModelProcessor.ts +105 -36
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +73 -0
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +0 -3
- package/vitest.config.ts +2 -1
- package/vitest.integration.config.ts +19 -0
- package/webhooks/for_Webhooks/against_a_kernel/when_registering_and_removing_a_webhook.integration.spec.ts +88 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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
|
+
});
|
package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { afterAll, beforeAll, chai, describe, it } from 'vitest';
|
|
6
|
+
import type { WebhookDefinition } from '@cratis/chronicle.contracts';
|
|
7
|
+
import { field } from '@cratis/fundamentals';
|
|
8
|
+
import { ChronicleClient } from '../../../ChronicleClient.js';
|
|
9
|
+
import { ChronicleOptions } from '../../../ChronicleOptions.js';
|
|
10
|
+
import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
|
|
11
|
+
import { eventType } from '../../../events/index.js';
|
|
12
|
+
import type { IEventStore } from '../../../IEventStore.js';
|
|
13
|
+
|
|
14
|
+
chai.should();
|
|
15
|
+
|
|
16
|
+
// Runs against a real Chronicle kernel; skipped locally unless the connection string is set (see vitest.integration.config.ts).
|
|
17
|
+
// In CI it never skips, so a missing connection string or unreachable kernel fails instead of passing vacuously.
|
|
18
|
+
const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
|
|
19
|
+
|
|
20
|
+
@eventType()
|
|
21
|
+
class OrderShipped {
|
|
22
|
+
@field(String) orderId = '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const artifacts: IClientArtifactsProvider = {
|
|
26
|
+
eventTypes: [OrderShipped],
|
|
27
|
+
readModels: [],
|
|
28
|
+
reactors: [],
|
|
29
|
+
reducers: [],
|
|
30
|
+
seeders: [],
|
|
31
|
+
constraints: [],
|
|
32
|
+
projections: [],
|
|
33
|
+
webhooks: [],
|
|
34
|
+
eventTypeMigrations: [],
|
|
35
|
+
globalForHandlers: []
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function eventually<T>(read: () => Promise<T>, accept: (value: T) => boolean, timeoutMs = 20_000): Promise<T> {
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
let value = await read();
|
|
41
|
+
while (!accept(value)) {
|
|
42
|
+
if (Date.now() > deadline) throw new Error('Timed out waiting for the kernel');
|
|
43
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
44
|
+
value = await read();
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe.skipIf(!connectionString && !process.env.CI)('when registering and removing a webhook against a kernel', () => {
|
|
50
|
+
const webhookId = randomUUID();
|
|
51
|
+
const targetUrl = 'http://localhost:9/orders';
|
|
52
|
+
let client: ChronicleClient;
|
|
53
|
+
let store: IEventStore;
|
|
54
|
+
let registered: WebhookDefinition | undefined;
|
|
55
|
+
let afterRemove: WebhookDefinition[];
|
|
56
|
+
let registerWithoutIdentifier: unknown;
|
|
57
|
+
|
|
58
|
+
const findRegistered = async () => (await store.webhooks.getWebhooks()).find(_ => _.Identifier === webhookId);
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
|
|
62
|
+
discoveryPatterns: [],
|
|
63
|
+
clientArtifactsProvider: artifacts
|
|
64
|
+
}));
|
|
65
|
+
store = await client.getEventStore(`WebhooksEndToEnd${randomUUID().replaceAll('-', '')}`);
|
|
66
|
+
|
|
67
|
+
await store.webhooks.register(webhookId, targetUrl, _ => _
|
|
68
|
+
.withEventType(OrderShipped)
|
|
69
|
+
.withHeader('X-Source', 'specs'));
|
|
70
|
+
|
|
71
|
+
// The kernel records the registration as events and builds its webhook list from them, so it shows up eventually.
|
|
72
|
+
registered = await eventually(findRegistered, _ => _ !== undefined);
|
|
73
|
+
|
|
74
|
+
await store.webhooks.remove(webhookId);
|
|
75
|
+
afterRemove = await eventually(() => store.webhooks.getWebhooks(), _ => !_.some(webhook => webhook.Identifier === webhookId));
|
|
76
|
+
|
|
77
|
+
registerWithoutIdentifier = await store.webhooks.register('', targetUrl, _ => _.withEventType(OrderShipped)).catch((error: unknown) => error);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterAll(() => client?.dispose());
|
|
81
|
+
|
|
82
|
+
it('should list the webhook', () => registered!.Identifier.should.equal(webhookId));
|
|
83
|
+
it('should keep the target url', () => registered!.Target!.Url.should.equal(targetUrl));
|
|
84
|
+
it('should keep the header', () => registered!.Target!.Headers['X-Source'].should.equal('specs'));
|
|
85
|
+
it('should keep the event type', () => registered!.EventTypes.map(_ => _.Id).should.deep.equal(['OrderShipped']));
|
|
86
|
+
it('should no longer list the removed webhook', () => afterRemove.some(_ => _.Identifier === webhookId).should.be.false);
|
|
87
|
+
it('should report a registration the kernel rejects', () => registerWithoutIdentifier.should.be.instanceOf(Error));
|
|
88
|
+
});
|