@cratis/chronicle 6.24.0 → 6.26.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 +8 -1
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/InProcessConstraints.d.ts +4 -0
- package/dist/testing/InProcessConstraints.d.ts.map +1 -1
- package/dist/testing/InProcessConstraints.js +123 -47
- package/dist/testing/InProcessConstraints.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +4 -2
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/package.json +1 -1
- package/testing/EventScenario.constraint-cycles.spec.ts +236 -0
- package/testing/EventScenario.constraint-lifecycle.spec.ts +231 -0
- package/testing/EventScenario.constraints.spec.ts +16 -27
- package/testing/EventScenario.fixtures.spec.ts +3 -1
- package/testing/EventScenario.rejections.spec.ts +1 -1
- package/testing/EventScenario.ts +9 -1
- package/testing/InProcessConstraints.ts +117 -46
- package/testing/InProcessEventSequence.ts +4 -2
|
@@ -29,9 +29,12 @@ function keyHash(value: string): string {
|
|
|
29
29
|
/** Narrow, fixture-backed unscoped constraint validation over serialized event snapshots. */
|
|
30
30
|
export class InProcessConstraints {
|
|
31
31
|
private readonly _constrainedProperties = new Map<string, Set<string>>();
|
|
32
|
+
private readonly _removalTypes = new Set<string>();
|
|
33
|
+
private readonly _propertyRemovalTypes = new Set<string>();
|
|
32
34
|
|
|
33
35
|
constructor(private readonly _definitions: ReadonlyMap<string, ConstraintCapture>) {
|
|
34
|
-
const coveredTypes = new
|
|
36
|
+
const coveredTypes = new Map<string, string>();
|
|
37
|
+
const removalOwners = new Map<string, string>();
|
|
35
38
|
for (const [name, capture] of _definitions) {
|
|
36
39
|
if (capture.uniqueConstraint && capture.uniqueEventType) {
|
|
37
40
|
throw this.unsupported(name, 'A definition with both constraint kinds is not fixture-backed.');
|
|
@@ -41,10 +44,20 @@ export class InProcessConstraints {
|
|
|
41
44
|
}
|
|
42
45
|
if (capture.uniqueConstraint) {
|
|
43
46
|
const unique = capture.uniqueConstraint;
|
|
44
|
-
if (unique.ignoreCasing || unique.
|
|
45
|
-
unique.eventDefinitions.
|
|
47
|
+
if (unique.ignoreCasing || unique.eventDefinitions.length === 0 ||
|
|
48
|
+
unique.eventDefinitions.some(entry => entry.properties.length !== 1 ||
|
|
46
49
|
!/^[a-z][a-zA-Z0-9]*$/.test(entry.properties[0]))) {
|
|
47
|
-
throw this.unsupported(name, 'Case folding
|
|
50
|
+
throw this.unsupported(name, 'Case folding and composite or nested keys are not fixture-backed.');
|
|
51
|
+
}
|
|
52
|
+
for (const id of [unique.removedWithEventTypeId, ...(unique.removedWithEventTypeIds ?? [])]) {
|
|
53
|
+
if (id === undefined) continue;
|
|
54
|
+
const owner = removalOwners.get(id);
|
|
55
|
+
if (owner !== undefined && owner !== name) {
|
|
56
|
+
throw this.unsupported(name, 'Removal type shared by several definitions is not fixture-backed.');
|
|
57
|
+
}
|
|
58
|
+
removalOwners.set(id, name);
|
|
59
|
+
this._removalTypes.add(id);
|
|
60
|
+
this._propertyRemovalTypes.add(id);
|
|
48
61
|
}
|
|
49
62
|
for (const entry of unique.eventDefinitions) {
|
|
50
63
|
const properties = this._constrainedProperties.get(entry.eventTypeId) ?? new Set<string>();
|
|
@@ -53,9 +66,23 @@ export class InProcessConstraints {
|
|
|
53
66
|
}
|
|
54
67
|
} else if (capture.uniqueEventType) {
|
|
55
68
|
const unique = capture.uniqueEventType;
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
const ids = unique.eventTypeIds ?? [unique.eventTypeId];
|
|
70
|
+
const removals = unique.removedWithEventTypeIds ?? [];
|
|
71
|
+
const shared = ids.filter(id => removals.includes(id)).length;
|
|
72
|
+
// Exactly the shapes installed by the pinned fixtures: one covered type without removal
|
|
73
|
+
// (constraints.json), two covered types with two separate removers
|
|
74
|
+
// (constraints-event-type-siblings.json), and three covered types with three removers of
|
|
75
|
+
// which one is also covered (constraints-event-type-cycles.json).
|
|
76
|
+
if (!(ids.length === 1 && removals.length === 0) &&
|
|
77
|
+
!(ids.length === 2 && removals.length === 2 && shared === 0) &&
|
|
78
|
+
!(ids.length === 3 && removals.length === 3 && shared === 1)) {
|
|
79
|
+
throw this.unsupported(name, 'This unique event type set and removal combination is not fixture-backed.');
|
|
80
|
+
}
|
|
81
|
+
// Each cycle fixture installs its definition alone; interaction with other definitions is unproven.
|
|
82
|
+
if (removals.length && _definitions.size !== 1) {
|
|
83
|
+
throw this.unsupported(name, 'Unique event cycles alongside other definitions are not fixture-backed.');
|
|
58
84
|
}
|
|
85
|
+
removals.forEach(id => this._removalTypes.add(id));
|
|
59
86
|
} else {
|
|
60
87
|
throw this.unsupported(name, 'Unknown constraint definition.');
|
|
61
88
|
}
|
|
@@ -63,11 +90,26 @@ export class InProcessConstraints {
|
|
|
63
90
|
capture.uniqueEventType?.eventTypeIds ?? [capture.uniqueEventType!.eventTypeId];
|
|
64
91
|
for (const id of ids) {
|
|
65
92
|
if (coveredTypes.has(id)) throw this.unsupported(name, 'Overlapping constraints are not fixture-backed.');
|
|
66
|
-
coveredTypes.
|
|
93
|
+
coveredTypes.set(id, name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const [name, definition] of _definitions) {
|
|
97
|
+
const unique = definition.uniqueConstraint;
|
|
98
|
+
if (!unique) continue;
|
|
99
|
+
for (const id of [unique.removedWithEventTypeId, ...(unique.removedWithEventTypeIds ?? [])]) {
|
|
100
|
+
if (id !== undefined && coveredTypes.has(id) && coveredTypes.get(id) !== name) {
|
|
101
|
+
throw this.unsupported(name, 'Removal overlapping another validating definition is not fixture-backed.');
|
|
102
|
+
}
|
|
67
103
|
}
|
|
68
104
|
}
|
|
69
105
|
}
|
|
70
106
|
|
|
107
|
+
isRemovalOnlyType(eventTypeId: string): boolean {
|
|
108
|
+
return this._propertyRemovalTypes.has(eventTypeId) && !this._constrainedProperties.has(eventTypeId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
hasRemovalType(eventTypeId: string): boolean { return this._removalTypes.has(eventTypeId); }
|
|
112
|
+
|
|
71
113
|
isConstrainedProperty(eventTypeId: string, property: string): boolean {
|
|
72
114
|
return this._constrainedProperties.get(eventTypeId)?.has(property) ?? false;
|
|
73
115
|
}
|
|
@@ -75,63 +117,92 @@ export class InProcessConstraints {
|
|
|
75
117
|
/** Validate against committed history and all earlier entries of this append, without changing state. */
|
|
76
118
|
validate(history: readonly AppendedEvent[], incoming: readonly AppendedEvent[]): WireConstraintViolation[] {
|
|
77
119
|
const violations: WireConstraintViolation[] = [];
|
|
78
|
-
|
|
79
|
-
|
|
120
|
+
// Durable ownership is one claim per source, not one entry per historical value.
|
|
121
|
+
// Rebuild it from committed history; batch claims below remain independent and are never released.
|
|
122
|
+
const owners = new Map<string, Map<string, { key: string; sequence: string }>>();
|
|
123
|
+
for (const [name, definition] of this._definitions) {
|
|
124
|
+
if (!definition.uniqueConstraint) continue;
|
|
125
|
+
const claims = new Map<string, { key: string; sequence: string }>();
|
|
126
|
+
const unique = definition.uniqueConstraint;
|
|
127
|
+
const removals = new Set([unique.removedWithEventTypeId, ...(unique.removedWithEventTypeIds ?? [])]);
|
|
128
|
+
for (const prior of history) {
|
|
129
|
+
const type = prior.eventType.id.value;
|
|
130
|
+
const source = prior.context.eventSourceId;
|
|
131
|
+
if (removals.has(type)) {
|
|
132
|
+
claims.delete(source);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const property = unique.eventDefinitions.find(entry => entry.eventTypeId === type)?.properties[0];
|
|
136
|
+
if (property === undefined) continue;
|
|
137
|
+
const value = prior.content[property];
|
|
138
|
+
if (typeof value !== 'string' && typeof value !== 'boolean') throw this.unsupported(name, 'History has an unproven key.');
|
|
139
|
+
claims.set(source, { key: keyHash(kernelKeyString(value, name)),
|
|
140
|
+
sequence: prior.context.sequenceNumber.toString() });
|
|
141
|
+
}
|
|
142
|
+
owners.set(name, claims);
|
|
143
|
+
}
|
|
144
|
+
const stagedKeys = new Map<string, Map<string, string>>();
|
|
145
|
+
const stagedCycles = new Map<string, Map<string, 'open' | 'released'>>();
|
|
80
146
|
for (const event of incoming) {
|
|
81
147
|
const type = event.eventType.id.value;
|
|
82
148
|
const source = event.context.eventSourceId;
|
|
149
|
+
const violationCount = violations.length;
|
|
83
150
|
for (const [name, definition] of this._definitions) {
|
|
84
151
|
const property = definition.uniqueConstraint?.eventDefinitions.find(entry => entry.eventTypeId === type)?.properties[0];
|
|
85
152
|
if (property !== undefined) {
|
|
86
153
|
const raw = kernelKeyString(event.content[property], name);
|
|
87
154
|
const key = keyHash(raw);
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
entry.eventTypeId === prior.eventType.id.value)?.properties[0];
|
|
93
|
-
if (priorProperty !== undefined) {
|
|
94
|
-
const priorValue = prior.content[priorProperty];
|
|
95
|
-
if (typeof priorValue !== 'string' && typeof priorValue !== 'boolean') throw this.unsupported(name, 'History has an unproven key.');
|
|
96
|
-
const priorHash = keyHash(kernelKeyString(priorValue, name));
|
|
97
|
-
owners.set(prior.context.eventSourceId, priorHash);
|
|
98
|
-
claims.set(priorHash, {
|
|
99
|
-
source: prior.context.eventSourceId, sequence: prior.context.sequenceNumber.toString()
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const ownerKey = owners.get(source);
|
|
104
|
-
if (ownerKey !== undefined && ownerKey !== key) {
|
|
105
|
-
throw this.unsupported(name, 'Replacing a unique key is not fixture-backed.');
|
|
106
|
-
}
|
|
107
|
-
const stagedOwner = stagedKeys.get(`${name}\u0000${source}`);
|
|
108
|
-
if (stagedOwner !== undefined && stagedOwner !== key) {
|
|
109
|
-
throw this.unsupported(name, 'Replacing a key within one batch is not fixture-backed.');
|
|
110
|
-
}
|
|
111
|
-
const existing = claims.get(key);
|
|
112
|
-
const batchOwner = stagedKeys.get(`${name}\u0001${key}`);
|
|
113
|
-
if (existing && existing.source !== source || batchOwner && batchOwner !== source) {
|
|
155
|
+
const existing = [...owners.get(name)!.entries()].find(([, claim]) => claim.key === key);
|
|
156
|
+
const batchClaims = stagedKeys.get(name) ?? new Map<string, string>();
|
|
157
|
+
const batchOwner = batchClaims.get(key);
|
|
158
|
+
if (existing && existing[0] !== source || batchOwner !== undefined && batchOwner !== source) {
|
|
114
159
|
const details = { PropertyName: property, PropertyValue: raw };
|
|
115
|
-
violations.push({ EventTypeId: type, SequenceNumber: BigInt(existing?.sequence ?? unavailable), ConstraintType: 1,
|
|
160
|
+
violations.push({ EventTypeId: type, SequenceNumber: BigInt(existing?.[1].sequence ?? unavailable), ConstraintType: 1,
|
|
116
161
|
ConstraintName: name,
|
|
117
|
-
Message: `Event '${type}' on member '${property}' violated a unique constraint on sequence number ${existing?.sequence ?? unavailable}`,
|
|
162
|
+
Message: `Event '${type}' on member '${property}' violated a unique constraint on sequence number ${existing?.[1].sequence ?? unavailable}`,
|
|
118
163
|
Details: details });
|
|
119
164
|
} else {
|
|
120
|
-
|
|
121
|
-
stagedKeys.set(
|
|
165
|
+
batchClaims.set(key, source);
|
|
166
|
+
stagedKeys.set(name, batchClaims);
|
|
122
167
|
}
|
|
123
168
|
}
|
|
124
169
|
const uniqueType = definition.uniqueEventType;
|
|
125
170
|
if (uniqueType && (uniqueType.eventTypeIds ?? [uniqueType.eventTypeId]).includes(type)) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
171
|
+
const covered = uniqueType.eventTypeIds ?? [uniqueType.eventTypeId];
|
|
172
|
+
const removals = uniqueType.removedWithEventTypeIds ?? [];
|
|
173
|
+
// Durable answer: the earliest covered event after the latest removal for this source.
|
|
174
|
+
let first: AppendedEvent | undefined;
|
|
175
|
+
for (const prior of history) {
|
|
176
|
+
if (prior.context.eventSourceId !== source) continue;
|
|
177
|
+
const priorType = prior.eventType.id.value;
|
|
178
|
+
if (removals.includes(priorType)) first = undefined;
|
|
179
|
+
else if (!first && covered.includes(priorType)) first = prior;
|
|
180
|
+
}
|
|
181
|
+
const cycles = stagedCycles.get(name) ?? new Map<string, 'open' | 'released'>();
|
|
182
|
+
const state = cycles.get(source);
|
|
183
|
+
// A cycle state recorded earlier in this append overrides durable history.
|
|
184
|
+
if (state === 'open' || state === undefined && first) {
|
|
185
|
+
// Durable storage supplies the sequence even when a staged claim causes rejection.
|
|
186
|
+
const sequence = first?.context.sequenceNumber.toString() ?? unavailable;
|
|
187
|
+
violations.push({ EventTypeId: type, SequenceNumber: BigInt(sequence), ConstraintType: 2,
|
|
131
188
|
ConstraintName: name,
|
|
132
|
-
Message: `Event '${type}' violated a unique event type constraint on sequence number ${
|
|
189
|
+
Message: `Event '${type}' violated a unique event type constraint on sequence number ${sequence}`,
|
|
133
190
|
Details: {} });
|
|
134
|
-
} else
|
|
191
|
+
} else {
|
|
192
|
+
cycles.set(source, 'open');
|
|
193
|
+
stagedCycles.set(name, cycles);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Kernel batch observers release a cycle only after the entire event validates.
|
|
198
|
+
// A covered-and-removal event first attempts its claim, then releases it.
|
|
199
|
+
if (violations.length === violationCount) {
|
|
200
|
+
for (const [name, definition] of this._definitions) {
|
|
201
|
+
if (definition.uniqueEventType?.removedWithEventTypeIds?.includes(type)) {
|
|
202
|
+
const cycles = stagedCycles.get(name) ?? new Map<string, 'open' | 'released'>();
|
|
203
|
+
cycles.set(source, 'released');
|
|
204
|
+
stagedCycles.set(name, cycles);
|
|
205
|
+
}
|
|
135
206
|
}
|
|
136
207
|
}
|
|
137
208
|
}
|
|
@@ -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 ||
|