@cratis/chronicle 6.38.0 → 6.40.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.
- package/ChronicleClient.ts +2 -3
- package/ChronicleOptions.ts +5 -1
- package/EventStore.ts +1 -2
- package/WellKnownTelemetryNames.ts +21 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +2 -3
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/ChronicleOptions.d.ts +1 -1
- package/dist/ChronicleOptions.d.ts.map +1 -1
- package/dist/ChronicleOptions.js +5 -1
- package/dist/ChronicleOptions.js.map +1 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +1 -2
- package/dist/EventStore.js.map +1 -1
- package/dist/WellKnownTelemetryNames.d.ts +21 -0
- package/dist/WellKnownTelemetryNames.d.ts.map +1 -1
- package/dist/WellKnownTelemetryNames.js +21 -0
- package/dist/WellKnownTelemetryNames.js.map +1 -1
- package/dist/eventSequences/EventSequence.js +9 -9
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/telemetry/ChronicleTelemetryOptions.d.ts +3 -1
- package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -1
- package/dist/telemetry/observeOperation.d.ts +3 -1
- package/dist/telemetry/observeOperation.d.ts.map +1 -1
- package/dist/telemetry/observeOperation.js +3 -1
- package/dist/telemetry/observeOperation.js.map +1 -1
- package/dist/testing/EventFieldSchema.d.ts +6 -0
- package/dist/testing/EventFieldSchema.d.ts.map +1 -0
- package/dist/testing/EventFieldSchema.js +78 -0
- package/dist/testing/EventFieldSchema.js.map +1 -0
- package/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +17 -7
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/InProcessConstraints.d.ts +3 -1
- package/dist/testing/InProcessConstraints.d.ts.map +1 -1
- package/dist/testing/InProcessConstraints.js +20 -9
- package/dist/testing/InProcessConstraints.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +9 -10
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/testing/ReadModelScenario.js +1 -1
- package/dist/testing/ReadModelScenario.js.map +1 -1
- package/dist/testing/ScenarioEvent.d.ts +1 -0
- package/dist/testing/ScenarioEvent.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.js +7 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventSequence.ts +9 -9
- package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +30 -9
- package/for_ChronicleOptions/when_configuring_telemetry/with_span_names.spec.ts +32 -0
- package/package.json +2 -2
- package/telemetry/ChronicleTelemetryOptions.ts +4 -1
- package/telemetry/for_ChronicleTelemetry/given/a_client.fixture.ts +47 -0
- package/telemetry/for_ChronicleTelemetry/given/a_span_name_registry.fixture.ts +33 -0
- package/telemetry/for_ChronicleTelemetry/when_exposing_names/with_legacy_compatibility.spec.ts +44 -0
- package/telemetry/for_ChronicleTelemetry/when_naming_spans/with_per_client_modes.spec.ts +138 -0
- package/telemetry/for_ChronicleTelemetry/when_recording_product_attributes/with_both_naming_modes.spec.ts +52 -0
- package/telemetry/observeOperation.ts +9 -1
- package/testing/EventFieldSchema.ts +71 -0
- package/testing/EventScenario.constraint-cycles.spec.ts +1 -1
- package/testing/EventScenario.constraints.spec.ts +4 -2
- package/testing/EventScenario.fixtures.spec.ts +5 -5
- package/testing/EventScenario.ts +18 -7
- package/testing/InProcessConstraints.ts +19 -9
- package/testing/InProcessEventSequence.ts +9 -10
- package/testing/ReadModelScenario.ts +1 -1
- package/testing/ScenarioEvent.ts +1 -1
- package/testing/for_EventScenario/when_appending_field_types/field_scenario_behaviors.fixture.ts +219 -0
- package/testing/for_EventScenario/when_appending_field_types/with_invalid_formatted_content.legacy.spec.ts +123 -0
- package/testing/for_EventScenario/when_appending_field_types/with_legacy_decorators.legacy.spec.ts +124 -0
- package/testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts +123 -0
- package/testing/for_EventScenario/when_appending_field_types/with_unproven_schemas.legacy.spec.ts +82 -0
- package/testing/projections/ProjectionReadModelProcessor.ts +10 -0
|
@@ -231,6 +231,6 @@ describe('fixture-backed unique event type cycles', () => {
|
|
|
231
231
|
await unsupported(() => new EventScenario({ artifacts: {
|
|
232
232
|
eventTypes: [OracleCycleFirst, OracleCycleSibling, OracleCycleRemoved, FieldlessRemoval],
|
|
233
233
|
constraints: [CycleFirst, CycleSibling]
|
|
234
|
-
} }), 'artifacts.eventTypes.schema (FieldlessRemoval)', 'Only
|
|
234
|
+
} }), 'artifacts.eventTypes.schema (FieldlessRemoval)', 'Only unclassified string, boolean, numeric, Guid, date and object fields are supported.');
|
|
235
235
|
});
|
|
236
236
|
});
|
|
@@ -288,10 +288,12 @@ describe('fixture-backed unscoped constraints', () => {
|
|
|
288
288
|
(await subject.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
it('
|
|
291
|
+
it('accepts fixture-backed safe-integer constrained schemas', async () => {
|
|
292
292
|
class NumericConstrained { @field(Number) @unique('NumberKey') key = 1; }
|
|
293
293
|
eventType('NumericConstrained')(NumericConstrained);
|
|
294
|
-
|
|
294
|
+
const subject = new EventScenario({ artifacts: { eventTypes: [NumericConstrained] } });
|
|
295
|
+
(await subject.append('A', new NumericConstrained())).isSuccess.should.be.true;
|
|
296
|
+
(await subject.append('B', new NumericConstrained())).isSuccess.should.be.false;
|
|
295
297
|
});
|
|
296
298
|
|
|
297
299
|
it('rejects a selected constraint covering an event outside the selected catalog', () => {
|
|
@@ -31,10 +31,10 @@ class BoundaryRecorded {
|
|
|
31
31
|
}
|
|
32
32
|
eventType('BoundaryRecorded')(BoundaryRecorded);
|
|
33
33
|
class UnknownEvent {}
|
|
34
|
-
class
|
|
35
|
-
@field(Number)
|
|
34
|
+
class UnprovenArray {
|
|
35
|
+
@field(Array, { genericArguments: [Number] }) amounts = [1];
|
|
36
36
|
}
|
|
37
|
-
eventType('
|
|
37
|
+
eventType('UnprovenArray')(UnprovenArray);
|
|
38
38
|
class ConstrainedEvent {
|
|
39
39
|
@field(String) value = 'value';
|
|
40
40
|
}
|
|
@@ -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', 'constraints-scopes.json'].includes(name)).map(name => ({
|
|
62
|
+
'constraints-ignore-casing.json', 'constraints-scopes.json', 'constraints-field-types.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: [] };
|
|
@@ -192,7 +192,7 @@ describe('when appending against committed kernel event fixtures', () => {
|
|
|
192
192
|
|
|
193
193
|
|
|
194
194
|
it('should reject unproven schemas and registrations but discover supported constraints', async () => {
|
|
195
|
-
unsupported(() => new EventScenario({ artifacts: { eventTypes: [
|
|
195
|
+
unsupported(() => new EventScenario({ artifacts: { eventTypes: [UnprovenArray] }, constraints: 'disabled' }), 'schema');
|
|
196
196
|
const constrained = new EventScenario({ artifacts: { eventTypes: [ConstrainedEvent] } });
|
|
197
197
|
(await constrained.append('A', new ConstrainedEvent())).isSuccess.should.be.true;
|
|
198
198
|
(await constrained.append('A', new ConstrainedEvent())).constraintViolations.length.should.equal(1);
|
package/testing/EventScenario.ts
CHANGED
|
@@ -85,7 +85,8 @@ export class EventScenario {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
constraints = new InProcessConstraints(definitions
|
|
88
|
+
constraints = new InProcessConstraints(definitions, new Map(eventTypes.map(type =>
|
|
89
|
+
[getEventTypeFor(type).id.value, getEventTypeMetadata(type)!.schema])));
|
|
89
90
|
for (const [name, capture] of definitions) {
|
|
90
91
|
const ids = capture.uniqueConstraint?.eventDefinitions.map(entry => entry.eventTypeId) ??
|
|
91
92
|
capture.uniqueEventType?.eventTypeIds ?? [capture.uniqueEventType?.eventTypeId];
|
|
@@ -106,22 +107,32 @@ export class EventScenario {
|
|
|
106
107
|
}
|
|
107
108
|
for (const entry of capture.uniqueConstraint?.eventDefinitions ?? []) {
|
|
108
109
|
const type = eventTypes.find(type => getEventTypeFor(type).id.value === entry.eventTypeId)!;
|
|
109
|
-
const
|
|
110
|
-
|
|
110
|
+
const schemas = entry.properties.map(property => getEventTypeMetadata(type)?.schema.properties?.[property]);
|
|
111
|
+
const plainStrings = schemas.every(schema => schema?.type === 'string' && schema.format === undefined);
|
|
112
|
+
if (scoped && !plainStrings) {
|
|
111
113
|
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
112
114
|
'Scoped property keys must be schema-backed strings.');
|
|
113
115
|
}
|
|
114
|
-
|
|
116
|
+
for (const schema of schemas) {
|
|
117
|
+
const supported = schema?.type === 'boolean' && schema.format === undefined ||
|
|
118
|
+
schema?.type === 'string' && (schema.format === undefined || schema.format === 'guid') ||
|
|
119
|
+
schema?.type === 'number' && (schema.format === undefined || schema.format === 'double');
|
|
120
|
+
if (!supported) throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
121
|
+
`Unique keys with schema ${schema?.type ?? 'missing'}/${schema?.format ?? 'unformatted'} are not fixture-backed; date, object and other numeric formats require a kernel-backed test.`);
|
|
122
|
+
}
|
|
123
|
+
const extendedKey = schemas.some(schema => schema?.type === 'number' || schema?.format === 'guid');
|
|
124
|
+
if (extendedKey && (definitions.size !== 1 || capture.uniqueConstraint!.eventDefinitions.length !== 1 ||
|
|
125
|
+
removedWith.some(id => id !== undefined))) {
|
|
115
126
|
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
116
|
-
'
|
|
127
|
+
'Guid and numeric keys require one isolated definition covering one event type without removers; shared definitions and removal combinations are not fixture-backed.');
|
|
117
128
|
}
|
|
118
129
|
// constraints-ignore-casing.json captures folded string keys only.
|
|
119
|
-
if (capture.uniqueConstraint!.ignoreCasing &&
|
|
130
|
+
if (capture.uniqueConstraint!.ignoreCasing && !plainStrings) {
|
|
120
131
|
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
121
132
|
'Case-insensitive keys must be schema-backed strings.');
|
|
122
133
|
}
|
|
123
134
|
// constraints-composite.json captures string components only.
|
|
124
|
-
if (entry.properties.length > 1 &&
|
|
135
|
+
if (entry.properties.length > 1 && !plainStrings) {
|
|
125
136
|
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
126
137
|
'Every property of a composite key must be a schema-backed string.');
|
|
127
138
|
}
|
|
@@ -7,6 +7,7 @@ import type { AppendedEvent } from '../events/AppendedEvent.js';
|
|
|
7
7
|
import type { ConstraintCapture } from '../events/constraints/ConstraintBuilder.js';
|
|
8
8
|
import { resolveConstraintMessage } from '../events/constraints/Constraints.js';
|
|
9
9
|
import type { ConstraintViolation } from '../eventSequences/ConstraintViolation.js';
|
|
10
|
+
import type { JsonSchema } from '../schemas/JsonSchema.js';
|
|
10
11
|
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
11
12
|
import { eventTypeScopeKey, propertyScopeKey, scopeIndex } from './ConstraintScope.js';
|
|
12
13
|
|
|
@@ -16,7 +17,18 @@ const unavailable = '18446744073709551615';
|
|
|
16
17
|
// Only the key characters captured by the packaged oracle, not every valid event-content character.
|
|
17
18
|
const provenString = /^[A-Za-z0-9 .@_:\-|{}$\u00e9]*$/;
|
|
18
19
|
|
|
19
|
-
function kernelKeyString(value: unknown, name: string): string {
|
|
20
|
+
function kernelKeyString(value: unknown, name: string, schema?: JsonSchema): string {
|
|
21
|
+
// constraints-field-types.json: the kernel converts Guid keys before comparing or reporting them.
|
|
22
|
+
if (schema?.format === 'guid') {
|
|
23
|
+
if (typeof value === 'string' && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value)) return value.toLowerCase();
|
|
24
|
+
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name, 'Only dashed Guid keys are fixture-backed.');
|
|
25
|
+
}
|
|
26
|
+
// Avoid .NET floating-point formatting, exponent spelling and JavaScript precision loss.
|
|
27
|
+
if (schema?.type === 'number') {
|
|
28
|
+
if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
|
|
29
|
+
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
30
|
+
'Only safe-integer numeric keys are fixture-backed; fractional, exponential and unsafe-integer comparisons require a kernel-backed test.');
|
|
31
|
+
}
|
|
20
32
|
if (typeof value === 'string' && provenString.test(value)) return value;
|
|
21
33
|
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
22
34
|
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
@@ -35,10 +47,10 @@ type Claim = { key: string; parts: Array<{ property: string; raw: string }> };
|
|
|
35
47
|
* (UniqueConstraintDefinitionExtensions.GetPropertiesAndValues/GetValue at Chronicle 8fe5d30). Not a tuple:
|
|
36
48
|
* ['a-b', 'c'] and ['a', 'b-c'] claim the same key, as constraints-composite.json captures.
|
|
37
49
|
*/
|
|
38
|
-
function claimOf(name: string, unique: UniqueCapture, eventTypeId: string, content: Record<string, unknown
|
|
50
|
+
function claimOf(name: string, unique: UniqueCapture, eventTypeId: string, content: Record<string, unknown>, schema?: JsonSchema): Claim | undefined {
|
|
39
51
|
const properties = unique.eventDefinitions.find(entry => entry.eventTypeId === eventTypeId)?.properties;
|
|
40
52
|
if (properties === undefined) return undefined;
|
|
41
|
-
const parts = properties.map(property => ({ property, raw: kernelKeyString(content[property], name) }));
|
|
53
|
+
const parts = properties.map(property => ({ property, raw: kernelKeyString(content[property], name, schema?.properties?.[property]) }));
|
|
42
54
|
const joined = parts.map(part => part.raw).join('-');
|
|
43
55
|
if (!unique.ignoreCasing) return { key: keyHash(joined), parts };
|
|
44
56
|
// The kernel applies .NET ToLowerInvariant to the joined value. constraints-ignore-casing.json proves
|
|
@@ -56,7 +68,8 @@ export class InProcessConstraints {
|
|
|
56
68
|
private readonly _removalTypes = new Set<string>();
|
|
57
69
|
private readonly _propertyRemovalTypes = new Set<string>();
|
|
58
70
|
|
|
59
|
-
constructor(private readonly _definitions: ReadonlyMap<string, ConstraintCapture
|
|
71
|
+
constructor(private readonly _definitions: ReadonlyMap<string, ConstraintCapture>,
|
|
72
|
+
private readonly _schemas: ReadonlyMap<string, JsonSchema> = new Map()) {
|
|
60
73
|
const coveredTypes = new Map<string, string>();
|
|
61
74
|
const removalOwners = new Map<string, string>();
|
|
62
75
|
for (const [name, capture] of _definitions) {
|
|
@@ -173,10 +186,7 @@ export class InProcessConstraints {
|
|
|
173
186
|
}
|
|
174
187
|
const properties = unique.eventDefinitions.find(entry => entry.eventTypeId === type)?.properties;
|
|
175
188
|
if (properties === undefined) continue;
|
|
176
|
-
|
|
177
|
-
throw this.unsupported(name, 'History has an unproven key.');
|
|
178
|
-
}
|
|
179
|
-
claims.set(source, { key: claimOf(name, unique, type, prior.content)!.key,
|
|
189
|
+
claims.set(source, { key: claimOf(name, unique, type, prior.content, this._schemas.get(type))!.key,
|
|
180
190
|
sequence: prior.context.sequenceNumber.toString() });
|
|
181
191
|
}
|
|
182
192
|
}
|
|
@@ -187,7 +197,7 @@ export class InProcessConstraints {
|
|
|
187
197
|
const source = event.context.eventSourceId;
|
|
188
198
|
const violationCount = violations.length;
|
|
189
199
|
for (const [name, definition] of this._definitions) {
|
|
190
|
-
const claim = definition.uniqueConstraint && claimOf(name, definition.uniqueConstraint, type, event.content);
|
|
200
|
+
const claim = definition.uniqueConstraint && claimOf(name, definition.uniqueConstraint, type, event.content, this._schemas.get(type));
|
|
191
201
|
if (claim) {
|
|
192
202
|
const key = claim.key;
|
|
193
203
|
const scope = propertyScopeKey(definition.scope, event.context);
|
|
@@ -29,6 +29,7 @@ import { prepareSingleAppend } from '../eventSequences/prepareSingleAppend.js';
|
|
|
29
29
|
import { toContractsGuid } from '../connection/Guid.js';
|
|
30
30
|
import { getRemovedConstraintNames } from '../events/constraints/removeConstraint.js';
|
|
31
31
|
import { InProcessConstraints, type WireConstraintViolation } from './InProcessConstraints.js';
|
|
32
|
+
import { matchesEventField, supportsEventField } from './EventFieldSchema.js';
|
|
32
33
|
import type { EventScenarioOptions } from './EventScenarioOptions.js';
|
|
33
34
|
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
34
35
|
|
|
@@ -38,7 +39,7 @@ const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space
|
|
|
38
39
|
function matchesConstrainedValue(value: unknown, content: unknown): boolean {
|
|
39
40
|
// Use production serialization for concepts, not their wrapper object's runtime type.
|
|
40
41
|
// Keep rejecting other unproven conversions, such as a Date assigned to a string field.
|
|
41
|
-
if (value && typeof value === 'object' && typeKeyOf(value.constructor as Constructor)
|
|
42
|
+
if (value && typeof value === 'object' && [conceptAsTypeKey, 'Guid'].includes(typeKeyOf(value.constructor as Constructor) ?? '')) {
|
|
42
43
|
return JsonSerializer.serialize(value) === JSON.stringify(content);
|
|
43
44
|
}
|
|
44
45
|
return value === content;
|
|
@@ -117,11 +118,8 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
117
118
|
!schema.properties || (Object.keys(schema.properties).length === 0 &&
|
|
118
119
|
!this._constraints?.isRemovalOnlyType(eventType.id.value)) ||
|
|
119
120
|
Object.entries(schema.properties).some(([key, property]) =>
|
|
120
|
-
!/^[a-z][a-zA-Z0-9]*$/.test(key) || !
|
|
121
|
-
|
|
122
|
-
property.items !== undefined || property.properties !== undefined || property.enum !== undefined ||
|
|
123
|
-
property.additionalProperties !== undefined)) {
|
|
124
|
-
throw this.unsupported('artifacts.eventTypes.schema', type.name, 'Only flat, unclassified string/boolean fields are fixture-backed.');
|
|
121
|
+
!/^[a-z][a-zA-Z0-9]*$/.test(key) || !supportsEventField(property))) {
|
|
122
|
+
throw this.unsupported('artifacts.eventTypes.schema', type.name, 'Only unclassified string, boolean, numeric, Guid, date and object fields are supported.');
|
|
125
123
|
}
|
|
126
124
|
this._catalog.set(type, metadata);
|
|
127
125
|
}
|
|
@@ -177,7 +175,7 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
177
175
|
try {
|
|
178
176
|
prepared = prepareSingleAppend(event, this._correlationId ? { correlationId: this._correlationId() } : undefined);
|
|
179
177
|
content = this.checkedContent(event, prepared.content, 'append.content',
|
|
180
|
-
'Content differs from the
|
|
178
|
+
'Content differs from the event schema; null, missing and extra values are not proven.');
|
|
181
179
|
} catch (error) {
|
|
182
180
|
if (error instanceof UnsupportedEventSequenceOperation) throw error;
|
|
183
181
|
throw this.unsupported('append.serialization', event.constructor.name, `Payload or metadata could not be serialized: ${String(error)}.`);
|
|
@@ -455,7 +453,7 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
455
453
|
}
|
|
456
454
|
|
|
457
455
|
private checkedContent(event: object, serialized: string, operation: string,
|
|
458
|
-
mismatchReason = 'Content differs from the
|
|
456
|
+
mismatchReason = 'Content differs from the event schema.'): Record<string, unknown> {
|
|
459
457
|
let content: Record<string, unknown>;
|
|
460
458
|
try { content = JSON.parse(serialized) as Record<string, unknown>; }
|
|
461
459
|
catch { throw this.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); }
|
|
@@ -463,10 +461,11 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
463
461
|
const properties = metadata.schema.properties!;
|
|
464
462
|
if (!content || typeof content !== 'object' || Array.isArray(content) ||
|
|
465
463
|
Object.keys(content).length !== Object.keys(properties).length ||
|
|
466
|
-
Object.entries(properties).some(([key, property]) =>
|
|
464
|
+
Object.entries(properties).some(([key, property]) => !Object.hasOwn(content, key) || !matchesEventField(content[key], property) ||
|
|
467
465
|
(this._constraints?.isConstrainedProperty(metadata.eventType.id.value, key) &&
|
|
468
466
|
!matchesConstrainedValue(Reflect.get(event, key), content[key])) ||
|
|
469
|
-
(property.type === 'string' &&
|
|
467
|
+
(property.type === 'string' && property.format === undefined &&
|
|
468
|
+
!/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
|
|
470
469
|
throw this.unsupported(operation, event.constructor.name, mismatchReason);
|
|
471
470
|
}
|
|
472
471
|
return content;
|
|
@@ -174,7 +174,7 @@ export class ReadModelScenario<TReadModel extends object> {
|
|
|
174
174
|
throw new UnsupportedProjectionOperation(this._modelName, 'observe', `${eventSourceType}/${eventStreamType}/${eventStreamId}`,
|
|
175
175
|
'observed events must use the default event source type and stream; routed events require a kernel-backed test');
|
|
176
176
|
}
|
|
177
|
-
return { sourceId: event.context.eventSourceId, content: event.content, context: event.context };
|
|
177
|
+
return { sourceId: event.context.eventSourceId, content: event.content, context: event.context, scenarioLocalHash: true };
|
|
178
178
|
});
|
|
179
179
|
}
|
|
180
180
|
|
package/testing/ScenarioEvent.ts
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
import type { EventContext } from '../events/EventContext.js';
|
|
5
5
|
|
|
6
6
|
/** An event serialized at the scenario boundary. */
|
|
7
|
-
export type ScenarioEvent = { sourceId: string; content: unknown; context: EventContext };
|
|
7
|
+
export type ScenarioEvent = { sourceId: string; content: unknown; context: EventContext; scenarioLocalHash?: boolean };
|
package/testing/for_EventScenario/when_appending_field_types/field_scenario_behaviors.fixture.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
5
|
+
import { Guid, JsonSerializer, type Constructor } from '@cratis/fundamentals';
|
|
6
|
+
import { beforeEach, chai, describe, it, vi, type Assertion } from 'vitest';
|
|
7
|
+
import type { ChronicleConnection } from '../../../connection/index.js';
|
|
8
|
+
import { EventSequence } from '../../../eventSequences/EventSequence.js';
|
|
9
|
+
import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
|
|
10
|
+
import type { IUnitOfWorkManager } from '../../../transactions/IUnitOfWorkManager.js';
|
|
11
|
+
import { getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
|
|
12
|
+
import { EventScenario, ReactorScenario, ReadModelScenario, UnsupportedEventSequenceOperation, UnsupportedProjectionOperation } from '../../index.js';
|
|
13
|
+
import { InProcessConstraints } from '../../InProcessConstraints.js';
|
|
14
|
+
|
|
15
|
+
chai.should();
|
|
16
|
+
const should = (value: unknown): Assertion => (value as { should: Assertion }).should;
|
|
17
|
+
|
|
18
|
+
export function fieldScenarioBehaviors(fixtures: {
|
|
19
|
+
recorded: Constructor;
|
|
20
|
+
reactor: Constructor;
|
|
21
|
+
model: Constructor;
|
|
22
|
+
reducer: Constructor;
|
|
23
|
+
guidConstraint: Constructor;
|
|
24
|
+
numberConstraint: Constructor;
|
|
25
|
+
doubleConstraint: Constructor;
|
|
26
|
+
double: Constructor<{ key: number }>;
|
|
27
|
+
projection: Constructor;
|
|
28
|
+
objectProjection: Constructor;
|
|
29
|
+
hashProjection: Constructor;
|
|
30
|
+
guid: Constructor<{ key: Guid }>;
|
|
31
|
+
number: Constructor<{ key: number }>;
|
|
32
|
+
guidConcept: Constructor;
|
|
33
|
+
numberConcept: Constructor;
|
|
34
|
+
rejected: Constructor[];
|
|
35
|
+
}): void {
|
|
36
|
+
const artifacts = { eventTypes: [fixtures.recorded], reducers: [], projections: [] };
|
|
37
|
+
const serialized = () => JSON.parse(JsonSerializer.serialize(new fixtures.recorded())) as Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
describe('when appending unconstrained primitive and concept fields', () => {
|
|
40
|
+
let scenario: EventScenario;
|
|
41
|
+
let connectedContent: Record<string, unknown>;
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
scenario = new EventScenario({ artifacts });
|
|
44
|
+
const append = vi.fn().mockResolvedValue({ Response: { SequenceNumber: 0n, ConstraintViolations: [], Errors: [] } });
|
|
45
|
+
const connected = new EventSequence(EventSequenceId.eventLog, 'store', 'default',
|
|
46
|
+
{ eventSequences: { append } } as unknown as ChronicleConnection, {} as IUnitOfWorkManager);
|
|
47
|
+
const event = new fixtures.recorded();
|
|
48
|
+
await connected.append('A', event);
|
|
49
|
+
connectedContent = JSON.parse(append.mock.calls[0][0].Content as string) as Record<string, unknown>;
|
|
50
|
+
await scenario.given.forEventSource('A').events(event);
|
|
51
|
+
await scenario.append('B', event);
|
|
52
|
+
await scenario.appendMany('C', [event]);
|
|
53
|
+
await scenario.appendMany([{ eventSourceId: 'D', event }]);
|
|
54
|
+
});
|
|
55
|
+
it('should serialize every append overload exactly like the connected client', () => {
|
|
56
|
+
should(scenario.appendedEvents.map(event => event.content)).deep.equal(Array(4).fill(connectedContent));
|
|
57
|
+
should(scenario.results.map(result => result.isSuccess)).deep.equal([true, true, true]);
|
|
58
|
+
});
|
|
59
|
+
it('should retain independent nested history snapshots', async () => {
|
|
60
|
+
const first = scenario.appendedEvents[0].content;
|
|
61
|
+
(first.payload as Record<string, unknown>).amount = -1;
|
|
62
|
+
const read = await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [fixtures.recorded]);
|
|
63
|
+
should(read[0].content).deep.equal(connectedContent);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const [property, value] of [
|
|
68
|
+
['identifier', 'not-a-guid'], ['identifier', Guid.parse('zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz')],
|
|
69
|
+
['occurred', new Date('+010000-01-01T00:00:00.000Z')], ['occurred', new Date('0000-01-01T00:00:00.000Z')],
|
|
70
|
+
['payload', { amount: 1, identifier: 'bad', occurred: new Date() }]
|
|
71
|
+
] as const) {
|
|
72
|
+
describe(`when a declared ${property} field contains an invalid serialized value`, () => {
|
|
73
|
+
it('should reject before committing a setup call', async () => {
|
|
74
|
+
const scenario = new EventScenario({ artifacts });
|
|
75
|
+
const event = Object.assign(new fixtures.recorded(), { [property]: value });
|
|
76
|
+
const error = await scenario.given.forEventSource('A').events(new fixtures.recorded(), event).then(() => undefined, error => error);
|
|
77
|
+
should(error).instanceOf(UnsupportedEventSequenceOperation);
|
|
78
|
+
should(scenario.appendedEvents).have.lengthOf(0);
|
|
79
|
+
should(scenario.results).have.lengthOf(0);
|
|
80
|
+
should((await scenario.eventSequence.getNextSequenceNumber()).value).equal(0n);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('when delivering new field types through a reactor', () => {
|
|
86
|
+
let scenario: ReactorScenario;
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
scenario = new ReactorScenario(fixtures.reactor, { artifacts });
|
|
89
|
+
await scenario.when.forEventSource('A').events(new fixtures.recorded());
|
|
90
|
+
});
|
|
91
|
+
it('should deliver client-serialized JSON without kernel normalization or concept wrappers', () => {
|
|
92
|
+
should(scenario.results[0].completed).equal(true);
|
|
93
|
+
should(scenario.produced[0]).deep.equal(serialized());
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('when observing new field types through a reducer', () => {
|
|
98
|
+
let result: object | null;
|
|
99
|
+
beforeEach(async () => {
|
|
100
|
+
const scenario = new EventScenario({ artifacts });
|
|
101
|
+
const models = new ReadModelScenario(fixtures.model, { ...artifacts, reducers: [fixtures.reducer] }).observe(scenario);
|
|
102
|
+
await scenario.append('A', new fixtures.recorded());
|
|
103
|
+
result = await models.instanceForEventSourceId('A');
|
|
104
|
+
});
|
|
105
|
+
it('should give the reducer client-serialized JSON without kernel normalization', () => { should(result).deep.equal(serialized()); });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('when observing new scalar fields through a projection', () => {
|
|
109
|
+
let result: object | null;
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
const scenario = new EventScenario({ artifacts });
|
|
112
|
+
const models = new ReadModelScenario(fixtures.projection, artifacts).observe(scenario);
|
|
113
|
+
await scenario.append('A', new fixtures.recorded());
|
|
114
|
+
result = await models.instanceForEventSourceId('A');
|
|
115
|
+
});
|
|
116
|
+
it('should preserve the existing fixture-backed projection conversion', () => {
|
|
117
|
+
const content = serialized();
|
|
118
|
+
should(JSON.parse(JsonSerializer.serialize(result))).deep.equal({
|
|
119
|
+
id: 'A', amount: content.amount, identifier: String(content.identifier).toLowerCase(), occurred: content.occurred });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should still reject object projection targets independently of accepted event content', () => {
|
|
124
|
+
should(() => new ReadModelScenario(fixtures.objectProjection, artifacts)).throw(UnsupportedProjectionOperation);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should reject a projection that treats a new payload hash as a kernel hash', async () => {
|
|
128
|
+
const scenario = new EventScenario({ artifacts });
|
|
129
|
+
const models = new ReadModelScenario(fixtures.hashProjection, artifacts).observe(scenario);
|
|
130
|
+
await scenario.append('A', new fixtures.recorded());
|
|
131
|
+
const error = await models.instanceForEventSourceId('A').then(() => undefined, error => error);
|
|
132
|
+
should(error).instanceOf(UnsupportedProjectionOperation);
|
|
133
|
+
should(String(error)).include('Hash');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
for (const type of fixtures.rejected) {
|
|
137
|
+
describe(`when registering the unproven ${type.name} unique key`, () => {
|
|
138
|
+
it('should name the constraint boundary rather than rejecting ordinary event serialization', async () => {
|
|
139
|
+
should(() => new EventScenario({ artifacts: { eventTypes: [type] } })).throw(UnsupportedEventSequenceOperation, 'artifacts.constraints');
|
|
140
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [type] }, constraints: 'disabled' });
|
|
141
|
+
should((await scenario.append('A', new type())).isSuccess).equal(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const type of [fixtures.guidConcept, fixtures.numberConcept]) {
|
|
147
|
+
describe(`when claiming the same ${type.name} concept key`, () => {
|
|
148
|
+
it('should compare its serialized primitive and report the duplicate', async () => {
|
|
149
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [type] } });
|
|
150
|
+
should((await scenario.append('A', new type())).isSuccess).equal(true);
|
|
151
|
+
const duplicate = await scenario.append('B', new type());
|
|
152
|
+
should(duplicate.isSuccess).equal(false);
|
|
153
|
+
should(duplicate.constraintViolations[0].details.PropertyValue).equal(
|
|
154
|
+
String((JSON.parse(JsonSerializer.serialize(new type())) as { key: unknown }).key).toLowerCase());
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const value of [1.5, 1e-7, Number.MAX_SAFE_INTEGER + 1, -Number.MAX_SAFE_INTEGER - 1, NaN, Infinity]) {
|
|
160
|
+
describe(`when a constrained number contains ${value}`, () => {
|
|
161
|
+
it('should reject the entire batch before recording results or history', async () => {
|
|
162
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [fixtures.number], constraints: [fixtures.numberConstraint] } });
|
|
163
|
+
const event = new fixtures.number(); event.key = value;
|
|
164
|
+
const error = await scenario.appendMany('A', [new fixtures.number(), event]).then(() => undefined, error => error);
|
|
165
|
+
should(error).instanceOf(UnsupportedEventSequenceOperation);
|
|
166
|
+
should(scenario.results).have.lengthOf(0);
|
|
167
|
+
should(scenario.appendedEvents).have.lengthOf(0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('should normalize negative zero using production serialization before comparing numeric keys', async () => {
|
|
173
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [fixtures.number], constraints: [fixtures.numberConstraint] } });
|
|
174
|
+
const zero = new fixtures.number(); zero.key = 0;
|
|
175
|
+
const negative = new fixtures.number(); negative.key = -0;
|
|
176
|
+
await scenario.append('A', zero);
|
|
177
|
+
should((await scenario.append('B', negative)).isSuccess).equal(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const fixture = JSON.parse(readFileSync(new URL('../../fixtures/constraints-field-types.json', import.meta.url), 'utf8')) as {
|
|
181
|
+
fieldConstraintCases: Array<{ kind: string; eventType: string; operations: Array<{ mode: string; events: Array<{ source: string; content: string }> }> }>;
|
|
182
|
+
expected: Array<{ fieldSchema: unknown; steps: Array<{ success: boolean; sequences: string[]; wireViolations: unknown[]; history: unknown[]; next: string }> }>;
|
|
183
|
+
};
|
|
184
|
+
for (const [caseIndex, test] of fixture.fieldConstraintCases.entries()) {
|
|
185
|
+
if (test.kind !== 'kernelSemantics') continue;
|
|
186
|
+
describe(`when replaying the packaged ${test.eventType} constraint fixture`, () => {
|
|
187
|
+
it('should match every raw violation, hash, history, sequence and atomic rejection', async () => {
|
|
188
|
+
const type = test.eventType === 'OracleFieldGuid' ? fixtures.guid : test.eventType === 'OracleFieldDouble' ? fixtures.double : fixtures.number;
|
|
189
|
+
should(getEventTypeMetadata(type)!.eventType.id.value).equal(test.eventType);
|
|
190
|
+
should(getEventTypeMetadata(type)!.schema.properties!.key).deep.equal(fixture.expected[caseIndex].fieldSchema);
|
|
191
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [type],
|
|
192
|
+
constraints: [type === fixtures.guid ? fixtures.guidConstraint : type === fixtures.double ? fixtures.doubleConstraint : fixtures.numberConstraint] } });
|
|
193
|
+
const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
|
|
194
|
+
try {
|
|
195
|
+
for (const [index, operation] of test.operations.entries()) {
|
|
196
|
+
const expected = fixture.expected[caseIndex].steps[index];
|
|
197
|
+
const events = operation.events.map(entry => {
|
|
198
|
+
const event = new type();
|
|
199
|
+
const key = (JSON.parse(entry.content) as { key: string | number }).key;
|
|
200
|
+
Object.assign(event, { key });
|
|
201
|
+
return { eventSourceId: entry.source, event };
|
|
202
|
+
});
|
|
203
|
+
const results = operation.mode === 'single'
|
|
204
|
+
? [await scenario.append(events[0].eventSourceId, events[0].event)] : await scenario.appendMany(events);
|
|
205
|
+
should(results.every(result => result.isSuccess === expected.success)).equal(true);
|
|
206
|
+
should(results.map(result => result.sequenceNumber.value.toString())).deep.equal(
|
|
207
|
+
expected.success ? expected.sequences : results.map(() => '0'));
|
|
208
|
+
const raw = validate.mock.results.at(-1)!.value as ReturnType<InProcessConstraints['validate']>;
|
|
209
|
+
should(raw.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() })))
|
|
210
|
+
.deep.equal(expected.wireViolations);
|
|
211
|
+
should(scenario.appendedEvents.map(event => ({ source: event.context.eventSourceId,
|
|
212
|
+
sequence: event.context.sequenceNumber.toString(), content: event.content, hash: event.context.hash }))).deep.equal(expected.history);
|
|
213
|
+
should((await scenario.eventSequence.getNextSequenceNumber()).value.toString()).equal(expected.next);
|
|
214
|
+
}
|
|
215
|
+
} finally { validate.mockRestore(); }
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { beforeEach, chai, describe, it } from 'vitest';
|
|
7
|
+
import { eventType, getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
|
|
8
|
+
import type { JsonSchema } from '../../../schemas/JsonSchema.js';
|
|
9
|
+
import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
|
|
10
|
+
|
|
11
|
+
chai.should();
|
|
12
|
+
|
|
13
|
+
const invalid: Array<{ schema: JsonSchema; value: unknown }> = [
|
|
14
|
+
{ schema: { type: 'integer' }, value: 1.5 },
|
|
15
|
+
{ schema: { type: 'integer' }, value: Number.MAX_SAFE_INTEGER + 1 }
|
|
16
|
+
];
|
|
17
|
+
for (const format of ['guid', 'uuid']) {
|
|
18
|
+
for (const value of ['not-a-guid', 'abcdef01abcdabcdabcdabcdef012345', 'abcdef01-abcd-abcd-abcd-abcdef01234g', '"', '\\', '\n', 'é']) {
|
|
19
|
+
invalid.push({ schema: { type: 'string', format }, value });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (const value of ['01/02/2025', '2025-02-29', '2024-02-30', '2025-13-01', '0000-01-01', '+010000-01-01', '2025-01-02T00:00:00.000Z']) {
|
|
23
|
+
invalid.push({ schema: { type: 'string', format: 'date' }, value });
|
|
24
|
+
}
|
|
25
|
+
for (const value of ['January 2, 2025', '2025-01-02', '2025-02-29T00:00:00.000Z', '2024-02-30T00:00:00.000Z',
|
|
26
|
+
'2025-01-02T24:00:00.000Z', '2025-01-02T00:60:00.000Z', '2025-01-02T00:00:60.000Z',
|
|
27
|
+
'0000-01-01T00:00:00.000Z', '+010000-01-01T00:00:00.000Z', '2025-01-02T00:00:00', '2025-01-02T00:00:00+01:00']) {
|
|
28
|
+
invalid.push({ schema: { type: 'string', format: 'date-time' }, value });
|
|
29
|
+
}
|
|
30
|
+
const ranges: Array<[string, number, number]> = [
|
|
31
|
+
['int8', -128, 127], ['uint8', 0, 255], ['int16', -32768, 32767], ['uint16', 0, 65535],
|
|
32
|
+
['int32', -2147483648, 2147483647], ['uint32', 0, 4294967295],
|
|
33
|
+
['int64', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], ['uint64', 0, Number.MAX_SAFE_INTEGER]
|
|
34
|
+
];
|
|
35
|
+
for (const [format, minimum, maximum] of ranges) {
|
|
36
|
+
for (const type of ['integer', 'number'] as const) {
|
|
37
|
+
for (const value of [minimum - 1, maximum + 1, 1.5]) invalid.push({ schema: { type, format }, value });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const [format, value] of [['int64', 2 ** 63], ['uint64', 2 ** 64], ['float', 3.5e38], ['decimal', 8e28]] as const) {
|
|
41
|
+
invalid.push({ schema: { type: 'number', format }, value });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const { schema, value } of invalid) {
|
|
45
|
+
describe(`when appending invalid ${schema.type}/${schema.format} content ${JSON.stringify(value)}`, () => {
|
|
46
|
+
for (const operation of ['single', 'batch', 'setup']) {
|
|
47
|
+
let scenario: EventScenario;
|
|
48
|
+
let failure: unknown;
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
class Recorded { @field(Object) value: unknown = value; }
|
|
51
|
+
eventType('InvalidFormattedContent')(Recorded);
|
|
52
|
+
getEventTypeMetadata(Recorded)!.schema.properties!.value = schema;
|
|
53
|
+
scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
|
|
54
|
+
const valid = new Recorded();
|
|
55
|
+
valid.value = schema.type === 'string'
|
|
56
|
+
? schema.format === 'date' ? '2024-02-29' : schema.format === 'date-time' ? '2024-02-29T00:00:00.000Z' : '00000000-0000-0000-0000-000000000000'
|
|
57
|
+
: 1;
|
|
58
|
+
await scenario.given.forEventSource('existing').events(valid);
|
|
59
|
+
const append = operation === 'single' ? scenario.append('A', new Recorded())
|
|
60
|
+
: operation === 'batch' ? scenario.appendMany('A', [valid, new Recorded()])
|
|
61
|
+
: scenario.given.forEventSource('A').events(valid, new Recorded());
|
|
62
|
+
failure = await append.then(() => undefined, error => error);
|
|
63
|
+
});
|
|
64
|
+
it(`should reject the ${operation} without changing history results or the next sequence`, async () => {
|
|
65
|
+
(failure as Error).should.be.instanceOf(UnsupportedEventSequenceOperation);
|
|
66
|
+
scenario.appendedEvents.should.have.lengthOf(1);
|
|
67
|
+
scenario.results.should.have.lengthOf(0);
|
|
68
|
+
(await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const property of [
|
|
75
|
+
...['date', 'date-time', 'guid', 'uuid'].map(format => ({ type: 'string' as const, format })),
|
|
76
|
+
{ type: 'integer', format: 'int32' }
|
|
77
|
+
] satisfies JsonSchema[]) {
|
|
78
|
+
for (const container of ['properties', 'additionalProperties']) {
|
|
79
|
+
describe(`when a nested ${container} schema contains an invalid ${property.format}`, () => {
|
|
80
|
+
it('should validate the nested field before accepting content', async () => {
|
|
81
|
+
class Recorded { @field(Object) payload = { nested: property.type === 'integer' ? 2147483648 : 'invalid' }; }
|
|
82
|
+
eventType('InvalidNestedFormattedContent')(Recorded);
|
|
83
|
+
getEventTypeMetadata(Recorded)!.schema.properties!.payload = container === 'properties'
|
|
84
|
+
? { type: 'object', properties: { nested: property } } : { type: 'object', additionalProperties: property };
|
|
85
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
|
|
86
|
+
const failure = await scenario.append('A', new Recorded()).then(() => undefined, error => error);
|
|
87
|
+
failure.should.be.instanceOf(UnsupportedEventSequenceOperation);
|
|
88
|
+
scenario.appendedEvents.should.have.lengthOf(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const valid: Array<{ schema: JsonSchema; values: unknown[] }> = [
|
|
95
|
+
{ schema: { type: 'string', format: 'date' }, values: ['0001-01-01', '2024-02-29', '9999-12-31'] },
|
|
96
|
+
{ schema: { type: 'string', format: 'date-time' }, values: ['0001-01-01T00:00:00.000Z', '2024-02-29T12:34:56Z', '9999-12-31T23:59:59.999Z'] },
|
|
97
|
+
{ schema: { type: 'string', format: 'guid' }, values: ['ABCDEF01-ABCD-ABCD-ABCD-ABCDEF012345'] },
|
|
98
|
+
...ranges.map(([format, minimum, maximum]) => ({ schema: { type: 'integer' as const, format }, values: [minimum, maximum] }))
|
|
99
|
+
];
|
|
100
|
+
for (const { schema, values } of valid) {
|
|
101
|
+
describe(`when appending supported ${schema.type}/${schema.format} boundaries`, () => {
|
|
102
|
+
it('should retain the serialized values without kernel normalization', async () => {
|
|
103
|
+
class Recorded { @field(Object) value: unknown; }
|
|
104
|
+
eventType('ValidFormattedContent')(Recorded);
|
|
105
|
+
getEventTypeMetadata(Recorded)!.schema.properties!.value = schema;
|
|
106
|
+
const scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
|
|
107
|
+
await scenario.appendMany('A', values.map(value => Object.assign(new Recorded(), { value })));
|
|
108
|
+
scenario.appendedEvents.map(event => event.content.value).should.deep.equal(values);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const format of ['unknown', 'int128']) {
|
|
114
|
+
describe(`when registering an unsupported numeric format ${format}`, () => {
|
|
115
|
+
it('should reject the schema rather than ignoring its format', () => {
|
|
116
|
+
class Recorded { @field(Number) value = 1; }
|
|
117
|
+
eventType('UnsupportedNumericFormat')(Recorded);
|
|
118
|
+
getEventTypeMetadata(Recorded)!.schema.properties!.value.format = format;
|
|
119
|
+
(() => new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' }))
|
|
120
|
+
.should.throw(UnsupportedEventSequenceOperation, 'artifacts.eventTypes.schema');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|