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