@cratis/chronicle 6.23.0 → 6.25.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/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +10 -3
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/InProcessConstraints.d.ts +5 -0
- package/dist/testing/InProcessConstraints.d.ts.map +1 -1
- package/dist/testing/InProcessConstraints.js +90 -41
- package/dist/testing/InProcessConstraints.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +13 -13
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/package.json +1 -1
- package/testing/EventScenario.constraint-lifecycle.spec.ts +231 -0
- package/testing/EventScenario.constraint-values.spec.ts +199 -0
- package/testing/EventScenario.constraints.spec.ts +19 -29
- package/testing/EventScenario.fixtures.spec.ts +2 -1
- package/testing/EventScenario.rejections.spec.ts +1 -1
- package/testing/EventScenario.ts +11 -3
- package/testing/InProcessConstraints.ts +90 -41
- package/testing/InProcessEventSequence.ts +14 -13
|
@@ -12,11 +12,28 @@ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOpe
|
|
|
12
12
|
export type WireConstraintViolation = ContractsConstraintViolation;
|
|
13
13
|
|
|
14
14
|
const unavailable = '18446744073709551615';
|
|
15
|
+
// Only the key characters captured by the packaged oracle, not every valid event-content character.
|
|
16
|
+
const provenString = /^[A-Za-z0-9 .@_:\-|{}$\u00e9]*$/;
|
|
17
|
+
|
|
18
|
+
function kernelKeyString(value: unknown, name: string): string {
|
|
19
|
+
if (typeof value === 'string' && provenString.test(value)) return value;
|
|
20
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
21
|
+
throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
|
|
22
|
+
'Only fixture-backed scalar string and boolean keys are supported.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function keyHash(value: string): string {
|
|
26
|
+
return createHash('sha256').update(value).digest('hex');
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
/** Narrow, fixture-backed unscoped constraint validation over serialized event snapshots. */
|
|
17
30
|
export class InProcessConstraints {
|
|
31
|
+
private readonly _constrainedProperties = new Map<string, Set<string>>();
|
|
32
|
+
private readonly _removalTypes = new Set<string>();
|
|
33
|
+
|
|
18
34
|
constructor(private readonly _definitions: ReadonlyMap<string, ConstraintCapture>) {
|
|
19
|
-
const coveredTypes = new
|
|
35
|
+
const coveredTypes = new Map<string, string>();
|
|
36
|
+
const removalOwners = new Map<string, string>();
|
|
20
37
|
for (const [name, capture] of _definitions) {
|
|
21
38
|
if (capture.uniqueConstraint && capture.uniqueEventType) {
|
|
22
39
|
throw this.unsupported(name, 'A definition with both constraint kinds is not fixture-backed.');
|
|
@@ -26,10 +43,24 @@ export class InProcessConstraints {
|
|
|
26
43
|
}
|
|
27
44
|
if (capture.uniqueConstraint) {
|
|
28
45
|
const unique = capture.uniqueConstraint;
|
|
29
|
-
if (unique.ignoreCasing || unique.
|
|
30
|
-
unique.eventDefinitions.
|
|
46
|
+
if (unique.ignoreCasing || unique.eventDefinitions.length === 0 ||
|
|
47
|
+
unique.eventDefinitions.some(entry => entry.properties.length !== 1 ||
|
|
31
48
|
!/^[a-z][a-zA-Z0-9]*$/.test(entry.properties[0]))) {
|
|
32
|
-
throw this.unsupported(name, 'Case folding
|
|
49
|
+
throw this.unsupported(name, 'Case folding and composite or nested keys are not fixture-backed.');
|
|
50
|
+
}
|
|
51
|
+
for (const id of [unique.removedWithEventTypeId, ...(unique.removedWithEventTypeIds ?? [])]) {
|
|
52
|
+
if (id === undefined) continue;
|
|
53
|
+
const owner = removalOwners.get(id);
|
|
54
|
+
if (owner !== undefined && owner !== name) {
|
|
55
|
+
throw this.unsupported(name, 'Removal type shared by several definitions is not fixture-backed.');
|
|
56
|
+
}
|
|
57
|
+
removalOwners.set(id, name);
|
|
58
|
+
this._removalTypes.add(id);
|
|
59
|
+
}
|
|
60
|
+
for (const entry of unique.eventDefinitions) {
|
|
61
|
+
const properties = this._constrainedProperties.get(entry.eventTypeId) ?? new Set<string>();
|
|
62
|
+
properties.add(entry.properties[0]);
|
|
63
|
+
this._constrainedProperties.set(entry.eventTypeId, properties);
|
|
33
64
|
}
|
|
34
65
|
} else if (capture.uniqueEventType) {
|
|
35
66
|
const unique = capture.uniqueEventType;
|
|
@@ -43,15 +74,58 @@ export class InProcessConstraints {
|
|
|
43
74
|
capture.uniqueEventType?.eventTypeIds ?? [capture.uniqueEventType!.eventTypeId];
|
|
44
75
|
for (const id of ids) {
|
|
45
76
|
if (coveredTypes.has(id)) throw this.unsupported(name, 'Overlapping constraints are not fixture-backed.');
|
|
46
|
-
coveredTypes.
|
|
77
|
+
coveredTypes.set(id, name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [name, definition] of _definitions) {
|
|
81
|
+
const unique = definition.uniqueConstraint;
|
|
82
|
+
if (!unique) continue;
|
|
83
|
+
for (const id of [unique.removedWithEventTypeId, ...(unique.removedWithEventTypeIds ?? [])]) {
|
|
84
|
+
if (id !== undefined && coveredTypes.has(id) && coveredTypes.get(id) !== name) {
|
|
85
|
+
throw this.unsupported(name, 'Removal overlapping another validating definition is not fixture-backed.');
|
|
86
|
+
}
|
|
47
87
|
}
|
|
48
88
|
}
|
|
49
89
|
}
|
|
50
90
|
|
|
91
|
+
isRemovalOnlyType(eventTypeId: string): boolean {
|
|
92
|
+
return this._removalTypes.has(eventTypeId) && !this._constrainedProperties.has(eventTypeId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
hasRemovalType(eventTypeId: string): boolean { return this._removalTypes.has(eventTypeId); }
|
|
96
|
+
|
|
97
|
+
isConstrainedProperty(eventTypeId: string, property: string): boolean {
|
|
98
|
+
return this._constrainedProperties.get(eventTypeId)?.has(property) ?? false;
|
|
99
|
+
}
|
|
100
|
+
|
|
51
101
|
/** Validate against committed history and all earlier entries of this append, without changing state. */
|
|
52
102
|
validate(history: readonly AppendedEvent[], incoming: readonly AppendedEvent[]): WireConstraintViolation[] {
|
|
53
103
|
const violations: WireConstraintViolation[] = [];
|
|
54
|
-
|
|
104
|
+
// Durable ownership is one claim per source, not one entry per historical value.
|
|
105
|
+
// Rebuild it from committed history; batch claims below remain independent and are never released.
|
|
106
|
+
const owners = new Map<string, Map<string, { key: string; sequence: string }>>();
|
|
107
|
+
for (const [name, definition] of this._definitions) {
|
|
108
|
+
if (!definition.uniqueConstraint) continue;
|
|
109
|
+
const claims = new Map<string, { key: string; sequence: string }>();
|
|
110
|
+
const unique = definition.uniqueConstraint;
|
|
111
|
+
const removals = new Set([unique.removedWithEventTypeId, ...(unique.removedWithEventTypeIds ?? [])]);
|
|
112
|
+
for (const prior of history) {
|
|
113
|
+
const type = prior.eventType.id.value;
|
|
114
|
+
const source = prior.context.eventSourceId;
|
|
115
|
+
if (removals.has(type)) {
|
|
116
|
+
claims.delete(source);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const property = unique.eventDefinitions.find(entry => entry.eventTypeId === type)?.properties[0];
|
|
120
|
+
if (property === undefined) continue;
|
|
121
|
+
const value = prior.content[property];
|
|
122
|
+
if (typeof value !== 'string' && typeof value !== 'boolean') throw this.unsupported(name, 'History has an unproven key.');
|
|
123
|
+
claims.set(source, { key: keyHash(kernelKeyString(value, name)),
|
|
124
|
+
sequence: prior.context.sequenceNumber.toString() });
|
|
125
|
+
}
|
|
126
|
+
owners.set(name, claims);
|
|
127
|
+
}
|
|
128
|
+
const stagedKeys = new Map<string, Map<string, string>>();
|
|
55
129
|
const stagedTypes = new Set<string>();
|
|
56
130
|
for (const event of incoming) {
|
|
57
131
|
const type = event.eventType.id.value;
|
|
@@ -59,45 +133,20 @@ export class InProcessConstraints {
|
|
|
59
133
|
for (const [name, definition] of this._definitions) {
|
|
60
134
|
const property = definition.uniqueConstraint?.eventDefinitions.find(entry => entry.eventTypeId === type)?.properties[0];
|
|
61
135
|
if (property !== undefined) {
|
|
62
|
-
const raw = event.content[property];
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const claims = new Map<string, { source: string; sequence: string }>();
|
|
69
|
-
const owners = new Map<string, string>();
|
|
70
|
-
for (const prior of history) {
|
|
71
|
-
const priorProperty = definition.uniqueConstraint!.eventDefinitions.find(entry =>
|
|
72
|
-
entry.eventTypeId === prior.eventType.id.value)?.properties[0];
|
|
73
|
-
if (priorProperty !== undefined) {
|
|
74
|
-
const priorValue = prior.content[priorProperty];
|
|
75
|
-
if (typeof priorValue !== 'string') throw this.unsupported(name, 'History has an unproven key.');
|
|
76
|
-
owners.set(prior.context.eventSourceId, createHash('sha256').update(priorValue).digest('hex'));
|
|
77
|
-
claims.set(createHash('sha256').update(priorValue).digest('hex'), {
|
|
78
|
-
source: prior.context.eventSourceId, sequence: prior.context.sequenceNumber.toString()
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
const ownerKey = owners.get(source);
|
|
83
|
-
if (ownerKey !== undefined && ownerKey !== key) {
|
|
84
|
-
throw this.unsupported(name, 'Replacing a unique key is not fixture-backed.');
|
|
85
|
-
}
|
|
86
|
-
const stagedOwner = stagedKeys.get(`${name}\u0000${source}`);
|
|
87
|
-
if (stagedOwner !== undefined && stagedOwner !== key) {
|
|
88
|
-
throw this.unsupported(name, 'Replacing a key within one batch is not fixture-backed.');
|
|
89
|
-
}
|
|
90
|
-
const existing = claims.get(key);
|
|
91
|
-
const batchOwner = stagedKeys.get(`${name}\u0001${key}`);
|
|
92
|
-
if (existing && existing.source !== source || batchOwner && batchOwner !== source) {
|
|
136
|
+
const raw = kernelKeyString(event.content[property], name);
|
|
137
|
+
const key = keyHash(raw);
|
|
138
|
+
const existing = [...owners.get(name)!.entries()].find(([, claim]) => claim.key === key);
|
|
139
|
+
const batchClaims = stagedKeys.get(name) ?? new Map<string, string>();
|
|
140
|
+
const batchOwner = batchClaims.get(key);
|
|
141
|
+
if (existing && existing[0] !== source || batchOwner !== undefined && batchOwner !== source) {
|
|
93
142
|
const details = { PropertyName: property, PropertyValue: raw };
|
|
94
|
-
violations.push({ EventTypeId: type, SequenceNumber: BigInt(existing?.sequence ?? unavailable), ConstraintType: 1,
|
|
143
|
+
violations.push({ EventTypeId: type, SequenceNumber: BigInt(existing?.[1].sequence ?? unavailable), ConstraintType: 1,
|
|
95
144
|
ConstraintName: name,
|
|
96
|
-
Message: `Event '${type}' on member '${property}' violated a unique constraint on sequence number ${existing?.sequence ?? unavailable}`,
|
|
145
|
+
Message: `Event '${type}' on member '${property}' violated a unique constraint on sequence number ${existing?.[1].sequence ?? unavailable}`,
|
|
97
146
|
Details: details });
|
|
98
147
|
} else {
|
|
99
|
-
|
|
100
|
-
stagedKeys.set(
|
|
148
|
+
batchClaims.set(key, source);
|
|
149
|
+
stagedKeys.set(name, batchClaims);
|
|
101
150
|
}
|
|
102
151
|
}
|
|
103
152
|
const uniqueType = definition.uniqueEventType;
|
|
@@ -94,7 +94,8 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
94
94
|
if (eventType.generation.value !== 1 || eventType.tombstone) {
|
|
95
95
|
throw this.unsupported('artifacts.eventTypes', type.name, 'Only generation 1, non-tombstone events are proven.');
|
|
96
96
|
}
|
|
97
|
-
if (options.constraints !== 'disabled' && getRemovedConstraintNames(type).length
|
|
97
|
+
if (options.constraints !== 'disabled' && getRemovedConstraintNames(type).length &&
|
|
98
|
+
!this._constraints?.hasRemovalType(eventType.id.value)) {
|
|
98
99
|
throw this.unsupported('artifacts.eventTypes.constraints', type.name, 'Constraint removal is not fixture-backed.');
|
|
99
100
|
}
|
|
100
101
|
if (getTagsFor(type).length) throw this.unsupported('artifacts.eventTypes.tags', type.name, 'Tagged events are not fixture-backed.');
|
|
@@ -104,7 +105,8 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
104
105
|
schema.enum !== undefined || schema.items !== undefined || schema.format !== undefined ||
|
|
105
106
|
schema.additionalProperties !== false ||
|
|
106
107
|
schema.required?.length !== Object.keys(schema.properties ?? {}).length ||
|
|
107
|
-
!schema.properties || Object.keys(schema.properties).length === 0
|
|
108
|
+
!schema.properties || (Object.keys(schema.properties).length === 0 &&
|
|
109
|
+
!this._constraints?.isRemovalOnlyType(eventType.id.value)) ||
|
|
108
110
|
Object.entries(schema.properties).some(([key, property]) =>
|
|
109
111
|
!/^[a-z][a-zA-Z0-9]*$/.test(key) || !['string', 'boolean'].includes(property.type ?? '') ||
|
|
110
112
|
property.format !== undefined || property.compliance?.length || property.security?.length ||
|
|
@@ -170,8 +172,10 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
170
172
|
let content: Record<string, unknown>;
|
|
171
173
|
try {
|
|
172
174
|
prepared = prepareSingleAppend(event, this._correlationId ? { correlationId: this._correlationId() } : undefined);
|
|
173
|
-
content =
|
|
175
|
+
content = this.checkedContent(event, prepared.content, 'append.content',
|
|
176
|
+
'Content differs from the flat scalar schema; null, missing and extra values are not proven.');
|
|
174
177
|
} catch (error) {
|
|
178
|
+
if (error instanceof UnsupportedEventSequenceOperation) throw error;
|
|
175
179
|
throw this.unsupported('append.serialization', event.constructor.name, `Payload or metadata could not be serialized: ${String(error)}.`);
|
|
176
180
|
}
|
|
177
181
|
try {
|
|
@@ -184,13 +188,6 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
184
188
|
Object.keys(prepared.causationChain[0].properties).length !== 0) {
|
|
185
189
|
throw this.unsupported('append.context', event.constructor.name, 'Ambient identity or causation metadata is not fixture-backed.');
|
|
186
190
|
}
|
|
187
|
-
const properties = metadata.schema.properties!;
|
|
188
|
-
if (!content || typeof content !== 'object' || Array.isArray(content) ||
|
|
189
|
-
Object.keys(content).length !== Object.keys(properties).length ||
|
|
190
|
-
Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
|
|
191
|
-
(property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
|
|
192
|
-
throw this.unsupported('append.content', event.constructor.name, 'Content differs from the flat scalar schema; null, missing and extra values are not proven.');
|
|
193
|
-
}
|
|
194
191
|
const occurred = this._clock();
|
|
195
192
|
// Date has millisecond precision; only the UTC year range representable by DateTimeOffset is supported.
|
|
196
193
|
if (!(occurred instanceof Date) || Number.isNaN(occurred.getTime()) ||
|
|
@@ -432,16 +429,20 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
432
429
|
}
|
|
433
430
|
}
|
|
434
431
|
|
|
435
|
-
private checkedContent(event: object, serialized: string, operation: string
|
|
432
|
+
private checkedContent(event: object, serialized: string, operation: string,
|
|
433
|
+
mismatchReason = 'Content differs from the fixture-backed scalar schema.'): Record<string, unknown> {
|
|
436
434
|
let content: Record<string, unknown>;
|
|
437
435
|
try { content = JSON.parse(serialized) as Record<string, unknown>; }
|
|
438
436
|
catch { throw this.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); }
|
|
439
|
-
const
|
|
437
|
+
const metadata = this._catalog.get(event.constructor)!;
|
|
438
|
+
const properties = metadata.schema.properties!;
|
|
440
439
|
if (!content || typeof content !== 'object' || Array.isArray(content) ||
|
|
441
440
|
Object.keys(content).length !== Object.keys(properties).length ||
|
|
442
441
|
Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
|
|
442
|
+
(this._constraints?.isConstrainedProperty(metadata.eventType.id.value, key) &&
|
|
443
|
+
(typeof Reflect.get(event, key) !== property.type || Reflect.get(event, key) !== content[key])) ||
|
|
443
444
|
(property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
|
|
444
|
-
throw this.unsupported(operation, event.constructor.name,
|
|
445
|
+
throw this.unsupported(operation, event.constructor.name, mismatchReason);
|
|
445
446
|
}
|
|
446
447
|
return content;
|
|
447
448
|
}
|