@cratis/chronicle 6.30.0 → 6.31.1

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 (44) hide show
  1. package/compliance/for_PIIManager/against_a_kernel/when_erasing_a_subject.integration.spec.ts +132 -0
  2. package/connection/ChronicleConnection.ts +1 -0
  3. package/connection/encodeExplicitFalse.ts +56 -0
  4. package/connection/for_encodeExplicitFalse/when_encoding_reactor_definition.spec.ts +34 -0
  5. package/connection/for_encodeExplicitFalse/when_encoding_webhook_definition.spec.ts +39 -0
  6. package/connection/for_encodeExplicitFalse/wireFields.ts +24 -0
  7. package/dist/connection/ChronicleConnection.d.ts +1 -0
  8. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  9. package/dist/connection/ChronicleConnection.js +1 -0
  10. package/dist/connection/ChronicleConnection.js.map +1 -1
  11. package/dist/connection/encodeExplicitFalse.d.ts +6 -0
  12. package/dist/connection/encodeExplicitFalse.d.ts.map +1 -0
  13. package/dist/connection/encodeExplicitFalse.js +45 -0
  14. package/dist/connection/encodeExplicitFalse.js.map +1 -0
  15. package/dist/connection/for_encodeExplicitFalse/wireFields.d.ts +6 -0
  16. package/dist/connection/for_encodeExplicitFalse/wireFields.d.ts.map +1 -0
  17. package/dist/connection/for_encodeExplicitFalse/wireFields.js +24 -0
  18. package/dist/connection/for_encodeExplicitFalse/wireFields.js.map +1 -0
  19. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  20. package/dist/testing/projections/ProjectionCapabilities.js +8 -2
  21. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  22. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts +33 -0
  23. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts.map +1 -0
  24. package/dist/testing/projections/ProjectionChildrenCapabilities.js +108 -0
  25. package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -0
  26. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +6 -0
  27. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  28. package/dist/testing/projections/ProjectionReadModelProcessor.js +94 -36
  29. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  30. package/dist/tsconfig.tsbuildinfo +1 -1
  31. package/events/constraints/for_Constraints/against_a_kernel/when_appending_unique_values_concurrently.integration.spec.ts +189 -0
  32. package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +156 -0
  33. package/jobs/for_Jobs/against_a_kernel/when_managing_a_replay_job.integration.spec.ts +114 -0
  34. package/package.json +2 -1
  35. package/reactors/for_Reactors/against_a_kernel/when_replaying_once_only_reactors.integration.spec.ts +111 -0
  36. package/testing/ReadModelScenario.childrenFrom.spec.ts +31 -5
  37. package/testing/projections/ProjectionCapabilities.ts +10 -3
  38. package/testing/projections/ProjectionChildrenCapabilities.ts +119 -0
  39. package/testing/projections/ProjectionReadModelProcessor.ts +105 -36
  40. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +73 -0
  41. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +0 -3
  42. package/vitest.config.ts +2 -1
  43. package/vitest.integration.config.ts +19 -0
  44. package/webhooks/for_Webhooks/against_a_kernel/when_registering_and_removing_a_webhook.integration.spec.ts +88 -0
