@cratis/chronicle 6.16.1 → 6.17.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 (116) hide show
  1. package/artifacts/ActivatedArtifact.ts +5 -1
  2. package/artifacts/ArtifactCompletionFailed.ts +20 -0
  3. package/artifacts/ArtifactInvocationContext.ts +21 -0
  4. package/artifacts/for_withActivatedArtifact/when_completing/after_processing_failure.spec.ts +34 -0
  5. package/artifacts/index.ts +2 -0
  6. package/artifacts/withActivatedArtifact.ts +20 -3
  7. package/dist/artifacts/ActivatedArtifact.d.ts +4 -1
  8. package/dist/artifacts/ActivatedArtifact.d.ts.map +1 -1
  9. package/dist/artifacts/ArtifactCompletionFailed.d.ts +9 -0
  10. package/dist/artifacts/ArtifactCompletionFailed.d.ts.map +1 -0
  11. package/dist/artifacts/ArtifactCompletionFailed.js +19 -0
  12. package/dist/artifacts/ArtifactCompletionFailed.js.map +1 -0
  13. package/dist/artifacts/ArtifactInvocationContext.d.ts +18 -0
  14. package/dist/artifacts/ArtifactInvocationContext.d.ts.map +1 -0
  15. package/dist/artifacts/ArtifactInvocationContext.js +4 -0
  16. package/dist/artifacts/ArtifactInvocationContext.js.map +1 -0
  17. package/dist/artifacts/index.d.ts +2 -0
  18. package/dist/artifacts/index.d.ts.map +1 -1
  19. package/dist/artifacts/index.js +1 -0
  20. package/dist/artifacts/index.js.map +1 -1
  21. package/dist/artifacts/withActivatedArtifact.d.ts +2 -1
  22. package/dist/artifacts/withActivatedArtifact.d.ts.map +1 -1
  23. package/dist/artifacts/withActivatedArtifact.js +22 -3
  24. package/dist/artifacts/withActivatedArtifact.js.map +1 -1
  25. package/dist/projections/CompiledProjectionDefinitions.d.ts +6 -0
  26. package/dist/projections/CompiledProjectionDefinitions.d.ts.map +1 -1
  27. package/dist/projections/ProjectionCapabilityProvenance.d.ts +8 -0
  28. package/dist/projections/ProjectionCapabilityProvenance.d.ts.map +1 -0
  29. package/dist/projections/ProjectionCapabilityProvenance.js +4 -0
  30. package/dist/projections/ProjectionCapabilityProvenance.js.map +1 -0
  31. package/dist/projections/ProjectionDefinitionCompiler.d.ts +1 -0
  32. package/dist/projections/ProjectionDefinitionCompiler.d.ts.map +1 -1
  33. package/dist/projections/ProjectionDefinitionCompiler.js +130 -4
  34. package/dist/projections/ProjectionDefinitionCompiler.js.map +1 -1
  35. package/dist/projections/ProjectionEventSchema.d.ts +10 -0
  36. package/dist/projections/ProjectionEventSchema.d.ts.map +1 -0
  37. package/dist/projections/ProjectionEventSchema.js +4 -0
  38. package/dist/projections/ProjectionEventSchema.js.map +1 -0
  39. package/dist/projections/VariantReclassifier.d.ts +2 -0
  40. package/dist/projections/VariantReclassifier.d.ts.map +1 -1
  41. package/dist/projections/VariantReclassifier.js.map +1 -1
  42. package/dist/projections/captureProjectionProvenance.d.ts +4 -0
  43. package/dist/projections/captureProjectionProvenance.d.ts.map +1 -0
  44. package/dist/projections/captureProjectionProvenance.js +56 -0
  45. package/dist/projections/captureProjectionProvenance.js.map +1 -0
  46. package/dist/projections/declarative/FromBuilder.d.ts.map +1 -1
  47. package/dist/projections/declarative/FromBuilder.js +9 -0
  48. package/dist/projections/declarative/FromBuilder.js.map +1 -1
  49. package/dist/projections/declarative/ProjectionBuilderCore.d.ts.map +1 -1
  50. package/dist/projections/declarative/ProjectionBuilderCore.js +8 -0
  51. package/dist/projections/declarative/ProjectionBuilderCore.js.map +1 -1
  52. package/dist/projections/declarative/projectionBuilderProvenance.d.ts +16 -0
  53. package/dist/projections/declarative/projectionBuilderProvenance.d.ts.map +1 -0
  54. package/dist/projections/declarative/projectionBuilderProvenance.js +28 -0
  55. package/dist/projections/declarative/projectionBuilderProvenance.js.map +1 -0
  56. package/dist/projections/eventContractPath.d.ts +4 -0
  57. package/dist/projections/eventContractPath.d.ts.map +1 -0
  58. package/dist/projections/eventContractPath.js +7 -0
  59. package/dist/projections/eventContractPath.js.map +1 -0
  60. package/dist/reactors/Reactors.d.ts.map +1 -1
  61. package/dist/reactors/Reactors.js +12 -3
  62. package/dist/reactors/Reactors.js.map +1 -1
  63. package/dist/reducers/Reducers.d.ts.map +1 -1
  64. package/dist/reducers/Reducers.js +12 -3
  65. package/dist/reducers/Reducers.js.map +1 -1
  66. package/dist/testing/projections/ProjectionCapabilities.d.ts +17 -0
  67. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -0
  68. package/dist/testing/projections/ProjectionCapabilities.js +233 -0
  69. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -0
  70. package/dist/testing/projections/UnsupportedProjectionOperation.d.ts +11 -0
  71. package/dist/testing/projections/UnsupportedProjectionOperation.d.ts.map +1 -0
  72. package/dist/testing/projections/UnsupportedProjectionOperation.js +16 -0
  73. package/dist/testing/projections/UnsupportedProjectionOperation.js.map +1 -0
  74. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts +7 -0
  75. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts.map +1 -0
  76. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js +16 -0
  77. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js.map +1 -0
  78. package/dist/testing/projections/for_ProjectionCapabilities/given/Model.d.ts +10 -0
  79. package/dist/testing/projections/for_ProjectionCapabilities/given/Model.d.ts.map +1 -0
  80. package/dist/testing/projections/for_ProjectionCapabilities/given/Model.js +20 -0
  81. package/dist/testing/projections/for_ProjectionCapabilities/given/Model.js.map +1 -0
  82. package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.d.ts +3 -0
  83. package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.d.ts.map +1 -0
  84. package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.js +7 -0
  85. package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.js.map +1 -0
  86. package/dist/testing/projections/for_ProjectionCapabilities/given/compile.d.ts +12 -0
  87. package/dist/testing/projections/for_ProjectionCapabilities/given/compile.d.ts.map +1 -0
  88. package/dist/testing/projections/for_ProjectionCapabilities/given/compile.js +34 -0
  89. package/dist/testing/projections/for_ProjectionCapabilities/given/compile.js.map +1 -0
  90. package/dist/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +1 -1
  92. package/projections/CompiledProjectionDefinitions.ts +6 -0
  93. package/projections/ProjectionCapabilityProvenance.ts +10 -0
  94. package/projections/ProjectionDefinitionCompiler.ts +127 -4
  95. package/projections/ProjectionEventSchema.ts +13 -0
  96. package/projections/VariantReclassifier.ts +2 -0
  97. package/projections/captureProjectionProvenance.ts +50 -0
  98. package/projections/declarative/FromBuilder.ts +9 -0
  99. package/projections/declarative/ProjectionBuilderCore.ts +7 -0
  100. package/projections/declarative/projectionBuilderProvenance.ts +40 -0
  101. package/projections/eventContractPath.ts +9 -0
  102. package/reactors/Reactors.ts +13 -3
  103. package/reactors/for_Reactors/when_delivering/with_activation.spec.ts +93 -2
  104. package/reducers/Reducers.ts +14 -3
  105. package/reducers/for_Reducers/when_delivering/with_activation.spec.ts +67 -1
  106. package/testing/projections/ProjectionCapabilities.ts +213 -0
  107. package/testing/projections/UnsupportedProjectionOperation.ts +16 -0
  108. package/testing/projections/for_ProjectionCapabilities/given/Changed.ts +12 -0
  109. package/testing/projections/for_ProjectionCapabilities/given/Model.ts +13 -0
  110. package/testing/projections/for_ProjectionCapabilities/given/Removed.ts +7 -0
  111. package/testing/projections/for_ProjectionCapabilities/given/compile.ts +39 -0
  112. package/testing/projections/for_ProjectionCapabilities/when_compiling_capability_evidence.spec.ts +68 -0
  113. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +230 -0
  114. package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +73 -0
  115. package/testing/projections/for_ProjectionCapabilities/when_validating_schema_boundaries.spec.ts +118 -0
  116. package/testing/projections/for_ProjectionCapabilities/when_validating_supported_operations.spec.ts +103 -0
@@ -14,6 +14,8 @@ import { FromEveryBuilder } from './FromEveryBuilder.js';
14
14
  import { RemovedWithBuilder } from './RemovedWithBuilder.js';
15
15
  import { RemovedWithJoinBuilder } from './RemovedWithJoinBuilder.js';
16
16
  import { ChildAdditionEntry } from './AddChildBuilder.js';
17
+ import { eventContractPath } from '../eventContractPath.js';
18
+ import { getProjectionBuilderProvenance, recordChildDeclaration, recordFromEveryDeclaration, recordKeyDeclaration } from './projectionBuilderProvenance.js';
17
19
 
18
20
  /** The contract-level event type identifier shape used across projection definitions. */
19
21
  export type ContractEventType = { Id: string; Generation: number; Tombstone: boolean };
@@ -118,6 +120,10 @@ export abstract class ProjectionBuilderCore<TReadModel, TBuilder> {
118
120
  ParentKey: fromBuilder.entry.parentKey
119
121
  }
120
122
  });
123
+ for (const [field, declaration] of getProjectionBuilderProvenance(fromBuilder).keys) {
124
+ recordKeyDeclaration(this, `${eventContractPath('From', contractType)}.${field}`, declaration);
125
+ }
126
+ for (const child of fromBuilder.entry.children) recordChildDeclaration(this, `Children.${child.targetProperty}`, '.from().addChild');
121
127
  this.mergeChildAdditions(contractType, fromBuilder.entry.children);
122
128
  return this as unknown as TBuilder;
123
129
  }
