@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.
@@ -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 Set<string>();
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.removedWithEventTypeId || unique.removedWithEventTypeIds?.length ||
45
- unique.eventDefinitions.length === 0 || unique.eventDefinitions.some(entry => entry.properties.length !== 1 ||
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, removal and composite or nested keys are not fixture-backed.');
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
- 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.');
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.add(id);
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
- const stagedKeys = new Map<string, string>();
79
- const stagedTypes = new Set<string>();
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 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) {
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
- stagedKeys.set(`${name}\u0000${source}`, key);
121
- stagedKeys.set(`${name}\u0001${key}`, source);
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 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,
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 ${first?.context.sequenceNumber.toString() ?? unavailable}`,
189
+ Message: `Event '${type}' violated a unique event type constraint on sequence number ${sequence}`,
133
190
  Details: {} });
134
- } else stagedTypes.add(`${name}\u0000${source}`);
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 ||