@cratis/chronicle 6.22.1 → 6.24.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.
Files changed (40) hide show
  1. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  2. package/dist/eventSequences/EventSequence.js +2 -27
  3. package/dist/eventSequences/EventSequence.js.map +1 -1
  4. package/dist/eventSequences/createAppendResult.d.ts +13 -0
  5. package/dist/eventSequences/createAppendResult.d.ts.map +1 -0
  6. package/dist/eventSequences/createAppendResult.js +31 -0
  7. package/dist/eventSequences/createAppendResult.js.map +1 -0
  8. package/dist/events/constraints/Constraints.d.ts +8 -3
  9. package/dist/events/constraints/Constraints.d.ts.map +1 -1
  10. package/dist/events/constraints/Constraints.js +135 -125
  11. package/dist/events/constraints/Constraints.js.map +1 -1
  12. package/dist/testing/EventScenario.d.ts.map +1 -1
  13. package/dist/testing/EventScenario.js +70 -10
  14. package/dist/testing/EventScenario.js.map +1 -1
  15. package/dist/testing/InProcessConstraints.d.ts +17 -0
  16. package/dist/testing/InProcessConstraints.d.ts.map +1 -0
  17. package/dist/testing/InProcessConstraints.js +142 -0
  18. package/dist/testing/InProcessConstraints.js.map +1 -0
  19. package/dist/testing/InProcessEventSequence.d.ts +5 -1
  20. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  21. package/dist/testing/InProcessEventSequence.js +43 -26
  22. package/dist/testing/InProcessEventSequence.js.map +1 -1
  23. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  24. package/dist/testing/ReactorScenario.js +3 -2
  25. package/dist/testing/ReactorScenario.js.map +1 -1
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/eventSequences/EventSequence.ts +3 -32
  28. package/eventSequences/createAppendResult.ts +44 -0
  29. package/events/constraints/Constraints.ts +57 -47
  30. package/events/constraints/for_Constraints/when_resolving_violation_messages.spec.ts +28 -0
  31. package/package.json +1 -1
  32. package/testing/EventScenario.constraint-discovery.spec.ts +69 -0
  33. package/testing/EventScenario.constraint-values.spec.ts +199 -0
  34. package/testing/EventScenario.constraints.spec.ts +415 -0
  35. package/testing/EventScenario.fixtures.spec.ts +5 -3
  36. package/testing/EventScenario.rejections.spec.ts +10 -3
  37. package/testing/EventScenario.ts +72 -11
  38. package/testing/InProcessConstraints.ts +148 -0
  39. package/testing/InProcessEventSequence.ts +42 -25
  40. package/testing/ReactorScenario.ts +3 -1