@@ -0,0 +1,114 @@
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 { randomUUID } from 'node:crypto';
5
+ import { afterAll, beforeAll, chai, describe, it } from 'vitest';
6
+ import { JobStatus, JobStepStatus, type JobStepSummaryResponse, type JobSummaryResponse } from '@cratis/chronicle.contracts';
7
+ import { field, Guid } from '@cratis/fundamentals';
8
+ import { ChronicleClient } from '../../../ChronicleClient.js';
9
+ import { ChronicleOptions } from '../../../ChronicleOptions.js';
10
+ import { fromContractsGuid } from '../../../connection/Guid.js';
11
+ import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
12
+ import { eventType } from '../../../events/index.js';
13
+ import type { IEventStore } from '../../../IEventStore.js';
14
+ import { fromEvent } from '../../../projections/index.js';
15
+ import { JobId } from '../../JobId.js';
16
+
17
+ chai.should();
18
+
19
+ // Runs against a real Chronicle kernel; skipped locally unless the connection string is set (see vitest.integration.config.ts).
20
+ // In CI it never skips, so a missing connection string or unreachable kernel fails instead of passing vacuously.
21
+ const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
22
+
23
+ @eventType()
24
+ class ItemAdded {
25
+ @field(String) name = '';
26
+ }
27
+
28
+ @fromEvent(ItemAdded)
29
+ class Item {
30
+ @field(String) name = '';
31
+ }
32
+
33
+ const artifacts: IClientArtifactsProvider = {
34
+ eventTypes: [ItemAdded],
35
+ readModels: [Item],
36
+ reactors: [],
37
+ reducers: [],
38
+ seeders: [],
39
+ constraints: [],
40
+ projections: [],
41
+ webhooks: [],
42
+ eventTypeMigrations: [],
43
+ globalForHandlers: []
44
+ };
45
+
46
+ async function eventually<T>(read: () => Promise<T>, accept: (value: T) => boolean, timeoutMs = 20_000): Promise<T> {
47
+ const deadline = Date.now() + timeoutMs;
48
+ let value = await read();
49
+ while (!accept(value)) {
50
+ if (Date.now() > deadline) throw new Error('Timed out waiting for the kernel');
51
+ await new Promise(resolve => setTimeout(resolve, 100));
52
+ value = await read();
53
+ }
54
+ return value;
55
+ }
56
+
57
+ describe.skipIf(!connectionString && !process.env.CI)('when managing a replay job against a kernel', () => {
58
+ let client: ChronicleClient;
59
+ let store: IEventStore;
60
+ let jobId: JobId;
61
+ let listed: JobSummaryResponse[];
62
+ let stopped: JobSummaryResponse | undefined;
63
+ let steps: JobStepSummaryResponse[];
64
+ let afterDelete: JobSummaryResponse | undefined;
65
+ let stopUnknownJob: unknown;
66
+ let deleteUnknownJob: unknown;
67
+
68
+ beforeAll(async () => {
69
+ client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
70
+ discoveryPatterns: [],
71
+ clientArtifactsProvider: artifacts
72
+ }));
73
+ store = await client.getEventStore(`JobsEndToEnd${randomUUID().replaceAll('-', '')}`);
74
+
75
+ // Enough events that the replay is still running when it is stopped; a stopped job stays until deleted,
76
+ // where a completed one is removed and could not be listed reliably.
77
+ await store.eventLog.appendMany(Array.from({ length: 500 }, (_, index) => ({
78
+ eventSourceId: randomUUID(),
79
+ event: Object.assign(new ItemAdded(), { name: `Item ${index}` })
80
+ })));
81
+
82
+ jobId = await store.projections.replayForModel(Item);
83
+
84
+ // Stopping a job in the instant it starts leaves its step running forever, so wait until it is running.
85
+ await eventually(() => store.jobs.getJob(jobId), _ => _?.Status === JobStatus.JOB_STATUS_Running);
86
+ await store.jobs.stop(jobId);
87
+
88
+ listed = await store.jobs.getJobs();
89
+ stopped = await eventually(() => store.jobs.getJob(jobId), _ => _?.Status === JobStatus.JOB_STATUS_Stopped);
90
+ // The kernel updates step status asynchronously, after the job itself reports stopped.
91
+ steps = await eventually(
92
+ () => store.jobs.getJobSteps(jobId),
93
+ _ => _.length > 0 && _.every(step => step.Status === JobStepStatus.JOB_STEP_STATUS_Stopped),
94
+ 30_000);
95
+
96
+ await store.jobs.delete(jobId);
97
+ afterDelete = await eventually(() => store.jobs.getJob(jobId), _ => _ === undefined);
98
+
99
+ stopUnknownJob = await store.jobs.stop(randomUUID()).catch((error: unknown) => error);
100
+ deleteUnknownJob = await store.jobs.delete(randomUUID()).catch((error: unknown) => error);
101
+ });
102
+
103
+ afterAll(() => client?.dispose());
104
+
105
+ it('should start a replay job', () => jobId.toString().should.not.equal(Guid.empty.toString()));
106
+ it('should list the job', () => listed.some(_ => fromContractsGuid(_.Id).toString() === jobId.toString()).should.be.true);
107
+ it('should report the job as a replay', () => stopped!.Type.should.equal('ReplayObserver'));
108
+ it('should report the job as stopped', () => stopped!.Status.should.equal(JobStatus.JOB_STATUS_Stopped));
109
+ it('should list the steps of the job', () => steps.length.should.be.greaterThan(0));
110
+ it('should report the steps as stopped', () => steps.every(_ => _.Status === JobStepStatus.JOB_STEP_STATUS_Stopped).should.be.true);
111
+ it('should no longer list the deleted job', () => (afterDelete === undefined).should.be.true);
112
+ it('should report stopping a job that does not exist', () => stopUnknownJob.should.be.instanceOf(Error));
113
+ it('should report deleting a job that does not exist', () => deleteUnknownJob.should.be.instanceOf(Error));
114
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.30.0",
3
+ "version": "6.31.1",
4
4
  "description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -167,6 +167,7 @@
