@cratis/chronicle 6.37.0 → 6.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/testing/EventFieldSchema.d.ts +6 -0
  2. package/dist/testing/EventFieldSchema.d.ts.map +1 -0
  3. package/dist/testing/EventFieldSchema.js +78 -0
  4. package/dist/testing/EventFieldSchema.js.map +1 -0
  5. package/dist/testing/EventScenario.d.ts.map +1 -1
  6. package/dist/testing/EventScenario.js +17 -7
  7. package/dist/testing/EventScenario.js.map +1 -1
  8. package/dist/testing/InProcessConstraints.d.ts +3 -1
  9. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  10. package/dist/testing/InProcessConstraints.js +20 -9
  11. package/dist/testing/InProcessConstraints.js.map +1 -1
  12. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  13. package/dist/testing/InProcessEventSequence.js +9 -10
  14. package/dist/testing/InProcessEventSequence.js.map +1 -1
  15. package/dist/testing/ReadModelScenario.js +1 -1
  16. package/dist/testing/ReadModelScenario.js.map +1 -1
  17. package/dist/testing/ScenarioEvent.d.ts +1 -0
  18. package/dist/testing/ScenarioEvent.d.ts.map +1 -1
  19. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  20. package/dist/testing/projections/ProjectionCapabilities.js +9 -2
  21. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  22. package/dist/testing/projections/ProjectionChildrenCapabilities.js +1 -1
  23. package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -1
  24. package/dist/testing/projections/ProjectionJoinRecord.d.ts +9 -0
  25. package/dist/testing/projections/ProjectionJoinRecord.d.ts.map +1 -0
  26. package/dist/testing/projections/ProjectionJoinRecord.js +4 -0
  27. package/dist/testing/projections/ProjectionJoinRecord.js.map +1 -0
  28. package/dist/testing/projections/ProjectionJoinValidationContext.d.ts +12 -0
  29. package/dist/testing/projections/ProjectionJoinValidationContext.d.ts.map +1 -0
  30. package/dist/testing/projections/ProjectionJoinValidationContext.js +4 -0
  31. package/dist/testing/projections/ProjectionJoinValidationContext.js.map +1 -0
  32. package/dist/testing/projections/ProjectionJoins.d.ts +8 -0
  33. package/dist/testing/projections/ProjectionJoins.d.ts.map +1 -0
  34. package/dist/testing/projections/ProjectionJoins.js +29 -0
  35. package/dist/testing/projections/ProjectionJoins.js.map +1 -0
  36. package/dist/testing/projections/ProjectionJoinsCapabilities.d.ts +10 -0
  37. package/dist/testing/projections/ProjectionJoinsCapabilities.d.ts.map +1 -0
  38. package/dist/testing/projections/ProjectionJoinsCapabilities.js +93 -0
  39. package/dist/testing/projections/ProjectionJoinsCapabilities.js.map +1 -0
  40. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +2 -0
  41. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  42. package/dist/testing/projections/ProjectionReadModelProcessor.js +42 -1
  43. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +30 -9
  46. package/package.json +2 -2
  47. package/testing/EventFieldSchema.ts +71 -0
  48. package/testing/EventScenario.constraint-cycles.spec.ts +1 -1
  49. package/testing/EventScenario.constraints.spec.ts +4 -2
  50. package/testing/EventScenario.fixtures.spec.ts +5 -5
  51. package/testing/EventScenario.ts +18 -7
  52. package/testing/InProcessConstraints.ts +19 -9
  53. package/testing/InProcessEventSequence.ts +9 -10
  54. package/testing/ReadModelScenario.ts +1 -1
  55. package/testing/ScenarioEvent.ts +1 -1
  56. package/testing/for_EventScenario/when_appending_field_types/field_scenario_behaviors.fixture.ts +219 -0
  57. package/testing/for_EventScenario/when_appending_field_types/with_invalid_formatted_content.legacy.spec.ts +123 -0
  58. package/testing/for_EventScenario/when_appending_field_types/with_legacy_decorators.legacy.spec.ts +124 -0
  59. package/testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts +123 -0
  60. package/testing/for_EventScenario/when_appending_field_types/with_unproven_schemas.legacy.spec.ts +82 -0
  61. package/testing/for_ReadModelScenario/when_evaluating_joins/with_a_fluent_projection.spec.ts +111 -0
  62. package/testing/for_ReadModelScenario/when_evaluating_joins/with_legacy_decorators.legacy.spec.ts +68 -0
  63. package/testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts +69 -0
  64. package/testing/projections/ProjectionCapabilities.ts +10 -2
  65. package/testing/projections/ProjectionChildrenCapabilities.ts +1 -1
  66. package/testing/projections/ProjectionJoinRecord.ts +10 -0
  67. package/testing/projections/ProjectionJoinValidationContext.ts +15 -0
  68. package/testing/projections/ProjectionJoins.ts +30 -0
  69. package/testing/projections/ProjectionJoinsCapabilities.ts +90 -0
  70. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +2 -0
  71. package/testing/projections/ProjectionReadModelProcessor.ts +44 -1
  72. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +1 -1
  73. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +2 -2
  74. package/testing/projections/for_ProjectionCapabilities/when_validating_joins.spec.ts +118 -0
