@cratis/chronicle 6.38.0 → 6.40.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 (74) hide show
  1. package/ChronicleClient.ts +2 -3
  2. package/ChronicleOptions.ts +5 -1
  3. package/EventStore.ts +1 -2
  4. package/WellKnownTelemetryNames.ts +21 -0
  5. package/dist/ChronicleClient.d.ts.map +1 -1
  6. package/dist/ChronicleClient.js +2 -3
  7. package/dist/ChronicleClient.js.map +1 -1
  8. package/dist/ChronicleOptions.d.ts +1 -1
  9. package/dist/ChronicleOptions.d.ts.map +1 -1
  10. package/dist/ChronicleOptions.js +5 -1
  11. package/dist/ChronicleOptions.js.map +1 -1
  12. package/dist/EventStore.d.ts.map +1 -1
  13. package/dist/EventStore.js +1 -2
  14. package/dist/EventStore.js.map +1 -1
  15. package/dist/WellKnownTelemetryNames.d.ts +21 -0
  16. package/dist/WellKnownTelemetryNames.d.ts.map +1 -1
  17. package/dist/WellKnownTelemetryNames.js +21 -0
  18. package/dist/WellKnownTelemetryNames.js.map +1 -1
  19. package/dist/eventSequences/EventSequence.js +9 -9
  20. package/dist/eventSequences/EventSequence.js.map +1 -1
  21. package/dist/telemetry/ChronicleTelemetryOptions.d.ts +3 -1
  22. package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -1
  23. package/dist/telemetry/observeOperation.d.ts +3 -1
  24. package/dist/telemetry/observeOperation.d.ts.map +1 -1
  25. package/dist/telemetry/observeOperation.js +3 -1
  26. package/dist/telemetry/observeOperation.js.map +1 -1
  27. package/dist/testing/EventFieldSchema.d.ts +6 -0
  28. package/dist/testing/EventFieldSchema.d.ts.map +1 -0
  29. package/dist/testing/EventFieldSchema.js +78 -0
  30. package/dist/testing/EventFieldSchema.js.map +1 -0
  31. package/dist/testing/EventScenario.d.ts.map +1 -1
  32. package/dist/testing/EventScenario.js +17 -7
  33. package/dist/testing/EventScenario.js.map +1 -1
  34. package/dist/testing/InProcessConstraints.d.ts +3 -1
  35. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  36. package/dist/testing/InProcessConstraints.js +20 -9
  37. package/dist/testing/InProcessConstraints.js.map +1 -1
  38. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  39. package/dist/testing/InProcessEventSequence.js +9 -10
  40. package/dist/testing/InProcessEventSequence.js.map +1 -1
  41. package/dist/testing/ReadModelScenario.js +1 -1
  42. package/dist/testing/ReadModelScenario.js.map +1 -1
  43. package/dist/testing/ScenarioEvent.d.ts +1 -0
  44. package/dist/testing/ScenarioEvent.d.ts.map +1 -1
  45. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  46. package/dist/testing/projections/ProjectionReadModelProcessor.js +7 -0
  47. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  48. package/dist/tsconfig.tsbuildinfo +1 -1
  49. package/eventSequences/EventSequence.ts +9 -9
  50. package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +30 -9
  51. package/for_ChronicleOptions/when_configuring_telemetry/with_span_names.spec.ts +32 -0
  52. package/package.json +2 -2
  53. package/telemetry/ChronicleTelemetryOptions.ts +4 -1
  54. package/telemetry/for_ChronicleTelemetry/given/a_client.fixture.ts +47 -0
  55. package/telemetry/for_ChronicleTelemetry/given/a_span_name_registry.fixture.ts +33 -0
  56. package/telemetry/for_ChronicleTelemetry/when_exposing_names/with_legacy_compatibility.spec.ts +44 -0
  57. package/telemetry/for_ChronicleTelemetry/when_naming_spans/with_per_client_modes.spec.ts +138 -0
  58. package/telemetry/for_ChronicleTelemetry/when_recording_product_attributes/with_both_naming_modes.spec.ts +52 -0
  59. package/telemetry/observeOperation.ts +9 -1
  60. package/testing/EventFieldSchema.ts +71 -0
  61. package/testing/EventScenario.constraint-cycles.spec.ts +1 -1
  62. package/testing/EventScenario.constraints.spec.ts +4 -2
  63. package/testing/EventScenario.fixtures.spec.ts +5 -5
  64. package/testing/EventScenario.ts +18 -7
  65. package/testing/InProcessConstraints.ts +19 -9
  66. package/testing/InProcessEventSequence.ts +9 -10
  67. package/testing/ReadModelScenario.ts +1 -1
  68. package/testing/ScenarioEvent.ts +1 -1
  69. package/testing/for_EventScenario/when_appending_field_types/field_scenario_behaviors.fixture.ts +219 -0
  70. package/testing/for_EventScenario/when_appending_field_types/with_invalid_formatted_content.legacy.spec.ts +123 -0
  71. package/testing/for_EventScenario/when_appending_field_types/with_legacy_decorators.legacy.spec.ts +124 -0
  72. package/testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts +123 -0
  73. package/testing/for_EventScenario/when_appending_field_types/with_unproven_schemas.legacy.spec.ts +82 -0
  74. package/testing/projections/ProjectionReadModelProcessor.ts +10 -0