167
167
  "build": "tsc -b",
168
168
  "clean": "rm -rf dist",
169
169
  "test": "vitest run",
170
+ "test:integration": "vitest run --config vitest.integration.config.ts",
170
171
  "test:standard-types": "tsc --ignoreConfig --noEmit --target es2022 --module es2022 --moduleResolution bundler --lib es2022,esnext.decorators --types node --skipLibCheck --strict schemas/decorators.standard.spec.ts projections/declarative/addChildCompatibility.standard.spec.ts projections/modelBound/decorators.standard.spec.ts events/classDecorators.standard.spec.ts reactors/decorators.standard.spec.ts readModels/standardFallbacks.standard.spec.ts readModels/registration.standard.spec.ts for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts artifacts/validateArtifactSchemas.spec.ts",
171
172
  "test:native": "node ./scripts/check-native-esm.mjs"
172
173
  },
@@ -0,0 +1,111 @@
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 { randomUUID } from 'node:crypto';
5
+ import { afterAll, beforeAll, chai, describe, it } from 'vitest';
6
+ import { field } from '@cratis/fundamentals';
7
+ import { ChronicleClient } from '../../../ChronicleClient.js';
8
+ import { ChronicleOptions } from '../../../ChronicleOptions.js';
9
+ import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
10
+ import type { ChronicleConnection } from '../../../connection/index.js';
11
+ import { eventType } from '../../../events/index.js';
12
+ import type { IEventStore } from '../../../IEventStore.js';
13
+ import { onceOnly } from '../../onceOnly.js';
14
+ import { reactor } from '../../reactor.js';
15
+
16
+ chai.should();
17
+
18
+ // Runs against a real Chronicle kernel; skipped locally unless the connection string is set (see vitest.integration.config.ts).
19
+ // In CI it never skips, so a missing connection string or unreachable kernel fails instead of passing vacuously.
20
+ const connectionString = process.env.CHRONICLE_INTEGRATION_CONNECTION_STRING;
21
+
22
+ @eventType()
23
+ class ThingHappened {
24
+ @field(String) name = '';
25
+ }
26
+
27
+ // The client constructs reactor instances; share sinks for assertions across that boundary.
28
+ const onceOnlyInvocations: string[] = [];
29
+ const replayableInvocations: string[] = [];
30
+
31
+ @reactor()
32
+ @onceOnly()
33
+ class OnceOnlyReactor {
34
+ thingHappened(event: ThingHappened) { onceOnlyInvocations.push(event.name); }
35
+ }
36
+
37
+ @reactor()
38
+ class ReplayableReactor {
39
+ thingHappened(event: ThingHappened) { replayableInvocations.push(event.name); }
40
+ }
41
+
42
+ const artifacts: IClientArtifactsProvider = {
43
+ eventTypes: [ThingHappened],
44
+ readModels: [],
45
+ reactors: [OnceOnlyReactor, ReplayableReactor],
46
+ reducers: [],
47
+ seeders: [],
48
+ constraints: [],
49
+ projections: [],
50
+ webhooks: [],
51
+ eventTypeMigrations: [],
52
+ globalForHandlers: []
53
+ };
54
+
55
+ async function eventually(accept: () => boolean, timeoutMs = 30_000): Promise<void> {
56
+ const deadline = Date.now() + timeoutMs;
57
+ while (!accept()) {
58
+ if (Date.now() > deadline) throw new Error('Timed out waiting for the kernel');
59
+ await new Promise(resolve => setTimeout(resolve, 250));
60
+ }
61
+ }
62
+
63
+ const isEmptyJobId = (jobId: string | undefined) => /^[0-]*$/.test(jobId ?? '');
64
+
65
+ describe.skipIf(!connectionString && !process.env.CI)('when replaying reactors against a kernel', () => {
66
+ const storeName = `Replay${randomUUID().replaceAll('-', '').slice(0, 12)}`;
67
+ let client: ChronicleClient;
68
+ let store: IEventStore;
69
+ let connection: ChronicleConnection;
70
+ let onceOnlyJobId: string | undefined;
71
+ let replayableJobId: string | undefined;
72
+
73
+ const replay = (observerId: string) => connection.observers.replay({
74
+ EventStore: storeName,
75
+ Namespace: 'Default',
76
+ ObserverId: observerId,
77
+ EventSequenceId: 'event-log'
78
+ });
79
+
80
+ beforeAll(async () => {
81
+ client = new ChronicleClient(ChronicleOptions.fromConnectionString(connectionString!, {
82
+ discoveryPatterns: [],
83
+ clientArtifactsProvider: artifacts
84
+ }));
85
+ store = await client.getEventStore(storeName);
86
+ connection = (store as unknown as { _connection: ChronicleConnection })._connection;
87
+
88
+ (await store.eventLog.append(randomUUID(), Object.assign(new ThingHappened(), { name: 'first' }))).isSuccess.should.be.true;
89
+ await eventually(() => onceOnlyInvocations.length === 1 && replayableInvocations.length === 1);
90
+
91
+ onceOnlyJobId = (await replay('OnceOnlyReactor')).JobId;
92
+ replayableJobId = (await replay('ReplayableReactor')).JobId;
93
+ await eventually(() => replayableInvocations.length === 2);
94
+ await new Promise(resolve => setTimeout(resolve, 3_000));
95
+ });
96
+
97
+ afterAll(() => client?.dispose());
98
+
99
+ it('should replay a reactor that is not once-only', () => {
100
+ isEmptyJobId(replayableJobId).should.be.false;
101
+ replayableInvocations.should.deep.equal(['first', 'first']);
102
+ });
103
+
104
+ it('should not start a replay of a class-level once-only reactor', () => {
105
+ isEmptyJobId(onceOnlyJobId).should.be.true;
106
+ });
107
+
108
+ it('should not invoke a class-level once-only reactor again', () => {
109
+ onceOnlyInvocations.should.deep.equal(['first']);
110
+ });
111
+ });
@@ -5,13 +5,13 @@ import { chai, describe, it } from 'vitest';
5
5
  import { eventType } from '../events/eventTypeDecorator.js';