@@ -0,0 +1,90 @@
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 { eventContractPath } from '../../projections/eventContractPath.js';
6
+ import type { FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
7
+ import type { JsonSchema } from '../../schemas/JsonSchema.js';
8
+ import { ProjectionArithmetic } from './ProjectionArithmetic.js';
9
+ import type { ProjectionJoinRecord } from './ProjectionJoinRecord.js';
10
+ import type { ProjectionJoinValidationContext } from './ProjectionJoinValidationContext.js';
11
+ import { ProjectionJoins } from './ProjectionJoins.js';
12
+
13
+ /** The string-only, single root join proven by the joins-* packaged-kernel fixtures. */
14
+ export class ProjectionJoinsCapabilities {
15
+ /** Rejects unproven join shapes before any history is replayed. */
16
+ static validate(definition: ProjectionDefinition, context: ProjectionJoinValidationContext): void {
17
+ const joins = definition.Join as ProjectionJoinRecord[] ?? [];
18
+ if (!joins.length) return;
19
+ const { schema, reject, requireEventSchema, checkMapping } = context;
20
+ const join = joins[0];
21
+ const path = eventContractPath('Join', join.Key);
22
+ if (joins.length !== 1) reject(path, 'multiple join subscriptions require a kernel-backed test');
23
+ if (Object.keys(definition.Children ?? {}).length) reject(path, 'root joins combined with children require a kernel-backed test');
24
+ const from = definition.From as FromRecord[] ?? [];
25
+ if ([...from, ...joins].some(entry => Object.values(entry.Value.Properties ?? {}).some(ProjectionArithmetic.isArithmetic))) {
26
+ reject(path, 'joins combined with arithmetic require a kernel-backed test');
27
+ }
28
+ if (Object.keys(JSON.parse(definition.InitialModelState || '{}') as object).length) {
29
+ reject('InitialModelState', 'joins with initial model state require a kernel-backed test');
30
+ }
31
+ if (join.Value.Key !== '$eventSourceId') reject(`${path}.Key`, 'join keys other than $eventSourceId require a kernel-backed test; the kernel ignores custom root join keys');
32
+ if (!join.Value.On || join.Value.On === 'id' || !/^[A-Za-z_]\w*$/.test(join.Value.On) || !schema.properties?.[join.Value.On]) {
33
+ reject(`${path}.On`, 'joins require a direct non-identifier read-model property; other join targets require a kernel-backed test');
34
+ }
35
+ for (const [property, member] of Object.entries(schema.properties ?? {})) {
36
+ if (!this.plainString(member)) reject(`ReadModel.Schema.${property}`, 'joins currently require plain string read-model properties; other schemas require a kernel-backed test');
37
+ }
38
+ if ([...from, ...(definition.RemovedWith as RemovedWithRecord[] ?? [])].some(entry => entry.Key.Id === join.Key.Id)) {
39
+ reject(path, 'events shared between Join and From or RemovedWith require a kernel-backed test');
40
+ }
41
+ const joinSchema = requireEventSchema(join.Key, path);
42
+ context.checkProtection(joinSchema, `${path}.EventSchema`);
43
+ if (![AutoMap.Inherit, AutoMap.Enabled, AutoMap.Disabled].includes(join.Value.AutoMap ?? AutoMap.Inherit)) {
44
+ reject(`${path}.AutoMap`, 'unknown join AutoMap settings require a kernel-backed test');
45
+ }
46
+ for (const [index, name] of Object.keys(joinSchema.properties ?? {}).entries()) {
47
+ if (Object.keys(joinSchema.properties ?? {}).slice(index + 1).some(other => other.toLowerCase() === name.toLowerCase())) {
48
+ reject(path, 'case-insensitively colliding join event properties require a kernel-backed test');
49
+ }
50
+ }
51
+ const mappings = ProjectionJoins.properties(definition, joinSchema, schema);
52
+ if (!Object.keys(mappings).length) reject(path, 'joins without effective property mappings require a kernel-backed test');
53
+ for (const [property, expression] of Object.entries(mappings)) {
54
+ const mappingPath = `${path}.${Object.hasOwn(join.Value.Properties ?? {}, property) ? 'Properties' : 'AutoMap'}.${property}`;
55
+ if (property === join.Value.On) reject(mappingPath, 'a join cannot map its own join target in scenarios; this requires a kernel-backed test');
56
+ this.checkStringMapping(joinSchema, expression, mappingPath, context);
57
+ checkMapping(schema, joinSchema, property, expression, mappingPath);
58
+ }
59
+ let hasJoinTarget = false;
60
+ for (const entry of from) {
61
+ const fromPath = eventContractPath('From', entry.Key);
62
+ const eventSchema = requireEventSchema(entry.Key, fromPath);
63
+ const properties = { ...entry.Value.Properties };
64
+ if (definition.AutoMap !== AutoMap.Disabled) {
65
+ for (const source of Object.keys(eventSchema.properties ?? {})) {
66
+ const destination = Object.keys(schema.properties ?? {}).find(name => name.toLowerCase() === source.toLowerCase());
67
+ if (destination && !Object.keys(properties).some(name => name.toLowerCase() === destination.toLowerCase()) &&
68
+ !(definition.NoAutoMapProperties ?? []).some(name => name.toLowerCase() === destination.toLowerCase())) properties[destination] = source;
69
+ }
70
+ }
71
+ hasJoinTarget ||= Object.hasOwn(properties, join.Value.On);
72
+ for (const [property, expression] of Object.entries(properties)) {
73
+ const mappingPath = `${fromPath}.${Object.hasOwn(entry.Value.Properties ?? {}, property) ? 'Properties' : 'AutoMap'}.${property}`;
74
+ if (Object.hasOwn(mappings, property)) reject(mappingPath, 'From and Join mapping the same property requires a kernel-backed test');
75
+ this.checkStringMapping(eventSchema, expression, mappingPath, context);
76
+ }
77
+ }
78
+ if (!hasJoinTarget) reject(`${path}.On`, 'joins require a From mapping for the join target; join-only projections require a kernel-backed test');
79
+ }
80
+
81
+ private static checkStringMapping(eventSchema: JsonSchema, expression: string, path: string, context: ProjectionJoinValidationContext): void {
82
+ if (!/^[A-Za-z_]\w*$/.test(expression) || !this.plainString(eventSchema.properties?.[expression])) {
83
+ context.reject(path, 'joins support direct plain string event-property mappings only; context, constants and other expressions require a kernel-backed test');
84
+ }
85
+ }
86
+
87
+ private static plainString(schema: JsonSchema | undefined): boolean {
88
+ return schema?.type === 'string' && !schema.format;
89
+ }
90
+ }
@@ -38,6 +38,8 @@ const allFixtures = readdirSync(directory).filter(name => name.endsWith('.json')
38
38
  .map(name => ({ name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture }));
39
39
  const fixtures = allFixtures.filter(({ fixture }) => fixture.kind === 'kernelSemantics');
40
40
  const guardErrors: Record<string, { type: string; message: string }> = {
41
+ 'joins-custom-key.json': { type: 'UnsupportedProjectionOperation', message: 'join keys other than $eventSourceId' },
42
+ 'joins-initial-state.json': { type: 'UnsupportedProjectionOperation', message: 'joins with initial model state' },
41
43
  'arithmetic-string-operand.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic operands must have number/double' },
42
44
  'arithmetic-nonnumeric-operand.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic operands must have number/double' },
43
45
  'arithmetic-uint32-overflow.json': { type: 'UnsupportedProjectionOperation', message: 'arithmetic targets must be number/double or integer/int32' },
@@ -15,6 +15,8 @@ import type { ScenarioEvent } from '../ScenarioEvent.js';
15
15
  import { ProjectionArithmetic } from './ProjectionArithmetic.js';
16
16
  import { ProjectionCapabilities } from './ProjectionCapabilities.js';
17
17
  import { ProjectionExpressionEvaluator } from './ProjectionExpressionEvaluator.js';
18
+ import { ProjectionJoins } from './ProjectionJoins.js';
19
+ import type { ProjectionJoinRecord } from './ProjectionJoinRecord.js';
18
20
  import { ProjectionValueConverter } from './ProjectionValueConverter.js';
19
21
  import { UnsupportedProjectionOperation } from './UnsupportedProjectionOperation.js';
20
22
 
@@ -32,6 +34,8 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
32
34
  private readonly _initial: Record<string, unknown>;
33
35
  private readonly _from: ReadonlyMap<string, FromRecord>;
34
36
  private readonly _removed: ReadonlySet<string>;
37
+ private readonly _join: ProjectionJoinRecord | undefined;
38
+ private readonly _joinProperties: Record<string, string>;
35
39
  private readonly _children: ReadonlyMap<string, ChildBinding>;
36
40
  private readonly _eventSchemas: ReadonlyMap<string, JsonSchema>;
37
41
  private readonly _bindings: ReadonlyMap<string, { generation: number; path: string; declaration: string }>;
@@ -52,6 +56,11 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
52
56
  const path = eventContractPath(section, entry.Key);
53
57
  return [entry.Key.Id, { generation: entry.Key.Generation, path, declaration: declarationFor(path) ?? 'contract' }] as const;
54
58
  });