@@ -146,6 +152,7 @@ export abstract class ProjectionBuilderCore<TReadModel, TBuilder> {
146
152
 
147
153
  /** @inheritdoc */
148
154
  fromEvery(builderCallback: (builder: IFromEveryBuilder<TReadModel>) => void): TBuilder {
155
+ recordFromEveryDeclaration(this);
149
156
  const builder = new FromEveryBuilder<TReadModel>();
150
157
  builderCallback(builder);
151
158
  this._all = {
@@ -0,0 +1,40 @@
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
+ /** Internal declaration origins; never part of a builder or its published interface. */
5
+ interface BuilderProvenance {
6
+ fromEvery: boolean;
7
+ keys: Map<string, string>;
8
+ children: Map<string, string>;
9
+ }
10
+
11
+ const declarations = new WeakMap<object, BuilderProvenance>();
12
+
13
+ function forBuilder(builder: object): BuilderProvenance {
14
+ let provenance = declarations.get(builder);
15
+ if (!provenance) {
16
+ provenance = { fromEvery: false, keys: new Map(), children: new Map() };
17
+ declarations.set(builder, provenance);
18
+ }
19
+ return provenance;
20
+ }
21
+
22
+ /** Records an explicit key operation on a from builder or projection builder. */
23
+ export function recordKeyDeclaration(builder: object, path: string, declaration: string): void {
24
+ forBuilder(builder).keys.set(path, declaration);
25
+ }
26
+
27
+ /** Records a child addition without adding state to the public builder type. */
28
+ export function recordChildDeclaration(builder: object, path: string, declaration: string): void {
29
+ forBuilder(builder).children.set(path, declaration);
30
+ }
31
+
32
+ /** Remembers empty fromEvery declarations, which have no wire representation. */
33
+ export function recordFromEveryDeclaration(builder: object): void {
34
+ forBuilder(builder).fromEvery = true;
35
+ }
36
+
37
+ /** Reads declaration origins while compiling, before variant lowering. */
38
+ export function getProjectionBuilderProvenance(builder: object): Readonly<BuilderProvenance> {
39
+ return forBuilder(builder);
40
+ }
@@ -0,0 +1,9 @@
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 type { ContractEventType } from './declarative/ProjectionBuilderCore.js';
5
+
6
+ /** Stable, generation-aware path shared by provenance and capability diagnostics. */
7
+ export function eventContractPath(section: string, eventType: ContractEventType): string {
8
+ return `${section}[${eventType.Id}:${eventType.Generation}]`;
9
+ }
@@ -28,6 +28,7 @@ import type { ActivatedArtifact } from '../artifacts/ActivatedArtifact.js';
28
28
  import { ArtifactKind } from '../artifacts/ArtifactKind.js';
29
29
  import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
30
30
  import { withActivatedArtifact, runActivated } from '../artifacts/withActivatedArtifact.js';
31
+ import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
31
32
 
32
33
  /** Expression used to partition reactor observations by event source ID. */
33
34
  const EVENT_SOURCE_ID_KEY = '$eventSourceId';
@@ -306,12 +307,16 @@ export class Reactors implements IReactors {
306
307
  signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
307
308
  replayState: eventsToObserve.ReplayState
308
309
  }, this._artifactActivator, artifact => runActivated(artifact, () =>
309
- notifyReplayLifecycle(artifact.instance, eventsToObserve.ReplayState, eventsToObserve.Partition)));
310
+ notifyReplayLifecycle(artifact.instance, eventsToObserve.ReplayState, eventsToObserve.Partition),
311
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState }));
310
312
  } else if (reactorInstance) {
311
313
  await notifyReplayLifecycle(reactorInstance, eventsToObserve.ReplayState, eventsToObserve.Partition);
312
314
  }
313
315
  } catch (err) {
314
316
  this._logger.error('Error notifying reactor of replay lifecycle transition', { reactorId: id, error: String(err) });
317
+ if (err instanceof ArtifactCompletionFailed && err.processingError !== undefined) {
318
+ exceptionMessages.push(String(err.processingError));
319
+ }
315
320
  exceptionMessages.push(String(err));
316
321
  exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
317
322
  state = ObservationState.Failed;
@@ -368,7 +373,7 @@ export class Reactors implements IReactors {
368
373
  const handlerResult = await artifact.instance[methodName](content, context, services);
369
374
  await dispatchReactorSideEffects(this._eventLog, handlerResult, context, reactorType as Function,
370
375
  this._eventStoreName, this._namespace, this._resultHandler);
371
- });
376
+ }, { delivery: ArtifactDelivery.Events, eventContext: context, methodName });
372
377
 
373
378
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
374
379
  } catch (err) {
@@ -398,8 +403,13 @@ export class Reactors implements IReactors {
398
403
  } catch (err) {
399
404
  this._logger.error('Error activating reactor', { reactorId: id, error: String(err) });
400
405
  exceptionMessages.push(String(err));
401
- exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
406
+ const completionStack = err instanceof Error ? (err.stack ?? '') : '';
407
+ exceptionStackTrace = err instanceof ArtifactCompletionFailed && exceptionStackTrace
408
+ ? `${exceptionStackTrace}\n${completionStack}` : completionStack;
402
409
  state = ObservationState.Failed;
410
+ if (err instanceof ArtifactCompletionFailed) {
411
+ lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
412
+ }
403
413
  }
404
414
  } else {
405
415
  await processEvents({ instance: reactorInstance ?? {} });
@@ -11,6 +11,7 @@ import type { IClientArtifactsProvider } from '../../../artifacts/IClientArtifac
11
11
  import { ArtifactDelivery } from '../../../artifacts/ArtifactDelivery.js';
12
12
  import { ArtifactKind } from '../../../artifacts/ArtifactKind.js';
13
13
  import type { ArtifactActivationContext } from '../../../artifacts/ArtifactActivationContext.js';
14
+ import type { ArtifactInvocationContext } from '../../../artifacts/ArtifactInvocationContext.js';
14
15
  import type { ClientArtifactsActivator } from '../../../artifacts/ClientArtifactsActivator.js';
15
16
  import type { IEventStore } from '../../../IEventStore.js';
16
17
  import type { IEventLog } from '../../../eventSequences/IEventLog.js';
@@ -159,10 +160,11 @@ describe('when delivering reactor batches', () => {
159
160
  observed.length = 0;
160
161
  serviceReferences.length = 0;
161
162
  const contexts: ArtifactActivationContext[] = [];
163
+ const invocations: ArtifactInvocationContext[] = [];
162
164
  const boundaries: string[] = [];
163
165
  const activator: ClientArtifactsActivator = (type, context) => {
164
166
  contexts.push(context);
165
- return { instance: new type(), run: async callback => { boundaries.push('enter');
167
+ return { instance: new type(), run: async (callback, invocation) => { invocations.push(invocation!); boundaries.push('enter');
166
168
  try { return await callback(); } finally { boundaries.push('exit'); } },
167
169
  dispose: () => { boundaries.push('dispose'); } };
168
170
  };
@@ -177,6 +179,11 @@ describe('when delivering reactor batches', () => {
177
179
  contexts[0].readModels.should.equal(result.store.readModels);
178
180
  if (contexts[0].delivery === ArtifactDelivery.Events) contexts[0].eventContext.sequenceNumber.should.equal(1n);
179
181
  boundaries.should.deep.equal(['enter', 'exit', 'enter', 'exit', 'dispose', 'enter', 'exit', 'dispose']);
182
+ invocations.map(invocation => invocation.delivery).should.deep.equal([ArtifactDelivery.Events, ArtifactDelivery.Events, ArtifactDelivery.Events]);
183
+ invocations.map(invocation => invocation.delivery === ArtifactDelivery.Events ? invocation.eventContext.sequenceNumber : 0n)
184
+ .should.deep.equal([1n, 2n, 3n]);
185
+ invocations.map(invocation => invocation.delivery === ArtifactDelivery.Events ? invocation.methodName : '')
186
+ .should.deep.equal(['activationEvent', 'activationEvent', 'activationEvent']);
180
187
  observed.should.deep.equal(['one:book-1', 'two:book-1', 'three:book-1']);
181
188
  serviceReferences.every(services => services.eventStore === result.store && services.readModels === result.store.readModels).should.be.true;
182
189
  });
@@ -241,9 +248,16 @@ describe('when delivering reactor batches', () => {
241
248
  it('should notify separately before processing events in the same message', async () => {
242
249
  observed.length = 0;
243
250
  const contexts: ArtifactActivationContext[] = [];
244
- const activator: ClientArtifactsActivator = (type, context) => { contexts.push(context); return { instance: new type() }; };
251
+ const invocations: ArtifactInvocationContext[] = [];
252
+ const activator: ClientArtifactsActivator = (type, context) => { contexts.push(context); return { instance: new type(),
253
+ run: async (callback, invocation) => { invocations.push(invocation!); return callback(); } }; };
245
254
  const result = await observe(ActivationReactor, [delivery([event('live', 1n)], ReplayState.BeginReplay)], 'tenant-a', activator);
246
255
  observed.should.deep.equal(['notification', 'live:book-1']);
256
+ invocations.should.deep.equal([
257
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: ReplayState.BeginReplay },
258
+ { delivery: ArtifactDelivery.Events, eventContext: contexts[1].delivery === ArtifactDelivery.Events ? contexts[1].eventContext : undefined,
259
+ methodName: 'activationEvent' }
260
+ ]);
247
261
  contexts.map(context => context.delivery).should.deep.equal([ArtifactDelivery.ReplayNotification, ArtifactDelivery.Events]);
248
262
  result.acknowledgements[0]?.State.should.equal(ObservationState.Success);
249
263
  });
@@ -370,6 +384,83 @@ describe('when delivering reactor batches', () => {
370
384
  reactors.dispose();
371
385
  });
372
386
 
387
+ it('should fail a replay notification when completion fails and suppress event handling', async () => {
388
+ observed.length = 0;
389
+ const steps: string[] = [];
390
+ const activator: ClientArtifactsActivator = (type, context) => ({ instance: new type(),
391
+ complete: () => { steps.push('complete'); if (context.delivery === ArtifactDelivery.ReplayNotification) throw new Error('replay cleanup'); },
392
+ dispose: () => { steps.push('dispose'); } });
393
+ const result = await observe(ActivationReactor, [delivery([event('one', 1n)], ReplayState.BeginReplay)], 'tenant-a', activator);
394
+ observed.should.deep.equal(['notification']);
395
+ steps.should.deep.equal(['complete', 'dispose']);
396
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
397
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['ArtifactCompletionFailed: Artifact completion failed: Error: replay cleanup']);
398
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(4294967295n);
399
+ });
400
+
401
+ it('should preserve both replay notification and completion errors', async () => {
402
+ const steps: string[] = [];
403
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
404
+ complete: () => { steps.push('complete'); throw new Error('replay cleanup'); },
405
+ dispose: () => { steps.push('dispose'); } });
406
+ const result = await observe(FailingNotificationReactor, [delivery([event('one', 1n)], ReplayState.BeginReplay)], 'tenant-a', activator);
407
+ steps.should.deep.equal(['complete', 'dispose']);
408
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal([
409
+ 'Error: notification failed', 'ArtifactCompletionFailed: Artifact completion failed: Error: replay cleanup'
410
+ ]);
411
+ });
412
+
413
+ it('should complete each successful lease before acknowledgement and then dispose it', async () => {
414
+ const steps: string[] = [];
415
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
416
+ complete: () => { steps.push('complete'); }, dispose: () => { steps.push('dispose'); } });
417
+ const result = await observe(ActivationReactor, [delivery([event('one', 1n), event('two', 2n)])], 'tenant-a', activator);
418
+ steps.should.deep.equal(['complete', 'dispose']);
419
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Success);
420
+ });
421
+
422
+ it('should complete after processing failure before acknowledgement and preserve the processing error', async () => {
423
+ const steps: string[] = [];
424
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
425
+ complete: () => { steps.push('complete'); }, dispose: () => { steps.push('dispose'); } });
426
+ const result = await observe(AppendReactor, [delivery([event('one', 1n)])], 'tenant-a', activator, false);
427
+ steps.should.deep.equal(['complete', 'dispose']);
428
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['Error: append rejected']);
429
+ });
430
+
431
+ it('should fail completion distinctly without advancing a successful checkpoint and still dispose', async () => {
432
+ const steps: string[] = [];
433
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
434
+ complete: async () => { steps.push('complete'); throw new Error('scope cleanup'); },
435
+ dispose: () => { steps.push('dispose'); } });
436
+ const result = await observe(ActivationReactor, [delivery([event('one', 1n), event('two', 2n)])], 'tenant-a', activator);
437
+ steps.should.deep.equal(['complete', 'dispose']);
438
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
439
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(4294967295n);
440
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['ArtifactCompletionFailed: Artifact completion failed: Error: scope cleanup']);
441
+ });
442
+
443
+ it('should report both processing and completion errors without acknowledging earlier successful events', async () => {
444
+ const steps: string[] = [];
445
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
446
+ complete: () => { steps.push('complete'); throw new Error('scope cleanup'); },
447
+ dispose: () => { steps.push('dispose'); } });
448
+ const result = await observe(AppendReactor, [delivery([event('one', 1n), event('two', 2n)])], 'tenant-a', activator, false);
449
+ steps.should.deep.equal(['complete', 'dispose']);
450
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal([
451
+ 'Error: append rejected', 'ArtifactCompletionFailed: Artifact completion failed: Error: scope cleanup'
452
+ ]);
453
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(4294967295n);
454
+ });
455
+
456
+ it('should leave a processing failure unchanged when legacy disposal also fails', async () => {
457
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
458
+ dispose: () => { throw new Error('legacy cleanup'); } });
459
+ const result = await observe(AppendReactor, [delivery([event('one', 1n)])], 'tenant-a', activator, false);
460
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
461
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['Error: append rejected']);
462
+ });
463
+
373
464
  it('should log disposal failure without changing successful acknowledgement', async () => {
374
465
  const errors: string[] = [];
375
466
  diag.setLogger({ error: (...values: unknown[]) => { errors.push(values.map(String).join(' ')); } } as DiagLogger, DiagLogLevel.ERROR);
@@ -27,6 +27,7 @@ import type { ActivatedArtifact } from '../artifacts/ActivatedArtifact.js';
27
27
  import { ArtifactKind } from '../artifacts/ArtifactKind.js';
28
28
  import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
29
29
  import { withActivatedArtifact, runActivated } from '../artifacts/withActivatedArtifact.js';
30
+ import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
30
31
 
31
32
  /** Expression used to partition reducer observations by event source ID. */
32
33
  const EVENT_SOURCE_ID_KEY = '$eventSourceId';
@@ -367,12 +368,16 @@ export class Reducers implements IReducers {
367
368
  signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
368
369
  replayState: operation.ReplayState
369
370
  }, this._artifactActivator, artifact => runActivated(artifact, () =>
370
- notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition)));
371
+ notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
372
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState }));
371
373
  } else if (reducerInstance) {
372
374
  await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
373
375
  }
374
376
  } catch (err) {
375
377
  this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: id, error: String(err) });
378
+ if (err instanceof ArtifactCompletionFailed && err.processingError !== undefined) {
379
+ exceptionMessages.push(String(err.processingError));
380
+ }
376
381
  exceptionMessages.push(String(err));
377
382
  exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
378
383
  state = ObservationState.Failed;
@@ -409,7 +414,8 @@ export class Reducers implements IReducers {
409
414
  hasState: currentState !== undefined
410
415
  });
411
416
 
412
- currentState = await runActivated(artifact, () => dispatcher.invoke(artifact.instance, entry, content, currentState, context));
417
+ currentState = await runActivated(artifact, () => dispatcher.invoke(artifact.instance, entry, content, currentState, context),
418
+ { delivery: ArtifactDelivery.Events, eventContext: context, methodName: entry.methodName });
413
419
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
414
420
  } catch (err) {
415
421
  this._logger.error('Error handling event in reducer', { reducerId: id, error: String(err) });
@@ -437,8 +443,13 @@ export class Reducers implements IReducers {
437
443
  } catch (err) {
438
444
  this._logger.error('Error activating reducer', { reducerId: id, error: String(err) });
439
445
  exceptionMessages.push(String(err));
440
- exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
446
+ const completionStack = err instanceof Error ? (err.stack ?? '') : '';
447
+ exceptionStackTrace = err instanceof ArtifactCompletionFailed && exceptionStackTrace
448
+ ? `${exceptionStackTrace}\n${completionStack}` : completionStack;
441
449
  state = ObservationState.Failed;
450
+ if (err instanceof ArtifactCompletionFailed) {
451
+ lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
452
+ }
442
453
  }
443
454
  } else {
444
455
  await processEvents({ instance: reducerInstance ?? {} });
@@ -9,6 +9,7 @@ import type { IClientArtifactsProvider } from '../../../artifacts/IClientArtifac
9
9
  import { ArtifactDelivery } from '../../../artifacts/ArtifactDelivery.js';
10
10
  import { ArtifactKind } from '../../../artifacts/ArtifactKind.js';
11
11
  import type { ArtifactActivationContext } from '../../../artifacts/ArtifactActivationContext.js';
12
+ import type { ArtifactInvocationContext } from '../../../artifacts/ArtifactInvocationContext.js';
12
13
  import type { ClientArtifactsActivator } from '../../../artifacts/ClientArtifactsActivator.js';
13
14
  import type { IEventStore } from '../../../IEventStore.js';
14
15
  import { eventType } from '../../../events/eventTypeDecorator.js';
@@ -92,10 +93,11 @@ describe('when delivering reducer batches', () => {
92
93
  instances.length = 0;
93
94
  notifications.length = 0;
94
95
  const contexts: ArtifactActivationContext[] = [];
96
+ const invocations: ArtifactInvocationContext[] = [];
95
97
  const steps: string[] = [];
96
98
  const activator: ClientArtifactsActivator = (type, context) => {
97
99
  contexts.push(context);
98
- return { instance: new type(), run: async callback => { steps.push('enter');
100
+ return { instance: new type(), run: async (callback, invocation) => { invocations.push(invocation!); steps.push('enter');
99
101
  try { return await callback(); } finally { steps.push('exit'); } },
100
102
  dispose: () => { steps.push('dispose'); } };
101
103
  };
@@ -108,6 +110,11 @@ describe('when delivering reducer batches', () => {
108
110
  if (contexts[1].delivery === ArtifactDelivery.Events) contexts[1].eventContext.sequenceNumber.should.equal(1n);
109
111
  notifications.should.deep.equal(['begin']);
110
112
  steps.should.deep.equal(['enter', 'exit', 'dispose', 'enter', 'exit', 'enter', 'exit', 'dispose', 'enter', 'exit', 'dispose']);
113
+ invocations[0].should.deep.equal({ delivery: ArtifactDelivery.ReplayNotification, replayState: ReplayState.BeginReplay });
114
+ invocations.slice(1).map(invocation => invocation.delivery === ArtifactDelivery.Events ? invocation.eventContext.sequenceNumber : 0n)
115
+ .should.deep.equal([1n, 2n, 3n]);
116
+ invocations.slice(1).map(invocation => invocation.delivery === ArtifactDelivery.Events ? invocation.methodName : '')
117
+ .should.deep.equal(['reductionEvent', 'reductionEvent', 'reductionEvent']);
111
118
  result.acknowledgements[0]?.ReadModelState.should.equal('{"count":3}');
112
119
  });
113
120
 
@@ -166,6 +173,65 @@ describe('when delivering reducer batches', () => {
166
173
  }
167
174
  });
168
175
 
176
+ it('should fail a replay notification on completion failure without processing events or publishing state', async () => {
177
+ notifications.length = 0;
178
+ const steps: string[] = [];
179
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
180
+ complete: () => { steps.push('complete'); throw new Error('replay cleanup'); },
181
+ dispose: () => { steps.push('dispose'); } });
182
+ const result = await observe(CountingReducer, [batch([event(1, 1n)], ReplayState.BeginReplay)], activator);
183
+ notifications.should.deep.equal(['begin']);
184
+ steps.should.deep.equal(['complete', 'dispose']);
185
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
186
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['ArtifactCompletionFailed: Artifact completion failed: Error: replay cleanup']);
187
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(4294967295n);
188
+ result.acknowledgements[0]?.ReadModelState.should.equal('');
189
+ });
190
+
191
+ it('should complete once per successful lease before publishing reduced state and then dispose', async () => {
192
+ const steps: string[] = [];
193
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
194
+ complete: () => { steps.push('complete'); }, dispose: () => { steps.push('dispose'); } });
195
+ const result = await observe(CountingReducer, [batch([event(1, 1n), event(2, 2n)])], activator);
196
+ steps.should.deep.equal(['complete', 'dispose']);
197
+ result.acknowledgements[0]?.ReadModelState.should.equal('{"count":3}');
198
+ });
199
+
200
+ it('should complete after handler failure and preserve the processing error', async () => {
201
+ const steps: string[] = [];
202
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
203
+ complete: () => { steps.push('complete'); }, dispose: () => { steps.push('dispose'); } });
204
+ const result = await observe(FailingReducer, [batch([event(1, 1n)])], activator);
205
+ steps.should.deep.equal(['complete', 'dispose']);
206
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['Error: reduction failed']);
207
+ });
208
+
209
+ it('should fail completion distinctly, discard the tentative state and checkpoint, then dispose', async () => {
210
+ const steps: string[] = [];
211
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
212
+ complete: async () => { steps.push('complete'); throw new Error('scope cleanup'); },
213
+ dispose: () => { steps.push('dispose'); } });
214
+ const result = await observe(CountingReducer, [batch([event(1, 1n), event(2, 2n)])], activator);
215
+ steps.should.deep.equal(['complete', 'dispose']);
216
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
217
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['ArtifactCompletionFailed: Artifact completion failed: Error: scope cleanup']);
218
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(4294967295n);
219
+ result.acknowledgements[0]?.ReadModelState.should.equal('');
220
+ });
221
+
222
+ it('should preserve both failures when completion also fails after processing', async () => {
223
+ const steps: string[] = [];
224
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(),
225
+ complete: () => { steps.push('complete'); throw new Error('scope cleanup'); },
226
+ dispose: () => { steps.push('dispose'); } });
227
+ const result = await observe(FailingReducer, [batch([event(1, 1n)])], activator);
228
+ steps.should.deep.equal(['complete', 'dispose']);
229
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal([
230
+ 'Error: reduction failed', 'ArtifactCompletionFailed: Artifact completion failed: Error: scope cleanup'
231
+ ]);
232
+ result.acknowledgements[0]?.ReadModelState.should.equal('');
233
+ });
234
+
169
235
  it('should dispose after handler failure without acknowledging the failed event', async () => {
170
236
  let disposed = 0;
171
237
  const activator: ClientArtifactsActivator = type => ({ instance: new type(), dispose: () => { disposed++; } });
@@ -0,0 +1,213 @@
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 { AutoMap, type ProjectionDefinition } from '@cratis/chronicle.contracts';
5
+ import { EventSequenceId } from '../../eventSequences/EventSequenceId.js';
6
+ import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js';
7
+ import { eventContractPath } from '../../projections/eventContractPath.js';
8
+ import { eventContextPropertyExpression } from '../../projections/eventContextPropertyExpression.js';
9
+ import { InvalidEventContextPropertyError } from '../../projections/InvalidEventContextPropertyError.js';
10
+ import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
11
+ import { getEventTypeMapKey } from '../../projections/modelBound/childrenAndNestedBuilder.js';
12
+ import type { JsonSchema } from '../../schemas/JsonSchema.js';
13
+ import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
14
+
15
+ /** Validates *all* subscribed operations against the bounded scenario subset, before replay. */
16
+ export class ProjectionCapabilities {
17
+ /**
18
+ * Throws with the original declaration and contract path for the first unsupported operation.
19
+ * @param compiled - Definitions and their schema/provenance evidence.
20
+ * @param definition - The compiled contract to validate.
21
+ */
22
+ static validate(compiled: CompiledProjectionDefinitions, definition: ProjectionDefinition): void {
23
+ const model = String(definition.ReadModel ?? definition.Identifier ?? '<unknown>');
24
+ const wire = definition as unknown as Record<string, unknown>;
25
+ const reject = (path: string, reason: string, fallback?: string) : never => {
26
+ const source = compiled.provenance.get(definition)?.find(entry => entry.contractPath.startsWith('From['))?.declaration ?? '.from';
27
+ const declaration = compiled.provenance.get(definition)?.find(entry => entry.contractPath === path)?.declaration
28
+ ?? fallback ?? (path.includes('.AutoMap.') ? `${source} (AutoMap)`
29
+ : path.startsWith('InitialModelState') ? '.withInitialValues'
30
+ : path.startsWith('ReadModel') ? source : 'contract');
31
+ throw new UnsupportedProjectionOperation(model, path, declaration, reason);
32
+ };
33
+ if (!compiled.definitions.includes(definition)) reject('Definition', 'definition is not part of this compile');
34
+ const provenance = compiled.provenance.get(definition) ?? reject('Definition', 'capability provenance is unavailable');
35
+ const catalog = compiled.eventSchemas.get(definition) ?? reject('Definition', 'event-schema catalog is unavailable');
36
+ const variant = provenance.find(entry => entry.contractPath === 'Variant');
37
+ if (variant) reject('Variant', 'variants require a kernel-backed test');
38
+ if (wire.IsActive === false) reject('IsActive', 'passive projections require a kernel-backed test');
39
+ if (wire.EventSequenceId !== EventSequenceId.eventLog.value) reject('EventSequenceId', 'non-default event sequences require a kernel-backed test');
40
+ for (const section of ['Join', 'Children', 'Nested', 'RemovedWithJoin'] as const) {
41
+ const value = wire[section];
42
+ const paths = Array.isArray(value) ? value.map((entry: { Key: ContractEventType }) => eventContractPath(section, entry.Key))
43
+ : Object.keys(value as Record<string, unknown> ?? {}).map(property => `${section}.${property}`);
44
+ if (paths.length) reject(paths[0], `${section === 'Children' ? 'children projections' : section === 'Nested' ? 'nested projections' : section === 'Join' ? 'joins' : 'removedWithJoin'} require a kernel-backed test`);
45
+ }
46
+ if ((wire.FromEvery as unknown[] | undefined)?.length) reject('FromEvery', 'derivative projections require a kernel-backed test');
47
+ if (wire.FromEventProperty) reject('FromEventProperty', 'event-property subscriptions require a kernel-backed test');
48
+ const all = wire.All as { Properties?: Record<string, string>; IncludeChildren?: boolean; AutoMap?: AutoMap } | undefined;
49
+ if (provenance.some(entry => entry.contractPath === 'All') || all?.IncludeChildren ||
50
+ Object.keys(all?.Properties ?? {}).length || all?.AutoMap === AutoMap.Enabled) {
51
+ reject('All', 'fromEvery/all subscriptions require a kernel-backed test');
52
+ }
53
+
54
+ const readModel = compiled.readModels.find(candidate => candidate.Type.Identifier === model)
55
+ ?? reject('ReadModel', 'a single registered read-model schema is required');
56
+ let schema: JsonSchema;
57
+ try {
58
+ schema = JSON.parse(readModel.Schema) as JsonSchema;
59
+ } catch {
60
+ reject('ReadModel.Schema', 'read-model schema is not valid JSON');
61
+ }
62
+ const identifier = schema!.properties?.id ?? schema!.properties?.Id ?? { type: 'string' };
63
+ if (!((identifier.type === 'string' && (!identifier.format || identifier.format === 'guid')) ||
64
+ (identifier.type === 'number' && (!identifier.format || identifier.format === 'double')) ||
65
+ (identifier.type === 'integer' && ['int32', 'uint32'].includes(identifier.format ?? '')))) {
66
+ reject('ReadModel.Schema.id', 'identifier schema must be string, number, or GUID (int32/uint32 identifiers are supported)');
67
+ }
68
+ let initial: unknown;
69
+ try { initial = JSON.parse(String(wire.InitialModelState ?? '{}')); } catch { reject('InitialModelState', 'initial values must be a JSON object'); }
70
+ if (initial === null || typeof initial !== 'object' || Array.isArray(initial)) reject('InitialModelState', 'initial values must be a JSON object');
71
+ for (const [property, value] of Object.entries(initial as Record<string, unknown>)) {
72
+ const target = schema!.properties?.[property]
73
+ ?? reject(`InitialModelState.${property}`, 'initial value has no read-model schema');
74
+ this.checkSchema(target, `InitialModelState.${property}`, reject);
75
+ if (typeof value === 'number') {
76
+ const outsideIntegerRange = !Number.isSafeInteger(value) ||
77
+ (target.format === 'int32' && (value < -2147483648 || value > 2147483647)) ||
78
+ (target.format === 'uint32' && (value < 0 || value > 4294967295));
79
+ if (!Number.isFinite(value) || (target.type === 'integer' && outsideIntegerRange)) {
80
+ reject(`InitialModelState.${property}`, 'initial numeric value is outside the supported finite/integer range');
81
+ }
82
+ }
83
+ }
84
+ const generations = new Map<string, number>();
85
+ const requireEventSchema = (eventType: ContractEventType, path: string): JsonSchema => {
86
+ const previous = generations.get(eventType.Id);
87
+ if (previous !== undefined && previous !== eventType.Generation) reject(path, 'multiple generations of the same event-type id require a kernel-backed test');
88
+ generations.set(eventType.Id, eventType.Generation);
89
+ const event = catalog.get(getEventTypeMapKey(eventType)) ?? reject(path, 'participating event schema is unavailable');
90
+ return event.schema;
91
+ };
92
+ const from = wire.From as FromRecord[] ?? [];
93
+ const removedWith = wire.RemovedWith as RemovedWithRecord[] ?? [];
94
+ for (const entry of [...from, ...removedWith]) {
95
+ const section = from.includes(entry as FromRecord) ? 'From' : 'RemovedWith';
96
+ const path = eventContractPath(section, entry.Key);
97
+ const eventSchema = requireEventSchema(entry.Key, path);
98
+ if (entry.Value.Key !== '$eventSourceId') reject(`${path}.Key`, 'only $eventSourceId keys are supported');
99
+ if (entry.Value.ParentKey) reject(`${path}.ParentKey`, 'parent keys require a kernel-backed test');
100
+ if (section === 'RemovedWith') continue;
101
+ const properties = (entry as FromRecord).Value.Properties ?? {};
102
+ for (const [property, expression] of Object.entries(properties)) {
103
+ const mappingPath = `${path}.Properties.${property}`;
104
+ this.checkMapping(schema!, eventSchema, property, expression, mappingPath, reject);
105
+ }
106
+ const expressions = Object.values(properties);
107
+ const aggregateOnly = expressions.length > 0 && expressions.every(expression =>
108
+ /^(?:\$add\([^()]+\)|\$subtract\([^()]+\)|\$count|\$increment|\$decrement)$/.test(expression));
109
+ if (wire.AutoMap === AutoMap.Enabled && !aggregateOnly) {
110
+ this.checkAutoMap(schema!, eventSchema, properties, wire.NoAutoMapProperties as string[] ?? [], path, reject);
111
+ }
112
+ }
113
+ }
114
+
115
+ private static checkAutoMap(
116
+ modelSchema: JsonSchema, eventSchema: JsonSchema, explicit: Record<string, string>, exclusions: string[],
117
+ path: string, reject: (path: string, reason: string) => never
118
+ ): void {
119
+ for (const [destination] of Object.entries(modelSchema.properties ?? {})) {
120
+ if (destination in explicit || exclusions.includes(destination)) continue;
121
+ const candidates = Object.keys(eventSchema.properties ?? {}).filter(source => source.toLowerCase() === destination.toLowerCase());
122
+ if (candidates.length > 1) reject(`${path}.AutoMap.${destination}`, `inferred AutoMap source is ambiguous: ${candidates.join(', ')}`);
123
+ if (candidates.length) this.checkMapping(modelSchema, eventSchema, destination, candidates[0], `${path}.AutoMap.${destination}`, reject);
124
+ }
125
+ }
126
+
127
+ private static checkMapping(
128
+ modelSchema: JsonSchema, eventSchema: JsonSchema, destination: string, expression: string,
129
+ path: string, reject: (path: string, reason: string) => never
130
+ ): void {
131
+ const fail = (reason: string): never => reject(path, `expression '${expression}': ${reason}`);
132
+ const target = modelSchema.properties?.[destination]
133
+ ?? fail('dynamic or unknown destination paths require a kernel-backed test');
134
+ if (destination.includes('.') || destination.includes('$')) fail('dynamic or unknown destination paths require a kernel-backed test');
135
+ this.checkSchema(target, path, (_path, reason) => fail(reason));
136
+ if (expression === '$eventSourceId' || expression === '$null') return;
137
+ if (expression.startsWith('$eventContext(')) {
138
+ if (/^\$eventContext\(.+\(\)\)$/.test(expression)) fail('derived event-context functions require a kernel-backed test');
139
+ const path = /^\$eventContext\(([^()]*)\)$/.exec(expression)?.[1];
140
+ if (path) {
141
+ try {
142
+ if (eventContextPropertyExpression(path) === expression) return;
143
+ } catch (error) {
144
+ if (!(error instanceof InvalidEventContextPropertyError)) throw error;
145
+ }
146
+ }
147
+ fail('event-context path is not supported by the client');
148
+ }
149
+ if (/^\$value\([\p{L}\p{Mn}\p{Nd}\p{Pc} ._/:*+-]*\)$/u.test(expression)) {
150
+ const text = expression.slice(7, -1);
151
+ if (target.format === 'date-time') fail('$value date-time literals require a kernel-backed test');
152
+ if (this.numeric(target)) {
153
+ if (!/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(text) || !Number.isFinite(Number(text)) ||
154
+ (target.type === 'integer' && (!Number.isSafeInteger(Number(text)) ||
155
+ (target.format === 'int32' && (Number(text) < -2147483648 || Number(text) > 2147483647)) ||
156
+ (target.format === 'uint32' && (Number(text) < 0 || Number(text) > 4294967295))))) {
157
+ fail('$value numeric literal is outside the supported finite/integer range');
158
+ }
159
+ } else if (target.type === 'boolean' && !/^(?:true|false)$/i.test(text)) {
160
+ fail('$value boolean literal must be true or false');
161
+ } else if (target.format === 'guid' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) {
162
+ fail('$value GUID literal is not canonical');
163
+ } else if (target.type === 'object' || target.type === 'array') {
164
+ fail('$value object/array literals require a kernel-backed test');
165
+ }
166
+ return;
167
+ }
168
+ const operation = /^(\$add|\$subtract)\(([^()]+)\)$/.exec(expression);
169
+ const arithmetic = operation || ['$count', '$increment', '$decrement'].includes(expression);
170
+ if (arithmetic) {
171
+ if (!this.numeric(target)) fail('arithmetic requires a supported finite number/double or int32/uint32 schema (not float, decimal, duration, or int64)');
172
+ if (operation) {
173
+ const operand = this.propertyAt(eventSchema, operation[2]);
174
+ if (!operand || !this.numeric(operand)) fail('arithmetic operand requires a supported numeric event schema');
175
+ }
176
+ return;
177
+ }
178
+ if (/^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*$/.test(expression)) {
179
+ const source = this.propertyAt(eventSchema, expression);
180
+ if (!source) return fail(`event property '${expression}' is absent from the participating event schema`);
181
+ this.checkSchema(source, path, (_path, reason) => fail(reason));
182
+ return;
183
+ }
184
+ if (expression.startsWith('$context.')) {
185
+ return fail('is a legacy expression the kernel does not resolve; event-context mappings use $eventContext(...)');
186
+ }
187
+ fail('requires a kernel-backed test');
188
+ }
189
+
190
+ private static propertyAt(schema: JsonSchema, path: string): JsonSchema | undefined {
191
+ return path.split('.').reduce<JsonSchema | undefined>((current, segment) => current?.properties?.[segment], schema);
192
+ }
193
+
194
+ private static numeric(schema: JsonSchema): boolean {
195
+ return (schema.type === 'number' && (!schema.format || schema.format === 'double')) ||
196
+ (schema.type === 'integer' && ['int32', 'uint32'].includes(schema.format ?? ''));
197
+ }
198
+
199
+ private static checkSchema(schema: JsonSchema, path: string, reject: (path: string, reason: string) => never): void {
200
+ if (!schema.type || schema.type === 'null' || (schema.format && !['guid', 'date-time', 'double', 'int32', 'uint32'].includes(schema.format)) ||
201
+ (schema.format === 'date-time' && schema.type !== 'string') ||
202
+ (schema.format === 'guid' && schema.type !== 'string') ||
203
+ (schema.format === 'double' && schema.type !== 'number') ||
204
+ (['int32', 'uint32'].includes(schema.format ?? '') && schema.type !== 'integer')) {
205
+ reject(path, `schema type/format '${schema.type ?? 'unknown'}${schema.format ? `/${schema.format}` : ''}' requires a kernel-backed test`);
206
+ }
207
+ if (schema.type === 'integer' && !this.numeric(schema)) reject(path, 'integer width requires a kernel-backed test');
208
+ if (schema.type === 'array' && schema.items) this.checkSchema(schema.items, path, reject);
209
+ if (schema.type === 'object') {
210
+ for (const property of Object.values(schema.properties ?? {})) this.checkSchema(property, path, reject);
211
+ }
212
+ }
213
+ }