@@ -0,0 +1,148 @@
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 { createHash } from 'node:crypto';
5
+ import type { ConstraintViolation as ContractsConstraintViolation } from '@cratis/chronicle.contracts';
6
+ import type { AppendedEvent } from '../events/AppendedEvent.js';
7
+ import type { ConstraintCapture } from '../events/constraints/ConstraintBuilder.js';
8
+ import { resolveConstraintMessage } from '../events/constraints/Constraints.js';
9
+ import type { ConstraintViolation } from '../eventSequences/ConstraintViolation.js';
10
+ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
11
+
12
+ export type WireConstraintViolation = ContractsConstraintViolation;
13
+
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
+ }
28
+
29
+ /** Narrow, fixture-backed unscoped constraint validation over serialized event snapshots. */
30
+ export class InProcessConstraints {
31
+ private readonly _constrainedProperties = new Map<string, Set<string>>();
32
+
33
+ constructor(private readonly _definitions: ReadonlyMap<string, ConstraintCapture>) {
34
+ const coveredTypes = new Set<string>();
35
+ for (const [name, capture] of _definitions) {
36
+ if (capture.uniqueConstraint && capture.uniqueEventType) {
37
+ throw this.unsupported(name, 'A definition with both constraint kinds is not fixture-backed.');
38
+ }
39
+ if (capture.scope.perEventSourceType || capture.scope.perEventStreamType || capture.scope.perEventStreamId) {
40
+ throw this.unsupported(name, 'Scoped constraints are not fixture-backed.');
41
+ }
42
+ if (capture.uniqueConstraint) {
43
+ const unique = capture.uniqueConstraint;
44
+ if (unique.ignoreCasing || unique.removedWithEventTypeId || unique.removedWithEventTypeIds?.length ||
45
+ unique.eventDefinitions.length === 0 || unique.eventDefinitions.some(entry => entry.properties.length !== 1 ||
46
+ !/^[a-z][a-zA-Z0-9]*$/.test(entry.properties[0]))) {
47
+ throw this.unsupported(name, 'Case folding, removal and composite or nested keys are not fixture-backed.');
48
+ }
49
+ for (const entry of unique.eventDefinitions) {
50
+ const properties = this._constrainedProperties.get(entry.eventTypeId) ?? new Set<string>();
51
+ properties.add(entry.properties[0]);
52
+ this._constrainedProperties.set(entry.eventTypeId, properties);
53
+ }
54
+ } else if (capture.uniqueEventType) {
55
+ const unique = capture.uniqueEventType;
56
+ if (unique.removedWithEventTypeIds?.length || (unique.eventTypeIds?.length ?? 1) !== 1) {
57
+ throw this.unsupported(name, 'Removal and multi-type unique event cycles are not fixture-backed.');
58
+ }
59
+ } else {
60
+ throw this.unsupported(name, 'Unknown constraint definition.');
61
+ }
62
+ const ids = capture.uniqueConstraint?.eventDefinitions.map(entry => entry.eventTypeId) ??
63
+ capture.uniqueEventType?.eventTypeIds ?? [capture.uniqueEventType!.eventTypeId];
64
+ for (const id of ids) {
65
+ if (coveredTypes.has(id)) throw this.unsupported(name, 'Overlapping constraints are not fixture-backed.');
66
+ coveredTypes.add(id);
67
+ }
68
+ }
69
+ }
70
+
71
+ isConstrainedProperty(eventTypeId: string, property: string): boolean {
72
+ return this._constrainedProperties.get(eventTypeId)?.has(property) ?? false;
73
+ }
74
+
75
+ /** Validate against committed history and all earlier entries of this append, without changing state. */
76
+ validate(history: readonly AppendedEvent[], incoming: readonly AppendedEvent[]): WireConstraintViolation[] {
77
+ const violations: WireConstraintViolation[] = [];
78
+ const stagedKeys = new Map<string, string>();
79
+ const stagedTypes = new Set<string>();
80
+ for (const event of incoming) {
81
+ const type = event.eventType.id.value;
82
+ const source = event.context.eventSourceId;
83
+ for (const [name, definition] of this._definitions) {
84
+ const property = definition.uniqueConstraint?.eventDefinitions.find(entry => entry.eventTypeId === type)?.properties[0];
85
+ if (property !== undefined) {
86
+ const raw = kernelKeyString(event.content[property], name);
87
+ const key = keyHash(raw);
88
+ const claims = new Map<string, { source: string; sequence: string }>();
89
+ const owners = new Map<string, string>();
90
+ for (const prior of history) {
91
+ const priorProperty = definition.uniqueConstraint!.eventDefinitions.find(entry =>
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) {
114
+ const details = { PropertyName: property, PropertyValue: raw };
115
+ violations.push({ EventTypeId: type, SequenceNumber: BigInt(existing?.sequence ?? unavailable), ConstraintType: 1,
116
+ ConstraintName: name,
117
+ Message: `Event '${type}' on member '${property}' violated a unique constraint on sequence number ${existing?.sequence ?? unavailable}`,
118
+ Details: details });
119
+ } else {
120
+ stagedKeys.set(`${name}\u0000${source}`, key);
121
+ stagedKeys.set(`${name}\u0001${key}`, source);
122
+ }
123
+ }
124
+ const uniqueType = definition.uniqueEventType;
125
+ if (uniqueType && (uniqueType.eventTypeIds ?? [uniqueType.eventTypeId]).includes(type)) {
126
+ const first = history.find(prior => prior.context.eventSourceId === source &&
127
+ (uniqueType.eventTypeIds ?? [uniqueType.eventTypeId]).includes(prior.eventType.id.value));
128
+ const staged = stagedTypes.has(`${name}\u0000${source}`);
129
+ if (first || staged) {
130
+ violations.push({ EventTypeId: type, SequenceNumber: first?.context.sequenceNumber ?? BigInt(unavailable), ConstraintType: 2,
131
+ ConstraintName: name,
132
+ Message: `Event '${type}' violated a unique event type constraint on sequence number ${first?.context.sequenceNumber.toString() ?? unavailable}`,
133
+ Details: {} });
134
+ } else stagedTypes.add(`${name}\u0000${source}`);
135
+ }
136
+ }
137
+ }
138
+ return violations;
139
+ }
140
+
141
+ resolveMessage(violation: ConstraintViolation): ConstraintViolation {
142
+ return resolveConstraintMessage(this._definitions, violation);
143
+ }
144
+
145
+ private unsupported(name: string, reason: string): UnsupportedEventSequenceOperation {
146
+ return new UnsupportedEventSequenceOperation('artifacts.constraints', name, reason);
147
+ }
148
+ }
@@ -6,6 +6,7 @@ import { EventObservationState } from '@cratis/chronicle.contracts';
6
6
  import { AppendOperationsBroadcaster } from '../eventSequences/AppendOperationsBroadcaster.js';
7
7
  import { prepareBatchAppend } from '../eventSequences/prepareBatchAppend.js';
8
8
  import { createAppendNotification, mapAppendNotificationCausation } from '../eventSequences/createAppendNotification.js';
9
+ import { createAppendResult } from '../eventSequences/createAppendResult.js';
9
10
  import { Guid, type Constructor } from '@cratis/fundamentals';
10
11
  import { getEventTypeMetadata, getEventTypeFor } from '../events/eventTypeDecorator.js';
11
12
  import { getTagsFor } from '../events/tagDecorator.js';
@@ -26,8 +27,8 @@ import type { ITransactionalEventSequence } from '../eventSequences/ITransaction
26
27
  import type { CompleteStreamResult } from '../eventSequences/CompleteStreamResult.js';
27
28
  import { prepareSingleAppend } from '../eventSequences/prepareSingleAppend.js';
28
29
  import { toContractsGuid } from '../connection/Guid.js';
29
- import { getUniqueEventMetadata, getUniquePropertyMetadata } from '../events/constraints/unique.js';
30
30
  import { getRemovedConstraintNames } from '../events/constraints/removeConstraint.js';
31
+ import { InProcessConstraints, type WireConstraintViolation } from './InProcessConstraints.js';
31
32
  import type { EventScenarioOptions } from './EventScenarioOptions.js';
32
33
  import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
33
34
 
@@ -64,7 +65,7 @@ export class InProcessEventSequence implements IEventSequence {
64
65
  private readonly _results: AppendResult[] = [];
65
66
  readonly appendOperations = new AppendOperationsBroadcaster<AppendedEventWithResult[]>();
66
67
 
67
- constructor(options: EventScenarioOptions, eventTypes: Constructor[]) {
68
+ constructor(options: EventScenarioOptions, eventTypes: Constructor[], private readonly _constraints?: InProcessConstraints) {
68
69
  if (options.eventSequenceId && options.eventSequenceId.value !== EventSequenceId.eventLog.value) {
69
70
  throw this.unsupported('options.eventSequenceId', options.eventSequenceId.value, 'Custom sequences are not fixture-backed.');
70
71
  }
@@ -93,9 +94,8 @@ export class InProcessEventSequence implements IEventSequence {
93
94
  if (eventType.generation.value !== 1 || eventType.tombstone) {
94
95
  throw this.unsupported('artifacts.eventTypes', type.name, 'Only generation 1, non-tombstone events are proven.');
95
96
  }
96
- if (options.constraints !== 'disabled' && (getUniqueEventMetadata(type) || getRemovedConstraintNames(type).length ||
97
- [...metadata.members.keys()].some(key => getUniquePropertyMetadata(type, key)))) {
98
- throw this.unsupported('artifacts.eventTypes.constraints', type.name, 'Constraint metadata is not supported in this increment.');
97
+ if (options.constraints !== 'disabled' && getRemovedConstraintNames(type).length) {
98
+ throw this.unsupported('artifacts.eventTypes.constraints', type.name, 'Constraint removal is not fixture-backed.');
99
99
  }
100
100
  if (getTagsFor(type).length) throw this.unsupported('artifacts.eventTypes.tags', type.name, 'Tagged events are not fixture-backed.');
101
101
  // Validate the complete schema, not only properties present on a particular event.
@@ -132,7 +132,8 @@ export class InProcessEventSequence implements IEventSequence {
132
132
  try { pending = this.append(source, event); }
133
133
  finally { this._allowSeedAppend = false; }
134
134
  const result = await pending;
135
- if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result)}`);
135
+ if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result, (_, value) =>
136
+ typeof value === 'bigint' ? value.toString() : value)}`);
136
137
  }
137
138
  for (const [index, stored] of this._stagedSeeds.entries()) {
138
139
  this._history.push(stored);
@@ -169,8 +170,10 @@ export class InProcessEventSequence implements IEventSequence {
169
170
  let content: Record<string, unknown>;
170
171
  try {
171
172
  prepared = prepareSingleAppend(event, this._correlationId ? { correlationId: this._correlationId() } : undefined);
172
- content = JSON.parse(prepared.content) as Record<string, unknown>;
173
+ content = this.checkedContent(event, prepared.content, 'append.content',
174
+ 'Content differs from the flat scalar schema; null, missing and extra values are not proven.');
173
175
  } catch (error) {
176
+ if (error instanceof UnsupportedEventSequenceOperation) throw error;
174
177
  throw this.unsupported('append.serialization', event.constructor.name, `Payload or metadata could not be serialized: ${String(error)}.`);
175
178
  }
176
179
  try {
@@ -183,13 +186,6 @@ export class InProcessEventSequence implements IEventSequence {
183
186
  Object.keys(prepared.causationChain[0].properties).length !== 0) {
184
187
  throw this.unsupported('append.context', event.constructor.name, 'Ambient identity or causation metadata is not fixture-backed.');
185
188
  }
186
- const properties = metadata.schema.properties!;
187
- if (!content || typeof content !== 'object' || Array.isArray(content) ||
188
- Object.keys(content).length !== Object.keys(properties).length ||
189
- Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
190
- (property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
191
- throw this.unsupported('append.content', event.constructor.name, 'Content differs from the flat scalar schema; null, missing and extra values are not proven.');
192
- }
193
189
  const occurred = this._clock();
194
190
  // Date has millisecond precision; only the UTC year range representable by DateTimeOffset is supported.
195
191
  if (!(occurred instanceof Date) || Number.isNaN(occurred.getTime()) ||
@@ -212,9 +208,12 @@ export class InProcessEventSequence implements IEventSequence {
212
208
  tags: prepared.tags.map(tag => new Tag(tag))
213
209
  }
214
210
  };
215
- if (this._stagedSeeds) this._stagedSeeds.push(this.snapshot(stored));
216
- else this._history.push(this.snapshot(stored));
217
- const result = this.success(sequenceNumber, event.constructor.name);
211
+ const violations = this.validateConstraints([...this._history, ...(this._stagedSeeds ?? [])], [stored]);
212
+ const result = violations.length ? this.rejected(violations) : this.success(sequenceNumber, event.constructor.name);
213
+ if (result.isSuccess) {
214
+ if (this._stagedSeeds) this._stagedSeeds.push(this.snapshot(stored));
215
+ else this._history.push(this.snapshot(stored));
216
+ }
218
217
  if (!this._setup) this._results.push(result);
219
218
  if (this._stagedNotifications || this.appendOperations.hasSubscribers) {
220
219
  const notification = [createAppendNotification(eventSourceId, event, result,
@@ -309,15 +308,16 @@ export class InProcessEventSequence implements IEventSequence {
309
308
  } };
310
309
  return { stored, result: this.success(sequenceNumber, event.constructor.name) };
311
310
  });
312
- // Stage and validate every entry before committing any event or result.
313
- this._history.push(...staged.map(item => this.snapshot(item.stored)));
314
- const results = staged.map(item => item.result);
311
+ // The kernel validates the entire batch before persisting any events or allocating sequences.
312
+ const violations = this.validateConstraints(this._history, staged.map(item => item.stored));
313
+ const results = violations.length ? staged.map(() => this.rejected(violations)) : staged.map(item => item.result);
314
+ if (!violations.length) this._history.push(...staged.map(item => this.snapshot(item.stored)));
315
315
  this._results.push(...results);
316
316
  if (this.appendOperations.hasSubscribers) {
317
317
  const occurredAt = new Date();
318
318
  const causationEntries = mapAppendNotificationCausation(batchCausationChain);
319
- this.appendOperations.publish(staged.map(({ result }, index) =>
320
- createAppendNotification(eventsForEventSourceIds[index].eventSourceId, eventsForEventSourceIds[index].event, result,
319
+ this.appendOperations.publish(staged.map((_, index) =>
320
+ createAppendNotification(eventsForEventSourceIds[index].eventSourceId, eventsForEventSourceIds[index].event, results[index],
321
321
  correlationId.toString(), causationEntries, eventsToAppend[index].Tags, occurredAt)));
322
322
  }
323
323
  return results;
@@ -427,20 +427,37 @@ export class InProcessEventSequence implements IEventSequence {
427
427
  }
428
428
  }
429
429
 
430
- private checkedContent(event: object, serialized: string, operation: string): Record<string, unknown> {
430
+ private checkedContent(event: object, serialized: string, operation: string,
431
+ mismatchReason = 'Content differs from the fixture-backed scalar schema.'): Record<string, unknown> {
431
432
  let content: Record<string, unknown>;
432
433
  try { content = JSON.parse(serialized) as Record<string, unknown>; }
433
434
  catch { throw this.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); }
434
- const properties = this._catalog.get(event.constructor)!.schema.properties!;
435
+ const metadata = this._catalog.get(event.constructor)!;
436
+ const properties = metadata.schema.properties!;
435
437
  if (!content || typeof content !== 'object' || Array.isArray(content) ||
436
438
  Object.keys(content).length !== Object.keys(properties).length ||
437
439
  Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
440
+ (this._constraints?.isConstrainedProperty(metadata.eventType.id.value, key) &&
441
+ (typeof Reflect.get(event, key) !== property.type || Reflect.get(event, key) !== content[key])) ||
438
442
  (property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
439
- throw this.unsupported(operation, event.constructor.name, 'Content differs from the fixture-backed scalar schema.');
443
+ throw this.unsupported(operation, event.constructor.name, mismatchReason);
440
444
  }
441
445
  return content;
442
446
  }
443
447
 
448
+ private validateConstraints(history: readonly AppendedEvent[], incoming: readonly AppendedEvent[]): WireConstraintViolation[] {
449
+ try { return this._constraints?.validate(history, incoming) ?? []; }
450
+ catch (error) {
451
+ if (error instanceof UnsupportedEventSequenceOperation) unsupportedObservers.get(this)?.(error);
452
+ throw error;
453
+ }
454
+ }
455
+
456
+ private rejected(violations: WireConstraintViolation[]): AppendResult {
457
+ return createAppendResult(18446744073709551615n, violations, [], undefined,
458
+ violation => this._constraints!.resolveMessage(violation), async () => ({ isSuccess: true, failedPartitions: [] }));
459
+ }
460
+
444
461
  private success(sequenceNumber: EventSequenceNumber, artifact: string): AppendResult {
445
462
  return Object.freeze({ sequenceNumber: Object.freeze(sequenceNumber), constraintViolations: Object.freeze([]),
446
463
  errors: Object.freeze([]), isSuccess: true,
@@ -178,7 +178,9 @@ export class ReactorScenario {
178
178
  await this._events.given.forEventSource(sourceId).events(...input);
179
179
  } else {
180
180
  const results = await this._events.when.forEventSource(sourceId).events(...input);
181
- if (results.some(result => !result.isSuccess)) throw new Error('ReactorScenario action append failed.');
181
+ const failures = results.filter(result => !result.isSuccess);
182
+ if (failures.length) throw new Error(`ReactorScenario action append failed: ${JSON.stringify(failures, (_, value) =>
183
+ typeof value === 'bigint' ? value.toString() : value)}`);
182
184
  }
183
185
  const events = this._events.appendedEvents.slice(before);
184
186
  if (events.length) await this.process(sourceId, events);