@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.
@@ -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 Set<string>();
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.removedWithEventTypeId || unique.removedWithEventTypeIds?.length ||
30
- unique.eventDefinitions.length === 0 || unique.eventDefinitions.some(entry => entry.properties.length !== 1 ||
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, removal and composite or nested keys are not fixture-backed.');
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.add(id);
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
- const stagedKeys = new Map<string, string>();
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
- // The oracle proves ordinal ASCII casing and significant spaces; no .NET Unicode folding or conversion is inferred.
64
- if (typeof raw !== 'string' || !/^[A-Za-z ]+$/.test(raw)) {
65
- throw this.unsupported(name, 'Only nonempty ASCII letter and space string keys are fixture-backed.');
66
- }
67
- const key = createHash('sha256').update(raw).digest('hex');
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
- stagedKeys.set(`${name}\u0000${source}`, key);
100
- stagedKeys.set(`${name}\u0001${key}`, source);
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 = JSON.parse(prepared.content) as Record<string, unknown>;
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): Record<string, unknown> {
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 properties = this._catalog.get(event.constructor)!.schema.properties!;
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, 'Content differs from the fixture-backed scalar schema.');
445
+ throw this.unsupported(operation, event.constructor.name, mismatchReason);
445
446
  }
446
447
  return content;
447
448
  }