6
6
  import { childrenFrom } from '../projections/modelBound/childrenFrom.js';
7
7
  import { fromEvent } from '../projections/modelBound/fromEvent.js';
8
+ import { setFrom } from '../projections/modelBound/setFrom.js';
8
9
  import { ReadModelScenario, UnsupportedProjectionOperation } from './index.js';
9
10
 
10
11
  chai.should();
11
12
 
12
13
  class ScenarioItemAdded {
13
- itemId = '';
14
- name = '';
14
+ constructor(public itemId = '', public name = '') {}
15
15
  }
16
16
  eventType('scenario-keyed-item-added')(ScenarioItemAdded);
17
17
 
@@ -21,15 +21,41 @@ class ScenarioItem {
21
21
  }
22
22
 
23
23
  class ScenarioOrder {
24
+ id = '';
24
25
  items: ScenarioItem[] = [];
25
26
  }
26
27
  childrenFrom(ScenarioItemAdded, { childType: ScenarioItem, key: 'itemId' })(ScenarioOrder.prototype, 'items');
27
28
  fromEvent(ScenarioItemAdded)(ScenarioOrder);
28
29
 
30
+ class ScenarioNamedItem {
31
+ id = '';
32
+ name = '';
33
+ }
34
+ setFrom(ScenarioItemAdded, 'name')(ScenarioNamedItem.prototype, 'name');
35
+
36
+ class ScenarioNamedOrder {
37
+ id = '';
38
+ items: ScenarioNamedItem[] = [];
39
+ }
40
+ childrenFrom(ScenarioItemAdded, { childType: ScenarioNamedItem, key: 'itemId' })(ScenarioNamedOrder.prototype, 'items');
41
+ fromEvent(ScenarioItemAdded)(ScenarioNamedOrder);
42
+
43
+ const artifacts = { eventTypes: [ScenarioItemAdded], reducers: [], projections: [] };
44
+
29
45
  describe('when a read model scenario evaluates keyed children from the options form', () => {
30
- it('should reject children projections explicitly when the scenario is created', () => {
31
- (() => new ReadModelScenario(ScenarioOrder, { eventTypes: [ScenarioItemAdded], reducers: [], projections: [] }))
46
+ it('should add one child per key and update an existing child in place (children-untyped-items)', async () => {
47
+ const scenario = new ReadModelScenario(ScenarioOrder, artifacts);
48
+ scenario.given.forEventSource('order-1').events(
49
+ new ScenarioItemAdded('a', 'first'), new ScenarioItemAdded('b', 'second'), new ScenarioItemAdded('a', 'again'));
50
+ const order = await scenario.instanceForEventSourceId('order-1');
51
+ order!.id.should.equal('order-1');
52
+ // The kernel's AutoMap is schema-driven, and legacy decorators register an untyped child item schema.
53
+ order!.items.map(item => ({ id: item.id, name: item.name })).should.deep.equal([{ id: 'a', name: '' }, { id: 'b', name: '' }]);
54
+ });
55
+
56
+ it('should reject explicit child property mappings', () => {
57
+ (() => new ReadModelScenario(ScenarioNamedOrder, artifacts))
32
58
  .should.throw(UnsupportedProjectionOperation)
33
- .with.property('message').that.includes('Children.items (@childrenFrom)').and.includes('children projections');
59
+ .with.property('message').that.includes('Children.items').and.includes('explicit child property mappings');
34
60
  });
35
61
  });
@@ -6,8 +6,9 @@ import { EventSequenceId } from '../../eventSequences/EventSequenceId.js';
6
6
  import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js';
7
7
  import { eventContractPath } from '../../projections/eventContractPath.js';
8
8
  import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
9
- import { getEventTypeMapKey } from '../../projections/modelBound/childrenAndNestedBuilder.js';
9
+ import { getEventTypeMapKey, type ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js';
10
10
  import type { JsonSchema } from '../../schemas/JsonSchema.js';
11
+ import { ProjectionChildrenCapabilities } from './ProjectionChildrenCapabilities.js';
11
12
  import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
12
13
 
13
14
  /** Validates *all* subscribed operations against the bounded scenario subset, before replay. */
@@ -36,11 +37,11 @@ export class ProjectionCapabilities {
36
37
  if (wire.IsActive === false) reject('IsActive', 'passive projections require a kernel-backed test');
37
38
  if (wire.SubscribesToAllEvents === true) reject('SubscribesToAllEvents', 'subscribe-to-all projections require a kernel-backed test');
38
39
  if (wire.EventSequenceId !== EventSequenceId.eventLog.value) reject('EventSequenceId', 'non-default event sequences require a kernel-backed test');
39
- for (const section of ['Join', 'Children', 'Nested', 'RemovedWithJoin'] as const) {
40
+ for (const section of ['Join', 'Nested', 'RemovedWithJoin'] as const) {
40
41
  const value = wire[section];
41
42
  const paths = Array.isArray(value) ? value.map((entry: { Key: ContractEventType }) => eventContractPath(section, entry.Key))
42
43
  : Object.keys(value as Record<string, unknown> ?? {}).map(property => `${section}.${property}`);
43
- if (paths.length) reject(paths[0], `${section === 'Children' ? 'children projections' : section === 'Nested' ? 'nested projections' : section === 'Join' ? 'joins' : 'removedWithJoin'} require a kernel-backed test`);
44
+ if (paths.length) reject(paths[0], `${section === 'Nested' ? 'nested projections' : section === 'Join' ? 'joins' : 'removedWithJoin'} require a kernel-backed test`);
44
45
  }
45
46
  if ((wire.FromEvery as unknown[] | undefined)?.length) reject('FromEvery', 'derivative projections require a kernel-backed test');
46
47
  if (wire.FromEventProperty) reject('FromEventProperty', 'event-property subscriptions require a kernel-backed test');
@@ -110,6 +111,12 @@ export class ProjectionCapabilities {
110
111
  this.checkAutoMap(schema!, eventSchema, properties, wire.NoAutoMapProperties as string[] ?? [], path, reject);
111
112
  }
112
113
  }
114
+ ProjectionChildrenCapabilities.validate(wire.Children as Record<string, ChildrenDefinitionLike> ?? {}, {
115
+ schema: schema!, rootAutoMap: wire.AutoMap as AutoMap, hasInitialModelState: Object.keys(initial as object).length > 0, from, removedWith, requireEventSchema, reject,
116
+ declarationFor: path => provenance.find(entry => entry.contractPath === path)?.declaration,
117
+ checkMapping: (modelSchema, eventSchema, destination, expression, path) =>
118
+ this.checkMapping(modelSchema, eventSchema, destination, expression, path, reject)
119
+ });
113
120
  }
114
121
 
115
122
  private static checkAutoMap(
@@ -0,0 +1,119 @@
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 } from '@cratis/chronicle.contracts';
5
+ import { eventContractPath } from '../../projections/eventContractPath.js';
6
+ import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
7
+ import type { ChildrenDefinitionLike } from '../../projections/modelBound/childrenAndNestedBuilder.js';
8
+ import type { JsonSchema } from '../../schemas/JsonSchema.js';
9
+
10
+ /** Rejects an operation at a contract path, optionally naming the responsible declaration. */
11
+ export type RejectOperation = (path: string, reason: string, fallback?: string) => never;
12
+
13
+ /** Validates a scalar mapping into a target schema, rejecting anything outside the fixture-backed subset. */
14
+ export type CheckMapping = (modelSchema: JsonSchema, eventSchema: JsonSchema, destination: string, expression: string, path: string) => void;
15
+
16
+ /** The inputs children validation shares with root validation. */
17
+ export interface ChildrenValidationContext {
18
+ readonly schema: JsonSchema;
19
+ readonly rootAutoMap: AutoMap;
20
+ readonly hasInitialModelState: boolean;
21
+ readonly from: readonly FromRecord[];
22
+ readonly removedWith: readonly RemovedWithRecord[];
23
+ readonly declarationFor: (path: string) => string | undefined;
24
+ readonly requireEventSchema: (eventType: ContractEventType, path: string) => JsonSchema;
25
+ readonly checkMapping: CheckMapping;
26
+ readonly reject: RejectOperation;
27
+ }
28
+
29
+ const kernelBacked = 'require a kernel-backed test (ChronicleKernelScenario / live kernel)';
30
+
31
+ /**
32
+ * Validates one level of children collections against the packaged-kernel fixtures
33
+ * (`children-from-keyed`, `children-identified-removed`, `children-untyped-items`).
34
+ */
35
+ export class ProjectionChildrenCapabilities {
36
+ /**
37
+ * Throws for the first children operation outside the fixture-backed subset.
38
+ * @param children - The wire `Children` section of the root definition.
39
+ * @param context - Shared root validation state.
40
+ */
41
+ static validate(children: Record<string, ChildrenDefinitionLike>, context: ChildrenValidationContext): void {
42
+ if (Object.keys(children).length && context.hasInitialModelState) {
43
+ context.reject('InitialModelState', `children with initial model state ${kernelBacked}`);
44
+ }
45
+ const owners = new Map<string, string>();
46
+ for (const [property, child] of Object.entries(children)) {
47
+ const base = `Children.${property}`;
48
+ const declaration = context.declarationFor(base);
49
+ const reject = (path: string, reason: string): never => context.reject(path, reason, declaration);
50
+ const wire = child as unknown as Record<string, unknown>;
51
+ if ((child.Join ?? []).length) reject(`${base}.Join`, 'joins require a kernel-backed test');
52
+ if (Object.keys(child.Children ?? {}).length) reject(`${base}.Children`, `nested children collections ${kernelBacked}`);
53
+ if (Object.keys(child.Nested ?? {}).length) reject(`${base}.Nested`, `nested projections inside children ${kernelBacked}`);
54
+ if ((child.RemovedWithJoin ?? []).length) reject(`${base}.RemovedWithJoin`, `removedWithJoin inside children ${kernelBacked}`);
55
+ if (wire.FromEventProperty) reject(`${base}.FromEventProperty`, `value children from an event property ${kernelBacked}`);
56
+ if (Object.keys(child.All?.Properties ?? {}).length || child.All?.IncludeChildren || (child.All?.AutoMap ?? AutoMap.Inherit) !== AutoMap.Inherit) {
57
+ reject(`${base}.All`, `fromEvery/all inside children ${kernelBacked}`);
58
+ }
59
+ if ((child.NoAutoMapProperties ?? []).length) reject(`${base}.NoAutoMapProperties`, `child AutoMap exclusions ${kernelBacked}`);
60
+ const autoMap = child.AutoMap === AutoMap.Inherit ? context.rootAutoMap : child.AutoMap;
61
+ if (autoMap === AutoMap.Disabled) reject(`${base}.AutoMap`, `children with AutoMap disabled ${kernelBacked}`);
62
+
63
+ const collection = context.schema.properties?.[property];
64
+ const items = collection?.items;
65
+ if (collection?.type !== 'array' || items?.type !== 'object') {
66
+ reject(base, `children collections must target an array of objects; other targets ${kernelBacked}`);
67
+ }
68
+ const typed = items!.properties !== undefined;
69
+ const identifiedBy = child.IdentifiedBy;
70
+ if (!identifiedBy || identifiedBy === '*NotSet*' || identifiedBy.includes('.')) {
71
+ reject(`${base}.IdentifiedBy`, `children without a direct identifying property ${kernelBacked}`);
72
+ }
73
+ const identity = items!.properties?.[identifiedBy];
74
+ if (typed && (identity?.type !== 'string' || identity.format)) {
75
+ reject(`${base}.IdentifiedBy`, `only a string child identifier is fixture-backed; other identifiers ${kernelBacked}`);
76
+ }
77
+
78
+ for (const entry of [...(child.From ?? []), ...(child.RemovedWith ?? [])]) {
79
+ const section = (child.From ?? []).includes(entry as FromRecord) ? 'From' : 'RemovedWith';
80
+ const path = `${base}.${eventContractPath(section, entry.Key)}`;
81
+ const owner = owners.get(entry.Key.Id);
82
+ if (owner) reject(path, `an event subscribed by more than one children operation (${owner}) ${kernelBacked}`);
83
+ owners.set(entry.Key.Id, path);
84
+ if (context.removedWith.some(removal => removal.Key.Id === entry.Key.Id) ||
85
+ (section === 'RemovedWith' && context.from.some(root => root.Key.Id === entry.Key.Id))) {
86
+ reject(path, `an event that both removes and changes the parent or a child ${kernelBacked}`);
87
+ }
88
+ const eventSchema = context.requireEventSchema(entry.Key, path);
89
+ const key = eventSchema.properties?.[entry.Value.Key];
90
+ if (!/^[A-Za-z_]\w*$/.test(entry.Value.Key) || key?.type !== 'string' || key.format) {
91
+ reject(`${path}.Key`, `only a child key read from a string event property is fixture-backed; other child keys ${kernelBacked}`);
92
+ }
93
+ if (entry.Value.ParentKey && entry.Value.ParentKey !== '$eventSourceId') {
94
+ reject(`${path}.ParentKey`, `parent keys other than the event source id ${kernelBacked}`);
95
+ }
96
+ if (section === 'RemovedWith') continue;
97
+ const properties = (entry as FromRecord).Value.Properties ?? {};
98
+ for (const [destination, expression] of Object.entries(properties)) {
99
+ const mappingPath = `${path}.Properties.${destination}`;
100
+ if (destination === identifiedBy) {
101
+ if (expression !== entry.Value.Key) reject(mappingPath, `mapping the child identifier from anything but the child key ${kernelBacked}`);
102
+ continue;
103
+ }
104
+ reject(mappingPath, `explicit child property mappings (renames, event context, constants, nulls) ${kernelBacked}; only same-name AutoMap into typed children is supported`);
105
+ }
106
+ if (!typed) continue;
107
+ for (const destination of Object.keys(items!.properties ?? {})) {
108
+ if (Object.keys(properties).some(name => name.toLowerCase() === destination.toLowerCase())) continue;
109
+ const candidates = Object.keys(eventSchema.properties ?? {}).filter(source => source.toLowerCase() === destination.toLowerCase());
110
+ if (!candidates.length) continue;
111
+ const mappingPath = `${path}.AutoMap.${destination}`;
112
+ if (destination === identifiedBy) reject(mappingPath, `AutoMap into the child identifier ${kernelBacked}`);
113
+ if (candidates.length > 1) reject(mappingPath, `inferred AutoMap source is ambiguous: ${candidates.join(', ')}`);
114
+ context.checkMapping(items!, eventSchema, destination, candidates[0], mappingPath);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }