@cratis/chronicle 6.16.1 → 6.18.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.
- package/artifacts/ActivatedArtifact.ts +5 -1
- package/artifacts/ArtifactCompletionFailed.ts +20 -0
- package/artifacts/ArtifactInvocationContext.ts +21 -0
- package/artifacts/for_withActivatedArtifact/when_completing/after_processing_failure.spec.ts +34 -0
- package/artifacts/index.ts +2 -0
- package/artifacts/withActivatedArtifact.ts +20 -3
- package/dist/artifacts/ActivatedArtifact.d.ts +4 -1
- package/dist/artifacts/ActivatedArtifact.d.ts.map +1 -1
- package/dist/artifacts/ArtifactCompletionFailed.d.ts +9 -0
- package/dist/artifacts/ArtifactCompletionFailed.d.ts.map +1 -0
- package/dist/artifacts/ArtifactCompletionFailed.js +19 -0
- package/dist/artifacts/ArtifactCompletionFailed.js.map +1 -0
- package/dist/artifacts/ArtifactInvocationContext.d.ts +18 -0
- package/dist/artifacts/ArtifactInvocationContext.d.ts.map +1 -0
- package/dist/artifacts/ArtifactInvocationContext.js +4 -0
- package/dist/artifacts/ArtifactInvocationContext.js.map +1 -0
- package/dist/artifacts/index.d.ts +2 -0
- package/dist/artifacts/index.d.ts.map +1 -1
- package/dist/artifacts/index.js +1 -0
- package/dist/artifacts/index.js.map +1 -1
- package/dist/artifacts/withActivatedArtifact.d.ts +2 -1
- package/dist/artifacts/withActivatedArtifact.d.ts.map +1 -1
- package/dist/artifacts/withActivatedArtifact.js +22 -3
- package/dist/artifacts/withActivatedArtifact.js.map +1 -1
- package/dist/projections/CompiledProjectionDefinitions.d.ts +6 -0
- package/dist/projections/CompiledProjectionDefinitions.d.ts.map +1 -1
- package/dist/projections/ProjectionCapabilityProvenance.d.ts +8 -0
- package/dist/projections/ProjectionCapabilityProvenance.d.ts.map +1 -0
- package/dist/projections/ProjectionCapabilityProvenance.js +4 -0
- package/dist/projections/ProjectionCapabilityProvenance.js.map +1 -0
- package/dist/projections/ProjectionDefinitionCompiler.d.ts +1 -0
- package/dist/projections/ProjectionDefinitionCompiler.d.ts.map +1 -1
- package/dist/projections/ProjectionDefinitionCompiler.js +130 -4
- package/dist/projections/ProjectionDefinitionCompiler.js.map +1 -1
- package/dist/projections/ProjectionEventSchema.d.ts +10 -0
- package/dist/projections/ProjectionEventSchema.d.ts.map +1 -0
- package/dist/projections/ProjectionEventSchema.js +4 -0
- package/dist/projections/ProjectionEventSchema.js.map +1 -0
- package/dist/projections/VariantReclassifier.d.ts +2 -0
- package/dist/projections/VariantReclassifier.d.ts.map +1 -1
- package/dist/projections/VariantReclassifier.js.map +1 -1
- package/dist/projections/captureProjectionProvenance.d.ts +4 -0
- package/dist/projections/captureProjectionProvenance.d.ts.map +1 -0
- package/dist/projections/captureProjectionProvenance.js +56 -0
- package/dist/projections/captureProjectionProvenance.js.map +1 -0
- package/dist/projections/declarative/AddChildBuilder.d.ts +13 -2
- package/dist/projections/declarative/AddChildBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/AddChildBuilder.js +27 -1
- package/dist/projections/declarative/AddChildBuilder.js.map +1 -1
- package/dist/projections/declarative/FromBuilder.d.ts +4 -0
- package/dist/projections/declarative/FromBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/FromBuilder.js +10 -2
- package/dist/projections/declarative/FromBuilder.js.map +1 -1
- package/dist/projections/declarative/IAddChildBuilder.d.ts +12 -0
- package/dist/projections/declarative/IAddChildBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/IReadModelPropertiesBuilder.d.ts +16 -1
- package/dist/projections/declarative/IReadModelPropertiesBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/JoinBuilder.d.ts +4 -0
- package/dist/projections/declarative/JoinBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/JoinBuilder.js +1 -2
- package/dist/projections/declarative/JoinBuilder.js.map +1 -1
- package/dist/projections/declarative/ProjectionBuilderCore.d.ts.map +1 -1
- package/dist/projections/declarative/ProjectionBuilderCore.js +9 -1
- package/dist/projections/declarative/ProjectionBuilderCore.js.map +1 -1
- package/dist/projections/declarative/projectionBuilderProvenance.d.ts +16 -0
- package/dist/projections/declarative/projectionBuilderProvenance.d.ts.map +1 -0
- package/dist/projections/declarative/projectionBuilderProvenance.js +28 -0
- package/dist/projections/declarative/projectionBuilderProvenance.js.map +1 -0
- package/dist/projections/eventContractPath.d.ts +4 -0
- package/dist/projections/eventContractPath.d.ts.map +1 -0
- package/dist/projections/eventContractPath.js +7 -0
- package/dist/projections/eventContractPath.js.map +1 -0
- package/dist/projections/modelBound/childrenAndNestedBuilder.d.ts.map +1 -1
- package/dist/projections/modelBound/childrenAndNestedBuilder.js +41 -10
- package/dist/projections/modelBound/childrenAndNestedBuilder.js.map +1 -1
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +12 -3
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +12 -3
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.d.ts +17 -0
- package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionCapabilities.js +233 -0
- package/dist/testing/projections/ProjectionCapabilities.js.map +1 -0
- package/dist/testing/projections/UnsupportedProjectionOperation.d.ts +11 -0
- package/dist/testing/projections/UnsupportedProjectionOperation.d.ts.map +1 -0
- package/dist/testing/projections/UnsupportedProjectionOperation.js +16 -0
- package/dist/testing/projections/UnsupportedProjectionOperation.js.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts +7 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js +16 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Model.d.ts +10 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Model.d.ts.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Model.js +20 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Model.js.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.d.ts +3 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.d.ts.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.js +7 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Removed.js.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/compile.d.ts +12 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/compile.d.ts.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/compile.js +34 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/compile.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/projections/CompiledProjectionDefinitions.ts +6 -0
- package/projections/ProjectionCapabilityProvenance.ts +10 -0
- package/projections/ProjectionDefinitionCompiler.registration.spec.ts +4 -2
- package/projections/ProjectionDefinitionCompiler.ts +127 -4
- package/projections/ProjectionEventSchema.ts +13 -0
- package/projections/Projections.childrenAndNested.spec.ts +250 -1
- package/projections/VariantReclassifier.ts +2 -0
- package/projections/captureProjectionProvenance.ts +50 -0
- package/projections/declarative/AddChildBuilder.ts +33 -2
- package/projections/declarative/FromBuilder.ts +18 -1
- package/projections/declarative/IAddChildBuilder.ts +14 -0
- package/projections/declarative/IReadModelPropertiesBuilder.ts +18 -1
- package/projections/declarative/JoinBuilder.ts +9 -1
- package/projections/declarative/ProjectionBuilderCore.ts +8 -1
- package/projections/declarative/addChildCompatibility.standard.spec.ts +80 -0
- package/projections/declarative/projectionBuilderProvenance.ts +40 -0
- package/projections/eventContextExpressions.spec.ts +20 -0
- package/projections/eventContractPath.ts +9 -0
- package/projections/modelBound/childrenAndNestedBuilder.ts +45 -11
- package/reactors/Reactors.ts +13 -3
- package/reactors/for_Reactors/when_delivering/with_activation.spec.ts +93 -2
- package/reducers/Reducers.ts +14 -3
- package/reducers/for_Reducers/when_delivering/with_activation.spec.ts +67 -1
- package/testing/projections/ProjectionCapabilities.ts +213 -0
- package/testing/projections/UnsupportedProjectionOperation.ts +16 -0
- package/testing/projections/for_ProjectionCapabilities/given/Changed.ts +12 -0
- package/testing/projections/for_ProjectionCapabilities/given/Model.ts +13 -0
- package/testing/projections/for_ProjectionCapabilities/given/Removed.ts +7 -0
- package/testing/projections/for_ProjectionCapabilities/given/compile.ts +39 -0
- package/testing/projections/for_ProjectionCapabilities/when_compiling_capability_evidence.spec.ts +68 -0
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +230 -0
- package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +73 -0
- package/testing/projections/for_ProjectionCapabilities/when_validating_schema_boundaries.spec.ts +118 -0
- package/testing/projections/for_ProjectionCapabilities/when_validating_supported_operations.spec.ts +103 -0
|
@@ -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
|
|
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);
|
package/reducers/Reducers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
/** A compiled projection uses a kernel feature outside the infrastructure-free scenario boundary. */
|
|
5
|
+
export class UnsupportedProjectionOperation extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* @param model - The read model being evaluated.
|
|
8
|
+
* @param contractPath - Location of the unsupported operation in the compiled contract.
|
|
9
|
+
* @param declaration - Source declaration responsible for the operation.
|
|
10
|
+
* @param reason - Why an infrastructure-free evaluation cannot represent it.
|
|
11
|
+
*/
|
|
12
|
+
constructor(model: string, contractPath: string, declaration: string, reason: string) {
|
|
13
|
+
super(`UnsupportedProjectionOperation: ${model}, ${contractPath} (${declaration}): ${reason}`);
|
|
14
|
+
this.name = 'UnsupportedProjectionOperation';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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 { field } from '@cratis/fundamentals';
|
|
5
|
+
import { eventType } from '../../../../events/eventTypeDecorator.js';
|
|
6
|
+
|
|
7
|
+
export class Changed { name!: string; quantity!: number; labels!: string[]; details!: object; }
|
|
8
|
+
field(String)(Changed.prototype, 'name');
|
|
9
|
+
field(Number)(Changed.prototype, 'quantity');
|
|
10
|
+
field(Array)(Changed.prototype, 'labels');
|
|
11
|
+
field(Object)(Changed.prototype, 'details');
|
|
12
|
+
eventType('capability-changed')(Changed);
|
|
@@ -0,0 +1,13 @@
|
|
|
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 { field } from '@cratis/fundamentals';
|
|
5
|
+
|
|
6
|
+
export class Model { id!: string; name!: string; quantity!: number; total!: number; state!: string; labels!: string[]; details!: object; }
|
|
7
|
+
field(String)(Model.prototype, 'id');
|
|
8
|
+
field(String)(Model.prototype, 'name');
|
|
9
|
+
field(Number)(Model.prototype, 'quantity');
|
|
10
|
+
field(Number)(Model.prototype, 'total');
|
|
11
|
+
field(String)(Model.prototype, 'state');
|
|
12
|
+
field(Array)(Model.prototype, 'labels');
|
|
13
|
+
field(Object)(Model.prototype, 'details');
|
|
@@ -0,0 +1,7 @@
|
|
|
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 { eventType } from '../../../../events/eventTypeDecorator.js';
|
|
5
|
+
|
|
6
|
+
export class Removed {}
|
|
7
|
+
eventType('capability-removed')(Removed);
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { Constructor } from '@cratis/fundamentals';
|
|
5
|
+
import type { IClientArtifactsProvider } from '../../../../artifacts/index.js';
|
|
6
|
+
import { IProjectionBuilderFor } from '../../../../projections/declarative/IProjectionBuilderFor.js';
|
|
7
|
+
import { IProjectionFor } from '../../../../projections/declarative/IProjectionFor.js';
|
|
8
|
+
import { projection } from '../../../../projections/declarative/projection.js';
|
|
9
|
+
import { fromEvent } from '../../../../projections/modelBound/fromEvent.js';
|
|
10
|
+
import { ProjectionDefinitionCompiler } from '../../../../projections/ProjectionDefinitionCompiler.js';
|
|
11
|
+
import { Changed } from './Changed.js';
|
|
12
|
+
import { Model } from './Model.js';
|
|
13
|
+
import { Removed } from './Removed.js';
|
|
14
|
+
|
|
15
|
+
function artifacts(model: Constructor, projections: Constructor[], eventTypes: Constructor[]): IClientArtifactsProvider {
|
|
16
|
+
return {
|
|
17
|
+
readModels: [model], projections, eventTypes: [Changed, Removed, ...eventTypes],
|
|
18
|
+
globalForHandlers: [], reducers: [], reactors: [], seeders: [], constraints: [], webhooks: [], eventTypeMigrations: []
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function compileModelBound(configure: (model: typeof Model) => void = () => {}, eventTypes: Constructor[] = []) {
|
|
23
|
+
class Candidate extends Model {}
|
|
24
|
+
fromEvent(Changed)(Candidate);
|
|
25
|
+
configure(Candidate);
|
|
26
|
+
const compiled = new ProjectionDefinitionCompiler(artifacts(Candidate, [], eventTypes), 'test-sink').compile([], [Candidate]);
|
|
27
|
+
return { compiled, definition: compiled.definitions[0] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function compileDeclarative(define: (builder: IProjectionBuilderFor<Model>) => void, eventTypes: Constructor[] = []) {
|
|
31
|
+
class Candidate extends Model {}
|
|
32
|
+
class CandidateProjection implements IProjectionFor<Model> {
|
|
33
|
+
define(builder: IProjectionBuilderFor<Model>): void { define(builder); }
|
|
34
|
+
}
|
|
35
|
+
projection('capability-projection', Candidate)(CandidateProjection);
|
|
36
|
+
const compiled = new ProjectionDefinitionCompiler(artifacts(Candidate, [CandidateProjection], eventTypes), 'test-sink')
|
|
37
|
+
.compile([CandidateProjection], []);
|
|
38
|
+
return { compiled, definition: compiled.definitions[0] };
|
|
39
|
+
}
|