59
+ this._join = _definition.Join?.[0] as ProjectionJoinRecord | undefined;
60
+ if (this._join) {
61
+ const path = eventContractPath('Join', this._join.Key);
62
+ bindings.push([this._join.Key.Id, { generation: this._join.Key.Generation, path, declaration: declarationFor(path) ?? 'contract' }]);
63
+ }
55
64
  const children = new Map<string, ChildBinding>();
56
65
  for (const [property, child] of Object.entries(wire.Children ?? {})) {
57
66
  const items = this._schema.properties![property].items!;
@@ -68,6 +77,7 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
68
77
  this._bindings = new Map(bindings);
69
78
  this._eventSchemas = new Map([...(compiled.eventSchemas.get(_definition) ?? new Map()).values()]
70
79
  .map(entry => [entry.eventType.Id, entry.schema]));
80
+ this._joinProperties = this._join ? ProjectionJoins.properties(_definition, this._eventSchemas.get(this._join.Key.Id)!, this._schema) : {};
71
81
  }
72
82
 
73
83
  /** A snapshot of the production-style in-memory sink, for kernel conformance fixtures. */
@@ -78,17 +88,34 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
78
88
  async process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>> {
79
89
  const states = new Map<string, ReadModelState<TReadModel>>();
80
90
  const engine: Record<string, Record<string, unknown>> = Object.create(null);
91
+ // Replay-local event-sequence lookup. Removal deletes a root, never its joined source history.
92
+ const joinedEvents = new Map<string, { event: ScenarioEvent; content: unknown }>();
81
93
  for (const event of events) {
82
94
  const typeId = event.context.eventType.id.value;
83
95
  const from = this._from.get(typeId);
84
96
  const removed = this._removed.has(typeId);
85
97
  const child = this._children.get(typeId);
86
- if (!from && !removed && !child) continue;
98
+ const joining = this._join?.Key.Id === typeId;
99
+ if (!from && !removed && !child && !joining) continue;
87
100
  const binding = this._bindings.get(typeId)!;
88
101
  if (event.context.eventType.generation.value !== binding.generation) {
89
102
  throw new UnsupportedProjectionOperation(String(this._definition.ReadModel), binding.path, binding.declaration,
90
103
  `seeded event generation ${event.context.eventType.generation.value} differs from subscribed generation ${binding.generation}; multi-generation history requires a kernel-backed test`);
91
104
  }
105
+ if (joining) {
106
+ const content = ProjectionValueConverter.eventContent(event.content, this._eventSchemas.get(typeId)!);
107
+ joinedEvents.set(event.sourceId, { event, content });
108
+ // SetInitialState skips join events: changes are computed against an empty state.
109
+ // Consequently a missing/null string produces no change, even on a populated row.
110
+ const changes: Record<string, unknown> = Object.create(null);
111
+ this.apply(this._joinProperties, content, event, this._schema, changes);
112
+ for (const [key, state] of Object.entries(engine)) {
113
+ if (state[this._join!.Value.On] !== event.sourceId) continue;
114
+ Object.assign(state, changes);
115
+ states.set(key, { instance: this.materialize(state), deleted: false });
116
+ }
117
+ continue;
118
+ }
92
119
  const key = this.keyFor(event.sourceId);
93
120
  if (removed) {
94
121
  delete engine[key];
@@ -96,6 +123,16 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
96
123
  continue;
97
124
  }
98
125
  const schema = this._eventSchemas.get(typeId)!;
126
+ // EventScenario preserves client JSON, not kernel-normalized GUID/date/number/object JSON.
127
+ // Its deterministic hash is not kernel evidence for those payloads. Ordinary mappings
128
+ // still use the independently validated projection converters below.
129
+ if (event.scenarioLocalHash && from && Object.values(from.Value.Properties ?? {}).some(expression =>
130
+ /^\$eventContext\(Hash(?:\.Value)?\)$/.test(expression)) &&
131
+ Object.values(schema.properties ?? {}).some(property =>
132
+ !['string', 'boolean'].includes(property.type ?? '') || property.format !== undefined)) {
133
+ throw new UnsupportedProjectionOperation(String(this._definition.ReadModel), binding.path, binding.declaration,
134
+ 'observed Hash mappings for numeric, Guid, date or object event fields are not fixture-backed; use a kernel-backed test');
135
+ }
99
136
  const content = ProjectionValueConverter.eventContent(event.content, schema);
100
137
  let state = engine[key];
101
138
  if (!state) {
@@ -111,6 +148,12 @@ export class ProjectionReadModelProcessor<TReadModel extends object> implements
111
148
  const properties = this._definition.AutoMap !== AutoMap.Disabled && !ProjectionArithmetic.suppressesAutoMap(explicit)
112
149
  ? this.withAutoMap(explicit, schema, this._schema, excluded) : explicit;
113
150
  this.apply(properties, content, event, this._schema, state);
151
+ // ResolveJoin is wired only to From clauses that map the join target (including AutoMap).
152
+ if (this._join && Object.hasOwn(properties, this._join.Value.On)) {
153
+ const onValue = state[this._join.Value.On];
154
+ const joined = typeof onValue === 'string' ? joinedEvents.get(onValue) : undefined;
155
+ if (joined) this.apply(this._joinProperties, joined.content, joined.event, this._schema, state);
156
+ }
114
157
  }
115
158
  if (child) this.applyChild(child, content, event, schema, state, binding);
116
159
  // InMemorySink.ApplyChanges always restores lowercase id after applying mappings.
@@ -73,7 +73,7 @@ describe('when rejecting unsupported children operations before any event is see
73
73
  });
74
74
 
75
75
  const cases: Array<{ name: string; change: (child: ChildrenDefinitionLike, wire: Wire) => void; path: string; reason: string }> = [
76
- { name: 'a join inside children', change: child => { child.Join = [{}]; }, path: 'Children.labels.Join', reason: 'joins require a kernel-backed test' },
76
+ { name: 'a join inside children', change: child => { child.Join = [{}]; }, path: 'Children.labels.Join', reason: 'joins inside children require a kernel-backed test' },
77
77
  { name: 'nested children collections', change: child => { child.Children = { inner: child }; }, path: 'Children.labels.Children', reason: 'nested children collections' },
78
78
  { name: 'nested projections inside children', change: child => { child.Nested = { inner: child }; }, path: 'Children.labels.Nested', reason: 'nested projections inside children' },
79
79
  { name: 'removedWithJoin inside children', change: child => { child.RemovedWithJoin = [child.RemovedWithJoin[0] ?? { Key: child.From[0].Key, Value: { Key: '' } }]; }, path: 'Children.labels.RemovedWithJoin', reason: 'removedWithJoin inside children' },
@@ -30,7 +30,7 @@ chai.should();
30
30
  describe('when rejecting unsupported operations before any event is seeded', () => {
31
31
  const modelBound = [
32
32
  { name: 'nested', configure: (model: Function) => nested(model.prototype, 'name'), path: 'Nested.name (@nested)', reason: 'nested projections' },
33
- { name: 'join', configure: (model: Function) => join(Removed, 'name')(model.prototype, 'name'), path: 'Join[capability-removed:1] (@join)', reason: 'joins' },
33
+ { name: 'join on identifier', configure: (model: Function) => join(Removed, 'id')(model.prototype, 'name'), path: 'Join[capability-removed:1].On (@join)', reason: 'direct non-identifier' },
34
34
  { name: 'removedWithJoin', configure: (model: Function) => removedWithJoin(Removed)(model), path: 'RemovedWithJoin[capability-removed:1] (@removedWithJoin)', reason: 'removedWithJoin' },
35
35
  { name: 'fromEvery', configure: (model: Function) => fromEvery('name')(model.prototype, 'name'), path: 'All (@fromEvery)', reason: 'fromEvery/all' },
36
36
  { name: 'fromAll', configure: (model: Function) => fromAll('name')(model.prototype, 'name'), path: 'All (@fromAll)', reason: 'fromEvery/all' },
@@ -74,7 +74,7 @@ describe('when rejecting unsupported operations before any event is seeded', ()
74
74
  { name: 'context parent key', define: builder => { builder.from(Changed, from => from.usingParentKeyFromContext('eventSourceId')); }, path: 'From[capability-changed:1].ParentKey (.from().usingParentKeyFromContext)' },
75
75
  { name: 'composite key', define: builder => { builder.from(Changed, from => from.usingCompositeKey<{ name: string }>(key => key.set(target => target.name, event => event.name))); }, path: 'From[capability-changed:1].Key (.from().usingCompositeKey)' },
76
76
  { name: 'parent key', define: builder => { builder.from(Changed, from => from.usingParentKey(event => event.name)); }, path: 'From[capability-changed:1].ParentKey (.from().usingParentKey)' },
77
- { name: 'join', define: builder => { builder.from(Changed).join(Removed, join => join.on(model => model.id)); }, path: 'Join[capability-removed:1] (.join)' },
77
+ { name: 'join on identifier', define: builder => { builder.from(Changed).join(Removed, join => join.on(model => model.id)); }, path: 'Join[capability-removed:1].On (.join)' },
78
78
  { name: 'nested', define: builder => { builder.from(Changed).nested(model => model.details, child => child.clearWith(Removed)); }, path: 'Nested.details (.nested)' },
79
79
  { name: 'removedWithJoin', define: builder => { builder.from(Changed).removedWithJoin(Removed); }, path: 'RemovedWithJoin[capability-removed:1] (.removedWithJoin)' },
80
80
  { name: 'removal key', define: builder => { builder.from(Removed).removedWith(Changed, removal => removal.usingKey(event => event.name)); }, path: 'RemovedWith[capability-changed:1].Key (.removedWith)' },
@@ -0,0 +1,118 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { readFileSync } from 'node:fs';
5
+ import { AutoMap, ReadModelObserverType, type ProjectionDefinition } from '@cratis/chronicle.contracts';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { EventType } from '../../../events/EventType.js';
8
+ import type { EventContext } from '../../../events/EventContext.js';
9
+ import { buildReadModelDefinition } from '../../../readModels/buildReadModelDefinition.js';
10
+ import type { CompiledProjectionDefinitions } from '../../../projections/CompiledProjectionDefinitions.js';
11
+ import type { JsonSchema } from '../../../schemas/JsonSchema.js';
12
+ import { ProjectionReadModelProcessor } from '../ProjectionReadModelProcessor.js';
13
+ import { UnsupportedProjectionOperation } from '../UnsupportedProjectionOperation.js';
14
+
15
+ chai.should();
16
+ declare global {
17
+ interface Object { should: Chai.Assertion; }
18
+ }
19
+
20
+ function fixture() {
21
+ return JSON.parse(readFileSync(new URL('../fixtures/joins-lifecycle.json', import.meta.url), 'utf8')) as {
22
+ wireDefinition: Omit<ProjectionDefinition, 'Join' | 'From' | 'RemovedWith' | 'All'> & {
23
+ All: NonNullable<ProjectionDefinition['All']>;
24
+ Join: { Key: NonNullable<ProjectionDefinition['Join'][number]['Key']>; Value: NonNullable<ProjectionDefinition['Join'][number]['Value']> }[];
25
+ From: { Key: NonNullable<ProjectionDefinition['From'][number]['Key']>; Value: NonNullable<ProjectionDefinition['From'][number]['Value']> }[];
26
+ RemovedWith: { Key: NonNullable<ProjectionDefinition['RemovedWith'][number]['Key']>; Value: NonNullable<ProjectionDefinition['RemovedWith'][number]['Value']> }[];
27
+ };
28
+ readModel: { schema: JsonSchema };
29
+ eventSchemas: { eventType: { Id: string; Generation: number }; schema: JsonSchema }[];
30
+ };
31
+ }
32
+
33
+ function create(value: ReturnType<typeof fixture>) {
34
+ const definition = value.wireDefinition;
35
+ const compiled: CompiledProjectionDefinitions = {
36
+ definitions: [definition],
37
+ readModels: [buildReadModelDefinition({ identifier: definition.ReadModel, schema: JSON.stringify(value.readModel.schema),
38
+ sinkTypeId: 'test', observerType: ReadModelObserverType.Projection, observerIdentifier: definition.Identifier })],
39
+ provenance: new Map([[definition, [{ contractPath: 'Join[CustomerNamed:1]', declaration: '.join' }]]]),
40
+ eventSchemas: new Map([[definition, new Map(value.eventSchemas.map(entry => [`${entry.eventType.Id}:${entry.eventType.Generation}:0`,
41
+ { eventType: { ...entry.eventType, Tombstone: false }, schema: entry.schema }]))]])
42
+ };
43
+ return new ProjectionReadModelProcessor(class Order {}, compiled, definition);
44
+ }
45
+
46
+ describe('when validating joins before replay', () => {
47
+ it('should accept the captured root join without replaying any events', () => {
48
+ (() => create(fixture())).should.not.throw();
49
+ });
50
+
51
+ for (const key of ['name', '$value(customer)', '$eventContext(EventSourceId)', '$context.eventSourceId', '']) {
52
+ it(`should reject the unproven join key ${key}`, () => {
53
+ const value = fixture();
54
+ value.wireDefinition.Join[0].Value.Key = key;
55
+ (() => create(value)).should.throw(UnsupportedProjectionOperation).with.property('message')
56
+ .that.includes('Join[CustomerNamed:1].Key (.join)').and.includes('join keys other than $eventSourceId');
57
+ });
58
+ }
59
+
60
+ const cases: { name: string; change: (value: ReturnType<typeof fixture>) => void; reason: string }[] = [
61
+ { name: 'empty joins', change: value => { value.wireDefinition.Join[0].Value.Properties = {}; }, reason: 'joins without effective property mappings' },
62
+ { name: 'unknown join AutoMap', change: value => { value.wireDefinition.Join[0].Value.AutoMap = 99 as AutoMap; }, reason: 'unknown join AutoMap settings' },
63
+ { name: 'unmapped protected join fields', change: value => { value.eventSchemas[1].schema.properties!.ignored.security = [{ metadataType: 'EncryptedSubject', details: '' }]; }, reason: 'protected fields' },
64
+ { name: 'multiple joins', change: value => value.wireDefinition.Join.push(structuredClone(value.wireDefinition.Join[0])), reason: 'multiple join subscriptions' },
65
+ { name: 'join-only projections', change: value => { value.wireDefinition.From = []; }, reason: 'joins require a From mapping' },
66
+ { name: 'initial values', change: value => { value.wireDefinition.InitialModelState = '{"title":"seed"}'; }, reason: 'joins with initial model state' },
67
+ { name: 'identifier joins', change: value => { value.wireDefinition.Join[0].Value.On = 'id'; }, reason: 'direct non-identifier' },
68
+ { name: 'nested joins', change: value => { value.wireDefinition.Join[0].Value.On = 'customer.id'; }, reason: 'direct non-identifier' },
69
+ { name: 'unknown join targets', change: value => { value.wireDefinition.Join[0].Value.On = 'missing'; }, reason: 'direct non-identifier' },
70
+ { name: 'GUID roots', change: value => { value.readModel.schema.properties!.id.format = 'guid'; }, reason: 'plain string read-model properties' },
71
+ { name: 'numeric targets', change: value => { value.readModel.schema.properties!.customerName = { type: 'number' }; }, reason: 'plain string read-model properties' },
72
+ { name: 'numeric sources', change: value => { value.eventSchemas[1].schema.properties!.name = { type: 'number' }; }, reason: 'direct plain string event-property mappings' },
73
+ { name: 'nullable targets', change: value => { value.readModel.schema.properties!.customerName.type = ['string', 'null'] as unknown as JsonSchema['type']; }, reason: 'plain string read-model properties' },
74
+ { name: 'case-colliding sources', change: value => { value.eventSchemas[1].schema.properties!.Name = { type: 'string' }; }, reason: 'colliding join event properties' },
75
+ { name: 'protected sources', change: value => { value.eventSchemas[1].schema.properties!.name.security = [{ metadataType: 'EncryptedSubject', details: '' }]; }, reason: 'protected fields' },
76
+ { name: 'overlapping From and Join events', change: value => { value.wireDefinition.Join[0].Key = value.wireDefinition.From[0].Key; }, reason: 'events shared between Join' },
77
+ { name: 'overlapping removal and Join events', change: value => { value.wireDefinition.Join[0].Key = value.wireDefinition.RemovedWith[0].Key; }, reason: 'events shared between Join' },
78
+ { name: 'overlapping target mappings', change: value => { value.wireDefinition.From[0].Value.Properties.customerName = 'title'; }, reason: 'From and Join mapping the same property' },
79
+ { name: 'overlapping AutoMap targets', change: value => { value.wireDefinition.AutoMap = AutoMap.Enabled; value.eventSchemas[0].schema.properties!.customerName = { type: 'string' }; }, reason: 'From and Join mapping the same property' },
80
+ { name: 'join target mutation', change: value => { value.wireDefinition.Join[0].Value.Properties.customerId = 'name'; }, reason: 'cannot map its own join target' },
81
+ { name: 'root arithmetic', change: value => { value.wireDefinition.From[0].Value.Properties.title = '$count'; }, reason: 'joins combined with arithmetic' },
82
+ { name: 'join arithmetic', change: value => { value.wireDefinition.Join[0].Value.Properties.customerName = '$add(amount)'; }, reason: 'joins combined with arithmetic' },
83
+ { name: 'removedWithJoin', change: value => { value.wireDefinition.RemovedWithJoin = [{ Key: value.wireDefinition.Join[0].Key, Value: { Key: '$eventSourceId' } }]; }, reason: 'removedWithJoin require' },
84
+ { name: 'children combined with joins', change: value => { value.wireDefinition.Children = { items: {} as ProjectionDefinition['Children'][string] }; }, reason: 'root joins combined with children' },
85
+ { name: 'fromEvery combined with joins', change: value => { value.wireDefinition.All.Properties.title = 'title'; }, reason: 'fromEvery/all' },
86
+ { name: 'constant root keys', change: value => { value.wireDefinition.From[0].Value.Key = '$value(all)'; }, reason: 'only $eventSourceId keys' },
87
+ { name: 'root parent keys', change: value => { value.wireDefinition.From[0].Value.ParentKey = 'title'; }, reason: 'parent keys require' }
88
+ ];
89
+ for (const testCase of cases) {
90
+ it(`should reject ${testCase.name} without history`, () => {
91
+ const value = fixture();
92
+ testCase.change(value);
93
+ (() => create(value)).should.throw(UnsupportedProjectionOperation).with.property('message').that.includes(testCase.reason);
94
+ });
95
+ }
96
+
97
+ it('should reject a seeded join event from another generation even before a root exists', async () => {
98
+ const processor = create(fixture());
99
+ await processor.process([{ sourceId: 'customer', content: { name: 'unsupported' }, context: {
100
+ eventType: EventType.parse('CustomerNamed+2'), sequenceNumber: 0n, eventSourceId: 'customer'
101
+ } as EventContext }]).then(
102
+ () => { throw new Error('Expected rejection'); },
103
+ error => {
104
+ (error as Error).should.be.instanceOf(UnsupportedProjectionOperation);
105
+ (error as Error).message.should.include('Join[CustomerNamed:1] (.join)').and.include('seeded event generation 2');
106
+ }
107
+ );
108
+ });
109
+
110
+ for (const expression of ['$eventSourceId', '$eventContext(Subject)', '$value(fixed)', '$null', 'nested.name', 'missing']) {
111
+ it(`should reject unproven join mapping ${expression}`, () => {
112
+ const value = fixture();
113
+ value.wireDefinition.Join[0].Value.Properties.customerName = expression;
114
+ (() => create(value)).should.throw(UnsupportedProjectionOperation).with.property('message')
115
+ .that.includes('Join[CustomerNamed:1].Properties.customerName (.join)').and.includes('direct plain string');
116
+ });
117
+ }
118
+ });