@@ -231,6 +231,6 @@ describe('fixture-backed unique event type cycles', () => {
231
231
  await unsupported(() => new EventScenario({ artifacts: {
232
232
  eventTypes: [OracleCycleFirst, OracleCycleSibling, OracleCycleRemoved, FieldlessRemoval],
233
233
  constraints: [CycleFirst, CycleSibling]
234
- } }), 'artifacts.eventTypes.schema (FieldlessRemoval)', 'Only flat, unclassified string/boolean fields are fixture-backed.');
234
+ } }), 'artifacts.eventTypes.schema (FieldlessRemoval)', 'Only unclassified string, boolean, numeric, Guid, date and object fields are supported.');
235
235
  });
236
236
  });
@@ -288,10 +288,12 @@ describe('fixture-backed unscoped constraints', () => {
288
288
  (await subject.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
289
289
  });
290
290
 
291
- it('rejects unproven numeric constrained schemas at construction', () => {
291
+ it('accepts fixture-backed safe-integer constrained schemas', async () => {
292
292
  class NumericConstrained { @field(Number) @unique('NumberKey') key = 1; }
293
293
  eventType('NumericConstrained')(NumericConstrained);
294
- unsupported(() => new EventScenario({ artifacts: { eventTypes: [NumericConstrained] } }), 'artifacts.constraints');
294
+ const subject = new EventScenario({ artifacts: { eventTypes: [NumericConstrained] } });
295
+ (await subject.append('A', new NumericConstrained())).isSuccess.should.be.true;
296
+ (await subject.append('B', new NumericConstrained())).isSuccess.should.be.false;
295
297
  });
296
298
 
297
299
  it('rejects a selected constraint covering an event outside the selected catalog', () => {
@@ -31,10 +31,10 @@ class BoundaryRecorded {
31
31
  }
32
32
  eventType('BoundaryRecorded')(BoundaryRecorded);
33
33
  class UnknownEvent {}
34
- class UnprovenNumber {
35
- @field(Number) amount = 1;
34
+ class UnprovenArray {
35
+ @field(Array, { genericArguments: [Number] }) amounts = [1];
36
36
  }
37
- eventType('UnprovenNumber')(UnprovenNumber);
37
+ eventType('UnprovenArray')(UnprovenArray);
38
38
  class ConstrainedEvent {
39
39
  @field(String) value = 'value';
40
40
  }
@@ -59,7 +59,7 @@ const fixtures = readdirSync(directory).filter(name => name.endsWith('.json') &&
59
59
  !['batches.json', 'batch-omitted-routes.json', 'batch-rollback.json', 'builders.json', 'constraints.json', 'constraints-isolation.json', 'constraints-key-domain.json',
60
60
  'constraints-property-lifecycle.json', 'constraints-property-covered-removal.json', 'constraints-event-type-cycles.json',
61
61
  'constraints-event-type-siblings.json', 'constraints-composite.json',
62
- 'constraints-ignore-casing.json', 'constraints-scopes.json'].includes(name)).map(name => ({
62
+ 'constraints-ignore-casing.json', 'constraints-scopes.json', 'constraints-field-types.json'].includes(name)).map(name => ({
63
63
  name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
64
64
  }));
65
65
  const artifacts = { eventTypes: [OracleEventRecorded, AlternateRecorded, BoundaryRecorded], constraints: [] };
@@ -192,7 +192,7 @@ describe('when appending against committed kernel event fixtures', () => {
192
192
 
193
193
 
194
194
  it('should reject unproven schemas and registrations but discover supported constraints', async () => {
195
- unsupported(() => new EventScenario({ artifacts: { eventTypes: [UnprovenNumber] }, constraints: 'disabled' }), 'schema');
195
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [UnprovenArray] }, constraints: 'disabled' }), 'schema');
196
196
  const constrained = new EventScenario({ artifacts: { eventTypes: [ConstrainedEvent] } });
197
197
  (await constrained.append('A', new ConstrainedEvent())).isSuccess.should.be.true;
198
198
  (await constrained.append('A', new ConstrainedEvent())).constraintViolations.length.should.equal(1);
@@ -85,7 +85,8 @@ export class EventScenario {
85
85
  }
86
86
  }
87
87
  }
88
- constraints = new InProcessConstraints(definitions);
88
+ constraints = new InProcessConstraints(definitions, new Map(eventTypes.map(type =>
89
+ [getEventTypeFor(type).id.value, getEventTypeMetadata(type)!.schema])));
89
90
  for (const [name, capture] of definitions) {
90
91
  const ids = capture.uniqueConstraint?.eventDefinitions.map(entry => entry.eventTypeId) ??
91
92
  capture.uniqueEventType?.eventTypeIds ?? [capture.uniqueEventType?.eventTypeId];
@@ -106,22 +107,32 @@ export class EventScenario {
106
107
  }
107
108
  for (const entry of capture.uniqueConstraint?.eventDefinitions ?? []) {
108
109
  const type = eventTypes.find(type => getEventTypeFor(type).id.value === entry.eventTypeId)!;
109
- const schemaTypes = entry.properties.map(property => getEventTypeMetadata(type)?.schema.properties?.[property]?.type ?? '');
110
- if (scoped && schemaTypes.some(schemaType => schemaType !== 'string')) {
110
+ const schemas = entry.properties.map(property => getEventTypeMetadata(type)?.schema.properties?.[property]);
111
+ const plainStrings = schemas.every(schema => schema?.type === 'string' && schema.format === undefined);
112
+ if (scoped && !plainStrings) {
111
113
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
112
114
  'Scoped property keys must be schema-backed strings.');
113
115
  }
114
- if (entry.properties.length === 1 && !['string', 'boolean'].includes(schemaTypes[0])) {
116
+ for (const schema of schemas) {
117
+ const supported = schema?.type === 'boolean' && schema.format === undefined ||
118
+ schema?.type === 'string' && (schema.format === undefined || schema.format === 'guid') ||
119
+ schema?.type === 'number' && (schema.format === undefined || schema.format === 'double');
120
+ if (!supported) throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
121
+ `Unique keys with schema ${schema?.type ?? 'missing'}/${schema?.format ?? 'unformatted'} are not fixture-backed; date, object and other numeric formats require a kernel-backed test.`);
122
+ }
123
+ const extendedKey = schemas.some(schema => schema?.type === 'number' || schema?.format === 'guid');
124
+ if (extendedKey && (definitions.size !== 1 || capture.uniqueConstraint!.eventDefinitions.length !== 1 ||
125
+ removedWith.some(id => id !== undefined))) {
115
126
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
116
- 'The constrained property must be a schema-backed string or boolean.');
127
+ 'Guid and numeric keys require one isolated definition covering one event type without removers; shared definitions and removal combinations are not fixture-backed.');
117
128
  }
118
129
  // constraints-ignore-casing.json captures folded string keys only.
119
- if (capture.uniqueConstraint!.ignoreCasing && schemaTypes.some(schemaType => schemaType !== 'string')) {
130
+ if (capture.uniqueConstraint!.ignoreCasing && !plainStrings) {
120
131
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
121
132
  'Case-insensitive keys must be schema-backed strings.');
122
133
  }
123
134
  // constraints-composite.json captures string components only.
124
- if (entry.properties.length > 1 && schemaTypes.some(schemaType => schemaType !== 'string')) {
135
+ if (entry.properties.length > 1 && !plainStrings) {
125
136
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
126
137
  'Every property of a composite key must be a schema-backed string.');
127
138
  }
@@ -7,6 +7,7 @@ import type { AppendedEvent } from '../events/AppendedEvent.js';
7
7
  import type { ConstraintCapture } from '../events/constraints/ConstraintBuilder.js';
8
8
  import { resolveConstraintMessage } from '../events/constraints/Constraints.js';
9
9
  import type { ConstraintViolation } from '../eventSequences/ConstraintViolation.js';
10
+ import type { JsonSchema } from '../schemas/JsonSchema.js';
10
11
  import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
11
12
  import { eventTypeScopeKey, propertyScopeKey, scopeIndex } from './ConstraintScope.js';
12
13
 
@@ -16,7 +17,18 @@ const unavailable = '18446744073709551615';
16
17
  // Only the key characters captured by the packaged oracle, not every valid event-content character.
17
18
  const provenString = /^[A-Za-z0-9 .@_:\-|{}$\u00e9]*$/;
18
19
 
19
- function kernelKeyString(value: unknown, name: string): string {
20
+ function kernelKeyString(value: unknown, name: string, schema?: JsonSchema): string {
21
+ // constraints-field-types.json: the kernel converts Guid keys before comparing or reporting them.
22
+ if (schema?.format === 'guid') {
23
+ if (typeof value === 'string' && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value)) return value.toLowerCase();
24
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', name, 'Only dashed Guid keys are fixture-backed.');
25
+ }
26
+ // Avoid .NET floating-point formatting, exponent spelling and JavaScript precision loss.
27
+ if (schema?.type === 'number') {
28
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
29
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
30
+ 'Only safe-integer numeric keys are fixture-backed; fractional, exponential and unsafe-integer comparisons require a kernel-backed test.');
31
+ }
20
32
  if (typeof value === 'string' && provenString.test(value)) return value;
21
33
  if (typeof value === 'boolean') return value ? 'True' : 'False';
22
34
  throw new UnsupportedEventSequenceOperation('artifacts.constraints', name,
@@ -35,10 +47,10 @@ type Claim = { key: string; parts: Array<{ property: string; raw: string }> };
35
47
  * (UniqueConstraintDefinitionExtensions.GetPropertiesAndValues/GetValue at Chronicle 8fe5d30). Not a tuple:
36
48
  * ['a-b', 'c'] and ['a', 'b-c'] claim the same key, as constraints-composite.json captures.
37
49
  */
38
- function claimOf(name: string, unique: UniqueCapture, eventTypeId: string, content: Record<string, unknown>): Claim | undefined {
50
+ function claimOf(name: string, unique: UniqueCapture, eventTypeId: string, content: Record<string, unknown>, schema?: JsonSchema): Claim | undefined {
39
51
  const properties = unique.eventDefinitions.find(entry => entry.eventTypeId === eventTypeId)?.properties;
40
52
  if (properties === undefined) return undefined;
41
- const parts = properties.map(property => ({ property, raw: kernelKeyString(content[property], name) }));
53
+ const parts = properties.map(property => ({ property, raw: kernelKeyString(content[property], name, schema?.properties?.[property]) }));
42
54
  const joined = parts.map(part => part.raw).join('-');
43
55
  if (!unique.ignoreCasing) return { key: keyHash(joined), parts };
44
56
  // The kernel applies .NET ToLowerInvariant to the joined value. constraints-ignore-casing.json proves
@@ -56,7 +68,8 @@ export class InProcessConstraints {
56
68
  private readonly _removalTypes = new Set<string>();
57
69
  private readonly _propertyRemovalTypes = new Set<string>();
58
70
 
59
- constructor(private readonly _definitions: ReadonlyMap<string, ConstraintCapture>) {
71
+ constructor(private readonly _definitions: ReadonlyMap<string, ConstraintCapture>,
72
+ private readonly _schemas: ReadonlyMap<string, JsonSchema> = new Map()) {
60
73
  const coveredTypes = new Map<string, string>();
61
74
  const removalOwners = new Map<string, string>();
62
75
  for (const [name, capture] of _definitions) {
@@ -173,10 +186,7 @@ export class InProcessConstraints {
173
186
  }
174
187
  const properties = unique.eventDefinitions.find(entry => entry.eventTypeId === type)?.properties;
175
188
  if (properties === undefined) continue;
176
- if (properties.some(property => !['string', 'boolean'].includes(typeof prior.content[property]))) {
177
- throw this.unsupported(name, 'History has an unproven key.');
178
- }
179
- claims.set(source, { key: claimOf(name, unique, type, prior.content)!.key,
189
+ claims.set(source, { key: claimOf(name, unique, type, prior.content, this._schemas.get(type))!.key,
180
190
  sequence: prior.context.sequenceNumber.toString() });
181
191
  }
182
192
  }
@@ -187,7 +197,7 @@ export class InProcessConstraints {
187
197
  const source = event.context.eventSourceId;
188
198
  const violationCount = violations.length;
189
199
  for (const [name, definition] of this._definitions) {
190
- const claim = definition.uniqueConstraint && claimOf(name, definition.uniqueConstraint, type, event.content);
200
+ const claim = definition.uniqueConstraint && claimOf(name, definition.uniqueConstraint, type, event.content, this._schemas.get(type));
191
201
  if (claim) {
192
202
  const key = claim.key;
193
203
  const scope = propertyScopeKey(definition.scope, event.context);
@@ -29,6 +29,7 @@ import { prepareSingleAppend } from '../eventSequences/prepareSingleAppend.js';
29
29
  import { toContractsGuid } from '../connection/Guid.js';
30
30
  import { getRemovedConstraintNames } from '../events/constraints/removeConstraint.js';
31
31
  import { InProcessConstraints, type WireConstraintViolation } from './InProcessConstraints.js';
32
+ import { matchesEventField, supportsEventField } from './EventFieldSchema.js';
32
33
  import type { EventScenarioOptions } from './EventScenarioOptions.js';
33
34
  import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
34
35
 
@@ -38,7 +39,7 @@ const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space
38
39
  function matchesConstrainedValue(value: unknown, content: unknown): boolean {
39
40
  // Use production serialization for concepts, not their wrapper object's runtime type.
40
41
  // Keep rejecting other unproven conversions, such as a Date assigned to a string field.
41
- if (value && typeof value === 'object' && typeKeyOf(value.constructor as Constructor) === conceptAsTypeKey) {
42
+ if (value && typeof value === 'object' && [conceptAsTypeKey, 'Guid'].includes(typeKeyOf(value.constructor as Constructor) ?? '')) {
42
43
  return JsonSerializer.serialize(value) === JSON.stringify(content);
43
44
  }
44
45
  return value === content;
@@ -117,11 +118,8 @@ export class InProcessEventSequence implements IEventSequence {
117
118
  !schema.properties || (Object.keys(schema.properties).length === 0 &&
118
119
  !this._constraints?.isRemovalOnlyType(eventType.id.value)) ||
119
120
  Object.entries(schema.properties).some(([key, property]) =>
120
- !/^[a-z][a-zA-Z0-9]*$/.test(key) || !['string', 'boolean'].includes(property.type ?? '') ||
121
- property.format !== undefined || property.compliance?.length || property.security?.length ||
122
- property.items !== undefined || property.properties !== undefined || property.enum !== undefined ||
123
- property.additionalProperties !== undefined)) {
124
- throw this.unsupported('artifacts.eventTypes.schema', type.name, 'Only flat, unclassified string/boolean fields are fixture-backed.');
121
+ !/^[a-z][a-zA-Z0-9]*$/.test(key) || !supportsEventField(property))) {
122
+ throw this.unsupported('artifacts.eventTypes.schema', type.name, 'Only unclassified string, boolean, numeric, Guid, date and object fields are supported.');
125
123
  }
126
124
  this._catalog.set(type, metadata);
127
125
  }
@@ -177,7 +175,7 @@ export class InProcessEventSequence implements IEventSequence {
177
175
  try {
178
176
  prepared = prepareSingleAppend(event, this._correlationId ? { correlationId: this._correlationId() } : undefined);
179
177
  content = this.checkedContent(event, prepared.content, 'append.content',
180
- 'Content differs from the flat scalar schema; null, missing and extra values are not proven.');
178
+ 'Content differs from the event schema; null, missing and extra values are not proven.');
181
179
  } catch (error) {
182
180
  if (error instanceof UnsupportedEventSequenceOperation) throw error;
183
181
  throw this.unsupported('append.serialization', event.constructor.name, `Payload or metadata could not be serialized: ${String(error)}.`);
@@ -455,7 +453,7 @@ export class InProcessEventSequence implements IEventSequence {
455
453
  }
456
454
 
457
455
  private checkedContent(event: object, serialized: string, operation: string,
458
- mismatchReason = 'Content differs from the fixture-backed scalar schema.'): Record<string, unknown> {
456
+ mismatchReason = 'Content differs from the event schema.'): Record<string, unknown> {
459
457
  let content: Record<string, unknown>;
460
458
  try { content = JSON.parse(serialized) as Record<string, unknown>; }
461
459
  catch { throw this.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); }
@@ -463,10 +461,11 @@ export class InProcessEventSequence implements IEventSequence {
463
461
  const properties = metadata.schema.properties!;
464
462
  if (!content || typeof content !== 'object' || Array.isArray(content) ||
465
463
  Object.keys(content).length !== Object.keys(properties).length ||
466
- Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
464
+ Object.entries(properties).some(([key, property]) => !Object.hasOwn(content, key) || !matchesEventField(content[key], property) ||
467
465
  (this._constraints?.isConstrainedProperty(metadata.eventType.id.value, key) &&
468
466
  !matchesConstrainedValue(Reflect.get(event, key), content[key])) ||
469
- (property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
467
+ (property.type === 'string' && property.format === undefined &&
468
+ !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
470
469
  throw this.unsupported(operation, event.constructor.name, mismatchReason);
471
470
  }
472
471
  return content;
@@ -174,7 +174,7 @@ export class ReadModelScenario<TReadModel extends object> {
174
174
  throw new UnsupportedProjectionOperation(this._modelName, 'observe', `${eventSourceType}/${eventStreamType}/${eventStreamId}`,
175
175
  'observed events must use the default event source type and stream; routed events require a kernel-backed test');
176
176
  }
177
- return { sourceId: event.context.eventSourceId, content: event.content, context: event.context };
177
+ return { sourceId: event.context.eventSourceId, content: event.content, context: event.context, scenarioLocalHash: true };
178
178
  });
179
179
  }
180
180
 
@@ -4,4 +4,4 @@
4
4
  import type { EventContext } from '../events/EventContext.js';
5
5
 
6
6
  /** An event serialized at the scenario boundary. */
7
- export type ScenarioEvent = { sourceId: string; content: unknown; context: EventContext };
7
+ export type ScenarioEvent = { sourceId: string; content: unknown; context: EventContext; scenarioLocalHash?: boolean };
@@ -0,0 +1,219 @@
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 { readFileSync } from 'node:fs';
5
+ import { Guid, JsonSerializer, type Constructor } from '@cratis/fundamentals';
6
+ import { beforeEach, chai, describe, it, vi, type Assertion } from 'vitest';
7
+ import type { ChronicleConnection } from '../../../connection/index.js';
8
+ import { EventSequence } from '../../../eventSequences/EventSequence.js';
9
+ import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
10
+ import type { IUnitOfWorkManager } from '../../../transactions/IUnitOfWorkManager.js';
11
+ import { getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
12
+ import { EventScenario, ReactorScenario, ReadModelScenario, UnsupportedEventSequenceOperation, UnsupportedProjectionOperation } from '../../index.js';
13
+ import { InProcessConstraints } from '../../InProcessConstraints.js';
14
+
15
+ chai.should();
16
+ const should = (value: unknown): Assertion => (value as { should: Assertion }).should;
17
+
18
+ export function fieldScenarioBehaviors(fixtures: {
19
+ recorded: Constructor;
20
+ reactor: Constructor;
21
+ model: Constructor;
22
+ reducer: Constructor;
23
+ guidConstraint: Constructor;
24
+ numberConstraint: Constructor;
25
+ doubleConstraint: Constructor;
26
+ double: Constructor<{ key: number }>;
27
+ projection: Constructor;
28
+ objectProjection: Constructor;
29
+ hashProjection: Constructor;
30
+ guid: Constructor<{ key: Guid }>;
31
+ number: Constructor<{ key: number }>;
32
+ guidConcept: Constructor;
33
+ numberConcept: Constructor;
34
+ rejected: Constructor[];
35
+ }): void {
36
+ const artifacts = { eventTypes: [fixtures.recorded], reducers: [], projections: [] };
37
+ const serialized = () => JSON.parse(JsonSerializer.serialize(new fixtures.recorded())) as Record<string, unknown>;
38
+
39
+ describe('when appending unconstrained primitive and concept fields', () => {
40
+ let scenario: EventScenario;
41
+ let connectedContent: Record<string, unknown>;
42
+ beforeEach(async () => {
43
+ scenario = new EventScenario({ artifacts });
44
+ const append = vi.fn().mockResolvedValue({ Response: { SequenceNumber: 0n, ConstraintViolations: [], Errors: [] } });
45
+ const connected = new EventSequence(EventSequenceId.eventLog, 'store', 'default',
46
+ { eventSequences: { append } } as unknown as ChronicleConnection, {} as IUnitOfWorkManager);
47
+ const event = new fixtures.recorded();
48
+ await connected.append('A', event);
49
+ connectedContent = JSON.parse(append.mock.calls[0][0].Content as string) as Record<string, unknown>;
50
+ await scenario.given.forEventSource('A').events(event);
51
+ await scenario.append('B', event);
52
+ await scenario.appendMany('C', [event]);
53
+ await scenario.appendMany([{ eventSourceId: 'D', event }]);
54
+ });
55
+ it('should serialize every append overload exactly like the connected client', () => {
56
+ should(scenario.appendedEvents.map(event => event.content)).deep.equal(Array(4).fill(connectedContent));
57
+ should(scenario.results.map(result => result.isSuccess)).deep.equal([true, true, true]);
58
+ });
59
+ it('should retain independent nested history snapshots', async () => {
60
+ const first = scenario.appendedEvents[0].content;
61
+ (first.payload as Record<string, unknown>).amount = -1;
62
+ const read = await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [fixtures.recorded]);
63
+ should(read[0].content).deep.equal(connectedContent);
64
+ });
65
+ });
66
+
67
+ for (const [property, value] of [
68
+ ['identifier', 'not-a-guid'], ['identifier', Guid.parse('zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz')],
69
+ ['occurred', new Date('+010000-01-01T00:00:00.000Z')], ['occurred', new Date('0000-01-01T00:00:00.000Z')],
70
+ ['payload', { amount: 1, identifier: 'bad', occurred: new Date() }]
71
+ ] as const) {
72
+ describe(`when a declared ${property} field contains an invalid serialized value`, () => {
73
+ it('should reject before committing a setup call', async () => {
74
+ const scenario = new EventScenario({ artifacts });
75
+ const event = Object.assign(new fixtures.recorded(), { [property]: value });
76
+ const error = await scenario.given.forEventSource('A').events(new fixtures.recorded(), event).then(() => undefined, error => error);
77
+ should(error).instanceOf(UnsupportedEventSequenceOperation);
78
+ should(scenario.appendedEvents).have.lengthOf(0);
79
+ should(scenario.results).have.lengthOf(0);
80
+ should((await scenario.eventSequence.getNextSequenceNumber()).value).equal(0n);
81
+ });
82
+ });
83
+ }
84
+
85
+ describe('when delivering new field types through a reactor', () => {
86
+ let scenario: ReactorScenario;
87
+ beforeEach(async () => {
88
+ scenario = new ReactorScenario(fixtures.reactor, { artifacts });
89
+ await scenario.when.forEventSource('A').events(new fixtures.recorded());
90
+ });
91
+ it('should deliver client-serialized JSON without kernel normalization or concept wrappers', () => {
92
+ should(scenario.results[0].completed).equal(true);
93
+ should(scenario.produced[0]).deep.equal(serialized());
94
+ });
95
+ });
96
+
97
+ describe('when observing new field types through a reducer', () => {
98
+ let result: object | null;
99
+ beforeEach(async () => {
100
+ const scenario = new EventScenario({ artifacts });
101
+ const models = new ReadModelScenario(fixtures.model, { ...artifacts, reducers: [fixtures.reducer] }).observe(scenario);
102
+ await scenario.append('A', new fixtures.recorded());
103
+ result = await models.instanceForEventSourceId('A');
104
+ });
105
+ it('should give the reducer client-serialized JSON without kernel normalization', () => { should(result).deep.equal(serialized()); });
106
+ });
107
+
108
+ describe('when observing new scalar fields through a projection', () => {
109
+ let result: object | null;
110
+ beforeEach(async () => {
111
+ const scenario = new EventScenario({ artifacts });
112
+ const models = new ReadModelScenario(fixtures.projection, artifacts).observe(scenario);
113
+ await scenario.append('A', new fixtures.recorded());
114
+ result = await models.instanceForEventSourceId('A');
115
+ });
116
+ it('should preserve the existing fixture-backed projection conversion', () => {
117
+ const content = serialized();
118
+ should(JSON.parse(JsonSerializer.serialize(result))).deep.equal({
119
+ id: 'A', amount: content.amount, identifier: String(content.identifier).toLowerCase(), occurred: content.occurred });
120
+ });
121
+ });
122
+
123
+ it('should still reject object projection targets independently of accepted event content', () => {
124
+ should(() => new ReadModelScenario(fixtures.objectProjection, artifacts)).throw(UnsupportedProjectionOperation);
125
+ });
126
+
127
+ it('should reject a projection that treats a new payload hash as a kernel hash', async () => {
128
+ const scenario = new EventScenario({ artifacts });
129
+ const models = new ReadModelScenario(fixtures.hashProjection, artifacts).observe(scenario);
130
+ await scenario.append('A', new fixtures.recorded());
131
+ const error = await models.instanceForEventSourceId('A').then(() => undefined, error => error);
132
+ should(error).instanceOf(UnsupportedProjectionOperation);
133
+ should(String(error)).include('Hash');
134
+ });
135
+
136
+ for (const type of fixtures.rejected) {
137
+ describe(`when registering the unproven ${type.name} unique key`, () => {
138
+ it('should name the constraint boundary rather than rejecting ordinary event serialization', async () => {
139
+ should(() => new EventScenario({ artifacts: { eventTypes: [type] } })).throw(UnsupportedEventSequenceOperation, 'artifacts.constraints');
140
+ const scenario = new EventScenario({ artifacts: { eventTypes: [type] }, constraints: 'disabled' });
141
+ should((await scenario.append('A', new type())).isSuccess).equal(true);
142
+ });
143
+ });
144
+ }
145
+
146
+ for (const type of [fixtures.guidConcept, fixtures.numberConcept]) {
147
+ describe(`when claiming the same ${type.name} concept key`, () => {
148
+ it('should compare its serialized primitive and report the duplicate', async () => {
149
+ const scenario = new EventScenario({ artifacts: { eventTypes: [type] } });
150
+ should((await scenario.append('A', new type())).isSuccess).equal(true);
151
+ const duplicate = await scenario.append('B', new type());
152
+ should(duplicate.isSuccess).equal(false);
153
+ should(duplicate.constraintViolations[0].details.PropertyValue).equal(
154
+ String((JSON.parse(JsonSerializer.serialize(new type())) as { key: unknown }).key).toLowerCase());
155
+ });
156
+ });
157
+ }
158
+
159
+ for (const value of [1.5, 1e-7, Number.MAX_SAFE_INTEGER + 1, -Number.MAX_SAFE_INTEGER - 1, NaN, Infinity]) {
160
+ describe(`when a constrained number contains ${value}`, () => {
161
+ it('should reject the entire batch before recording results or history', async () => {
162
+ const scenario = new EventScenario({ artifacts: { eventTypes: [fixtures.number], constraints: [fixtures.numberConstraint] } });
163
+ const event = new fixtures.number(); event.key = value;
164
+ const error = await scenario.appendMany('A', [new fixtures.number(), event]).then(() => undefined, error => error);
165
+ should(error).instanceOf(UnsupportedEventSequenceOperation);
166
+ should(scenario.results).have.lengthOf(0);
167
+ should(scenario.appendedEvents).have.lengthOf(0);
168
+ });
169
+ });
170
+ }
171
+
172
+ it('should normalize negative zero using production serialization before comparing numeric keys', async () => {
173
+ const scenario = new EventScenario({ artifacts: { eventTypes: [fixtures.number], constraints: [fixtures.numberConstraint] } });
174
+ const zero = new fixtures.number(); zero.key = 0;
175
+ const negative = new fixtures.number(); negative.key = -0;
176
+ await scenario.append('A', zero);
177
+ should((await scenario.append('B', negative)).isSuccess).equal(false);
178
+ });
179
+
180
+ const fixture = JSON.parse(readFileSync(new URL('../../fixtures/constraints-field-types.json', import.meta.url), 'utf8')) as {
181
+ fieldConstraintCases: Array<{ kind: string; eventType: string; operations: Array<{ mode: string; events: Array<{ source: string; content: string }> }> }>;
182
+ expected: Array<{ fieldSchema: unknown; steps: Array<{ success: boolean; sequences: string[]; wireViolations: unknown[]; history: unknown[]; next: string }> }>;
183
+ };
184
+ for (const [caseIndex, test] of fixture.fieldConstraintCases.entries()) {
185
+ if (test.kind !== 'kernelSemantics') continue;
186
+ describe(`when replaying the packaged ${test.eventType} constraint fixture`, () => {
187
+ it('should match every raw violation, hash, history, sequence and atomic rejection', async () => {
188
+ const type = test.eventType === 'OracleFieldGuid' ? fixtures.guid : test.eventType === 'OracleFieldDouble' ? fixtures.double : fixtures.number;
189
+ should(getEventTypeMetadata(type)!.eventType.id.value).equal(test.eventType);
190
+ should(getEventTypeMetadata(type)!.schema.properties!.key).deep.equal(fixture.expected[caseIndex].fieldSchema);
191
+ const scenario = new EventScenario({ artifacts: { eventTypes: [type],
192
+ constraints: [type === fixtures.guid ? fixtures.guidConstraint : type === fixtures.double ? fixtures.doubleConstraint : fixtures.numberConstraint] } });
193
+ const validate = vi.spyOn(InProcessConstraints.prototype, 'validate');
194
+ try {
195
+ for (const [index, operation] of test.operations.entries()) {
196
+ const expected = fixture.expected[caseIndex].steps[index];
197
+ const events = operation.events.map(entry => {
198
+ const event = new type();
199
+ const key = (JSON.parse(entry.content) as { key: string | number }).key;
200
+ Object.assign(event, { key });
201
+ return { eventSourceId: entry.source, event };
202
+ });
203
+ const results = operation.mode === 'single'
204
+ ? [await scenario.append(events[0].eventSourceId, events[0].event)] : await scenario.appendMany(events);
205
+ should(results.every(result => result.isSuccess === expected.success)).equal(true);
206
+ should(results.map(result => result.sequenceNumber.value.toString())).deep.equal(
207
+ expected.success ? expected.sequences : results.map(() => '0'));
208
+ const raw = validate.mock.results.at(-1)!.value as ReturnType<InProcessConstraints['validate']>;
209
+ should(raw.map(({ SequenceNumber, ...violation }) => ({ ...violation, SequenceNumber: SequenceNumber.toString() })))
210
+ .deep.equal(expected.wireViolations);
211
+ should(scenario.appendedEvents.map(event => ({ source: event.context.eventSourceId,
212
+ sequence: event.context.sequenceNumber.toString(), content: event.content, hash: event.context.hash }))).deep.equal(expected.history);
213
+ should((await scenario.eventSequence.getNextSequenceNumber()).value.toString()).equal(expected.next);
214
+ }
215
+ } finally { validate.mockRestore(); }
216
+ });
217
+ });
218
+ }
219
+ }
@@ -0,0 +1,123 @@
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 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { beforeEach, chai, describe, it } from 'vitest';
7
+ import { eventType, getEventTypeMetadata } from '../../../events/eventTypeDecorator.js';
8
+ import type { JsonSchema } from '../../../schemas/JsonSchema.js';
9
+ import { EventScenario, UnsupportedEventSequenceOperation } from '../../index.js';
10
+
11
+ chai.should();
12
+
13
+ const invalid: Array<{ schema: JsonSchema; value: unknown }> = [
14
+ { schema: { type: 'integer' }, value: 1.5 },
15
+ { schema: { type: 'integer' }, value: Number.MAX_SAFE_INTEGER + 1 }
16
+ ];
17
+ for (const format of ['guid', 'uuid']) {
18
+ for (const value of ['not-a-guid', 'abcdef01abcdabcdabcdabcdef012345', 'abcdef01-abcd-abcd-abcd-abcdef01234g', '"', '\\', '\n', 'é']) {
19
+ invalid.push({ schema: { type: 'string', format }, value });
20
+ }
21
+ }
22
+ for (const value of ['01/02/2025', '2025-02-29', '2024-02-30', '2025-13-01', '0000-01-01', '+010000-01-01', '2025-01-02T00:00:00.000Z']) {
23
+ invalid.push({ schema: { type: 'string', format: 'date' }, value });
24
+ }
25
+ for (const value of ['January 2, 2025', '2025-01-02', '2025-02-29T00:00:00.000Z', '2024-02-30T00:00:00.000Z',
26
+ '2025-01-02T24:00:00.000Z', '2025-01-02T00:60:00.000Z', '2025-01-02T00:00:60.000Z',
27
+ '0000-01-01T00:00:00.000Z', '+010000-01-01T00:00:00.000Z', '2025-01-02T00:00:00', '2025-01-02T00:00:00+01:00']) {
28
+ invalid.push({ schema: { type: 'string', format: 'date-time' }, value });
29
+ }
30
+ const ranges: Array<[string, number, number]> = [
31
+ ['int8', -128, 127], ['uint8', 0, 255], ['int16', -32768, 32767], ['uint16', 0, 65535],
32
+ ['int32', -2147483648, 2147483647], ['uint32', 0, 4294967295],
33
+ ['int64', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], ['uint64', 0, Number.MAX_SAFE_INTEGER]
34
+ ];
35
+ for (const [format, minimum, maximum] of ranges) {
36
+ for (const type of ['integer', 'number'] as const) {
37
+ for (const value of [minimum - 1, maximum + 1, 1.5]) invalid.push({ schema: { type, format }, value });
38
+ }
39
+ }
40
+ for (const [format, value] of [['int64', 2 ** 63], ['uint64', 2 ** 64], ['float', 3.5e38], ['decimal', 8e28]] as const) {
41
+ invalid.push({ schema: { type: 'number', format }, value });
42
+ }
43
+
44
+ for (const { schema, value } of invalid) {
45
+ describe(`when appending invalid ${schema.type}/${schema.format} content ${JSON.stringify(value)}`, () => {
46
+ for (const operation of ['single', 'batch', 'setup']) {
47
+ let scenario: EventScenario;
48
+ let failure: unknown;
49
+ beforeEach(async () => {
50
+ class Recorded { @field(Object) value: unknown = value; }
51
+ eventType('InvalidFormattedContent')(Recorded);
52
+ getEventTypeMetadata(Recorded)!.schema.properties!.value = schema;
53
+ scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
54
+ const valid = new Recorded();
55
+ valid.value = schema.type === 'string'
56
+ ? schema.format === 'date' ? '2024-02-29' : schema.format === 'date-time' ? '2024-02-29T00:00:00.000Z' : '00000000-0000-0000-0000-000000000000'
57
+ : 1;
58
+ await scenario.given.forEventSource('existing').events(valid);
59
+ const append = operation === 'single' ? scenario.append('A', new Recorded())
60
+ : operation === 'batch' ? scenario.appendMany('A', [valid, new Recorded()])
61
+ : scenario.given.forEventSource('A').events(valid, new Recorded());
62
+ failure = await append.then(() => undefined, error => error);
63
+ });
64
+ it(`should reject the ${operation} without changing history results or the next sequence`, async () => {
65
+ (failure as Error).should.be.instanceOf(UnsupportedEventSequenceOperation);
66
+ scenario.appendedEvents.should.have.lengthOf(1);
67
+ scenario.results.should.have.lengthOf(0);
68
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(1n);
69
+ });
70
+ }
71
+ });
72
+ }
73
+
74
+ for (const property of [
75
+ ...['date', 'date-time', 'guid', 'uuid'].map(format => ({ type: 'string' as const, format })),
76
+ { type: 'integer', format: 'int32' }
77
+ ] satisfies JsonSchema[]) {
78
+ for (const container of ['properties', 'additionalProperties']) {
79
+ describe(`when a nested ${container} schema contains an invalid ${property.format}`, () => {
80
+ it('should validate the nested field before accepting content', async () => {
81
+ class Recorded { @field(Object) payload = { nested: property.type === 'integer' ? 2147483648 : 'invalid' }; }
82
+ eventType('InvalidNestedFormattedContent')(Recorded);
83
+ getEventTypeMetadata(Recorded)!.schema.properties!.payload = container === 'properties'
84
+ ? { type: 'object', properties: { nested: property } } : { type: 'object', additionalProperties: property };
85
+ const scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
86
+ const failure = await scenario.append('A', new Recorded()).then(() => undefined, error => error);
87
+ failure.should.be.instanceOf(UnsupportedEventSequenceOperation);
88
+ scenario.appendedEvents.should.have.lengthOf(0);
89
+ });
90
+ });
91
+ }
92
+ }
93
+
94
+ const valid: Array<{ schema: JsonSchema; values: unknown[] }> = [
95
+ { schema: { type: 'string', format: 'date' }, values: ['0001-01-01', '2024-02-29', '9999-12-31'] },
96
+ { schema: { type: 'string', format: 'date-time' }, values: ['0001-01-01T00:00:00.000Z', '2024-02-29T12:34:56Z', '9999-12-31T23:59:59.999Z'] },
97
+ { schema: { type: 'string', format: 'guid' }, values: ['ABCDEF01-ABCD-ABCD-ABCD-ABCDEF012345'] },
98
+ ...ranges.map(([format, minimum, maximum]) => ({ schema: { type: 'integer' as const, format }, values: [minimum, maximum] }))
99
+ ];
100
+ for (const { schema, values } of valid) {
101
+ describe(`when appending supported ${schema.type}/${schema.format} boundaries`, () => {
102
+ it('should retain the serialized values without kernel normalization', async () => {
103
+ class Recorded { @field(Object) value: unknown; }
104
+ eventType('ValidFormattedContent')(Recorded);
105
+ getEventTypeMetadata(Recorded)!.schema.properties!.value = schema;
106
+ const scenario = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
107
+ await scenario.appendMany('A', values.map(value => Object.assign(new Recorded(), { value })));
108
+ scenario.appendedEvents.map(event => event.content.value).should.deep.equal(values);
109
+ });
110
+ });
111
+ }
112
+
113
+ for (const format of ['unknown', 'int128']) {
114
+ describe(`when registering an unsupported numeric format ${format}`, () => {
115
+ it('should reject the schema rather than ignoring its format', () => {
116
+ class Recorded { @field(Number) value = 1; }
117
+ eventType('UnsupportedNumericFormat')(Recorded);
118
+ getEventTypeMetadata(Recorded)!.schema.properties!.value.format = format;
119
+ (() => new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' }))
120
+ .should.throw(UnsupportedEventSequenceOperation, 'artifacts.eventTypes.schema');
121
+ });
122
+ });
123
+ }