@cratis/chronicle 6.32.0 → 6.33.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 (43) hide show
  1. package/dist/readModels/IReadModels.d.ts +14 -0
  2. package/dist/readModels/IReadModels.d.ts.map +1 -1
  3. package/dist/readModels/ReadModels.d.ts +6 -0
  4. package/dist/readModels/ReadModels.d.ts.map +1 -1
  5. package/dist/readModels/ReadModels.js +110 -0
  6. package/dist/readModels/ReadModels.js.map +1 -1
  7. package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts +20 -0
  8. package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts.map +1 -0
  9. package/dist/readModels/for_ReadModels/given/a_stored_read_model.js +79 -0
  10. package/dist/readModels/for_ReadModels/given/a_stored_read_model.js.map +1 -0
  11. package/dist/schemas/JsonSchemaGenerator.d.ts +4 -4
  12. package/dist/schemas/JsonSchemaGenerator.d.ts.map +1 -1
  13. package/dist/schemas/JsonSchemaGenerator.js +23 -8
  14. package/dist/schemas/JsonSchemaGenerator.js.map +1 -1
  15. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts +2 -1
  16. package/dist/testing/projections/ProjectionChildrenCapabilities.d.ts.map +1 -1
  17. package/dist/testing/projections/ProjectionChildrenCapabilities.js +9 -3
  18. package/dist/testing/projections/ProjectionChildrenCapabilities.js.map +1 -1
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +2 -2
  21. package/readModels/IReadModels.ts +15 -0
  22. package/readModels/ReadModels.ts +118 -1
  23. package/readModels/for_ReadModels/given/a_stored_read_model.ts +62 -0
  24. package/readModels/for_ReadModels/when_releasing_a_document/with_a_default_subject.spec.ts +39 -0
  25. package/readModels/for_ReadModels/when_releasing_a_document/with_a_frozen_document.spec.ts +28 -0
  26. package/readModels/for_ReadModels/when_releasing_a_document/with_a_malformed_response.spec.ts +24 -0
  27. package/readModels/for_ReadModels/when_releasing_a_document/with_a_reported_failure.spec.ts +38 -0
  28. package/readModels/for_ReadModels/when_releasing_a_document/with_an_empty_released_value.spec.ts +19 -0
  29. package/readModels/for_ReadModels/when_releasing_a_document/with_an_unknown_subject_property.spec.ts +42 -0
  30. package/readModels/for_ReadModels/when_releasing_a_document/with_bookkeeping_fields.spec.ts +27 -0
  31. package/readModels/for_ReadModels/when_releasing_a_document/with_conflicting_or_missing_properties.spec.ts +32 -0
  32. package/readModels/for_ReadModels/when_releasing_a_document/with_empty_subject_metadata.spec.ts +56 -0
  33. package/readModels/for_ReadModels/when_releasing_a_document/with_invalid_subject_metadata.spec.ts +27 -0
  34. package/readModels/for_ReadModels/when_releasing_a_document/with_mixed_subjects.spec.ts +38 -0
  35. package/readModels/for_ReadModels/when_releasing_a_document/with_property_subjects.spec.ts +52 -0
  36. package/readModels/for_ReadModels/when_releasing_a_document/with_whitespace_subjects.spec.ts +24 -0
  37. package/readModels/for_ReadModels/when_releasing_a_document/without_subjects.spec.ts +24 -0
  38. package/schemas/JsonSchemaGenerator.ts +22 -7
  39. package/schemas/for_JsonSchemaGenerator/when_generating_children/with_legacy_decorators.legacy.spec.ts +143 -0
  40. package/schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts +66 -0
  41. package/testing/ReadModelScenario.childrenFrom.spec.ts +70 -3
  42. package/testing/projections/ProjectionChildrenCapabilities.ts +9 -2
  43. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_children_operations.spec.ts +34 -0
@@ -0,0 +1,42 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each(['unknown', 'contact.phone', '__subject', '__subjects', 'toString'])('when releasing a document with unknown subject property %s', property => {
10
+ let context: a_stored_read_model;
11
+ let error: Error;
12
+
13
+ beforeEach(async () => {
14
+ context = new a_stored_read_model();
15
+ await context.readModels.releaseDocument(AccountDocument, {
16
+ name: 'encrypted name', contact: { phone: 'encrypted phone' }, unknown: 'not declared',
17
+ 'contact.phone': 'not declared', toString: 'not declared',
18
+ __subject: 'owner', __subjects: { [property]: 'other-owner' }
19
+ }).catch(caught => { error = caught as Error; });
20
+ });
21
+
22
+ it('should reject undeclared, nested, reserved and inherited property names', () => error.message.should.equal('Stored read model document has an unknown subject property.'));
23
+ it('should validate the entire subject map before attempting any releases', () => context.release.mock.calls.should.have.lengthOf(0));
24
+ });
25
+
26
+ describe.each(['email', 'removedProperty', 'contact.phone', 'toString'])('when releasing a document with a stale subject property %s', property => {
27
+ let context: a_stored_read_model;
28
+ let result: Record<string, unknown>;
29
+
30
+ beforeEach(async () => {
31
+ context = new a_stored_read_model();
32
+ result = await context.readModels.releaseDocument(AccountDocument, {
33
+ name: 'encrypted name', __subject: 'owner', __subjects: { [property]: 'other-owner' }
34
+ });
35
+ });
36
+
37
+ it('should ignore mappings without a corresponding document property', () => result.should.deep.equal({ name: 'encrypted name' }));
38
+ it('should release only the existing properties under their subject', () => {
39
+ context.release.mock.calls.should.have.lengthOf(1);
40
+ JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ name: 'encrypted name' });
41
+ });
42
+ });
@@ -0,0 +1,27 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([undefined, 'owner'])('when releasing a document with bookkeeping and default subject %s', defaultSubject => {
10
+ let context: a_stored_read_model;
11
+ let result: Record<string, unknown>;
12
+
13
+ beforeEach(async () => {
14
+ context = new a_stored_read_model();
15
+ result = await context.readModels.releaseDocument(AccountDocument, {
16
+ id: 'account', name: 'stored name', _id: 'storage-id', __generation: 1,
17
+ __subject: defaultSubject, __subjects: {}
18
+ });
19
+ });
20
+
21
+ it('should strip storage-only and subject metadata', () => result.should.deep.equal({ id: 'account', name: 'stored name' }));
22
+ it('should never send bookkeeping for release', () => {
23
+ context.release.mock.calls.forEach(([request]) => {
24
+ JSON.parse(request.Payload).should.deep.equal({ id: 'account', name: 'stored name' });
25
+ });
26
+ });
27
+ });
@@ -0,0 +1,32 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ const should = chai.should();
8
+
9
+ describe.each([
10
+ '{}',
11
+ '{"name":"wrong group"}',
12
+ '{"email":"released email","name":"overwrite released group"}',
13
+ '{"email":"released email","id":"overwrite unreleased property"}',
14
+ '{"email":"released email","__proto__":{"polluted":true}}'
15
+ ])('when releasing a document with missing or conflicting response properties %s', payload => {
16
+ let result: Record<string, unknown> | undefined;
17
+ let error: Error;
18
+
19
+ beforeEach(async () => {
20
+ const context = new a_stored_read_model();
21
+ result = undefined;
22
+ context.release.mockResolvedValueOnce({ HasError: false, Error: '', Payload: '{"name":"released name"}' });
23
+ context.release.mockResolvedValueOnce({ HasError: false, Error: '', Payload: payload });
24
+ await context.readModels.releaseDocument(AccountDocument, {
25
+ id: 'account', name: 'encrypted name', email: 'encrypted email',
26
+ __subjects: { name: 'owner', email: 'other-owner' }
27
+ }).then(released => { result = released; }, caught => { error = caught as Error; });
28
+ });
29
+
30
+ it('should reject the whole operation', () => error.message.should.equal('Failed to release stored read model document: missing or conflicting properties.'));
31
+ it('should not expose a partial result', () => should.not.exist(result));
32
+ });
@@ -0,0 +1,56 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([
10
+ { __subject: null }, { __subject: '' },
11
+ { __subjects: { name: null } }, { __subjects: { name: '' } },
12
+ { __subjects: null }, { __subjects: [] }, { __subjects: 'owner' }, { __subjects: 42 }
13
+ ])('when releasing a document without usable subject metadata %j', metadata => {
14
+ let context: a_stored_read_model;
15
+ let result: Record<string, unknown>;
16
+
17
+ beforeEach(async () => {
18
+ context = new a_stored_read_model();
19
+ result = await context.readModels.releaseDocument(AccountDocument, { name: 'stored name', ...metadata });
20
+ });
21
+
22
+ it('should copy properties without a subject unchanged', () => result.should.deep.equal({ name: 'stored name' }));
23
+ it('should not attempt release', () => context.release.mock.calls.should.have.lengthOf(0));
24
+ });
25
+
26
+ describe.each([null, ''])('when releasing a document with an empty property subject %j and a default subject', subject => {
27
+ let context: a_stored_read_model;
28
+ let result: Record<string, unknown>;
29
+
30
+ beforeEach(async () => {
31
+ context = new a_stored_read_model();
32
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: JSON.stringify({ name: 'released name' }) });
33
+ result = await context.readModels.releaseDocument(AccountDocument, {
34
+ name: 'encrypted name', __subject: 'owner', __subjects: { name: subject }
35
+ });
36
+ });
37
+
38
+ it('should fall back to the default subject', () => context.requestFor('owner').Subject.should.equal('owner'));
39
+ it('should release the property', () => result.should.deep.equal({ name: 'released name' }));
40
+ });
41
+
42
+ describe.each([null, ''])('when releasing a document with an empty default subject %j and a property subject', subject => {
43
+ let context: a_stored_read_model;
44
+ let result: Record<string, unknown>;
45
+
46
+ beforeEach(async () => {
47
+ context = new a_stored_read_model();
48
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: JSON.stringify({ name: 'released name' }) });
49
+ result = await context.readModels.releaseDocument(AccountDocument, {
50
+ id: 'account', name: 'encrypted name', __subject: subject, __subjects: { name: 'owner' }
51
+ });
52
+ });
53
+
54
+ it('should release only the property with a subject', () => JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ name: 'encrypted name' }));
55
+ it('should retain properties without a subject', () => result.should.deep.equal({ id: 'account', name: 'released name' }));
56
+ });
@@ -0,0 +1,27 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([
10
+ { __subject: 42 }, { __subject: false }, { __subject: [] }, { __subject: {} },
11
+ { __subjects: { name: 42 } }, { __subjects: { name: false } },
12
+ { __subjects: { name: [] } }, { __subjects: { name: { subject: 'private@example.test' } } },
13
+ { __subjects: { email: 42 } }, { __subjects: { removedProperty: 42 } }
14
+ ])('when releasing a document with invalid subject metadata %j', metadata => {
15
+ let context: a_stored_read_model;
16
+ let error: Error;
17
+
18
+ beforeEach(async () => {
19
+ context = new a_stored_read_model();
20
+ await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', ...metadata })
21
+ .catch(caught => { error = caught as Error; });
22
+ });
23
+
24
+ it('should reject the document', () => error.should.be.instanceOf(Error));
25
+ it('should not expose metadata values in the error', () => error.message.should.not.contain('private@example.test'));
26
+ it('should not attempt release with invalid metadata', () => context.release.mock.calls.should.have.lengthOf(0));
27
+ });
@@ -0,0 +1,38 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when releasing a document with mixed default and property subjects', () => {
10
+ let context: a_stored_read_model;
11
+ let result: Record<string, unknown>;
12
+
13
+ beforeEach(async () => {
14
+ context = new a_stored_read_model();
15
+ context.release.mockImplementation(async request => ({
16
+ HasError: false, Error: '', Payload: JSON.stringify(request.Subject === 'owner'
17
+ ? { id: 'account', name: 'released name' } : { email: 'released email' })
18
+ }));
19
+ result = await context.readModels.releaseDocument(AccountDocument, {
20
+ id: 'account', name: 'encrypted name', email: 'encrypted email',
21
+ __subject: 'owner', __subjects: { name: 'owner', email: 'joined-owner' }
22
+ });
23
+ });
24
+
25
+ it('should reuse the default subject group for matching property subjects', () => context.release.mock.calls.should.have.lengthOf(2));
26
+ it('should apply the default to unmapped properties', () => {
27
+ JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ id: 'account', name: 'encrypted name' });
28
+ });
29
+ it('should override the default for mapped properties', () => {
30
+ JSON.parse(context.requestFor('joined-owner').Payload).should.deep.equal({ email: 'encrypted email' });
31
+ });
32
+ it('should send a schema limited to the overridden property', () => {
33
+ Object.keys(JSON.parse(context.requestFor('joined-owner').Schema).properties).should.deep.equal(['email']);
34
+ });
35
+ it('should merge the released properties without bookkeeping', () => result.should.deep.equal({
36
+ id: 'account', name: 'released name', email: 'released email'
37
+ }));
38
+ });
@@ -0,0 +1,52 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { JsonSchemaGenerator } from '../../../schemas/index.js';
6
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when releasing a document with property subjects', () => {
11
+ let context: a_stored_read_model;
12
+ let result: Record<string, unknown>;
13
+
14
+ beforeEach(async () => {
15
+ context = new a_stored_read_model();
16
+ context.release.mockImplementation(async request => ({
17
+ HasError: false, Error: '', Payload: JSON.stringify(request.Subject === 'owner'
18
+ ? { name: 'released name', email: 'released email' }
19
+ : { contact: { phone: 'released phone' }, contacts: [{ phone: 'released list phone' }] })
20
+ }));
21
+ result = await context.readModels.releaseDocument(AccountDocument, {
22
+ id: 'account', name: 'encrypted name', email: 'encrypted email',
23
+ contact: { phone: 'encrypted phone' }, contacts: [{ phone: 'encrypted list phone' }],
24
+ __subjects: { name: 'owner', email: 'owner', contact: 'contact-owner', contacts: 'contact-owner' }
25
+ });
26
+ });
27
+
28
+ it('should batch properties by distinct subject', () => context.release.mock.calls.should.have.lengthOf(2));
29
+ it('should send only the owner properties to that subject', () => {
30
+ JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ name: 'encrypted name', email: 'encrypted email' });
31
+ });
32
+ it('should release each nested subtree and array under its top-level subject', () => {
33
+ JSON.parse(context.requestFor('contact-owner').Payload).should.deep.equal({
34
+ contact: { phone: 'encrypted phone' }, contacts: [{ phone: 'encrypted list phone' }]
35
+ });
36
+ });
37
+ it('should retain nested compliance schemas', () => {
38
+ const properties = JsonSchemaGenerator.generate(AccountDocument).properties!;
39
+ JSON.parse(context.requestFor('contact-owner').Schema).properties.should.deep.equal({
40
+ contact: properties.contact, contacts: properties.contacts
41
+ });
42
+ });
43
+ it('should include the array item phone PII metadata in the subject-specific schema', () => {
44
+ JSON.parse(context.requestFor('contact-owner').Schema).properties.contacts.items.properties.phone.should.deep.equal({
45
+ type: 'string', compliance: [{ metadataType: 'PII', details: '' }]
46
+ });
47
+ });
48
+ it('should combine the released groups and retain properties without a subject', () => result.should.deep.equal({
49
+ id: 'account', name: 'released name', email: 'released email',
50
+ contact: { phone: 'released phone' }, contacts: [{ phone: 'released list phone' }]
51
+ }));
52
+ });
@@ -0,0 +1,24 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([
10
+ { __subject: ' \t' },
11
+ { __subject: 'owner', __subjects: { name: ' \t' } }
12
+ ])('when releasing a document with a whitespace-only subject %j', metadata => {
13
+ let context: a_stored_read_model;
14
+ let result: Record<string, unknown>;
15
+
16
+ beforeEach(async () => {
17
+ context = new a_stored_read_model();
18
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: JSON.stringify({ name: 'released name' }) });
19
+ result = await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', ...metadata });
20
+ });
21
+
22
+ it('should preserve the exact subject in the release request', () => context.requestFor(' \t').Subject.should.equal(' \t'));
23
+ it('should release the property', () => result.should.deep.equal({ name: 'released name' }));
24
+ });
@@ -0,0 +1,24 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when releasing a document without subjects', () => {
10
+ let context: a_stored_read_model;
11
+ let document: Record<string, unknown>;
12
+ let result: Record<string, unknown>;
13
+
14
+ beforeEach(async () => {
15
+ context = new a_stored_read_model();
16
+ document = { id: 'account', name: 'unchanged', contact: { phone: 'unchanged phone' } };
17
+ result = await context.readModels.releaseDocument(AccountDocument, document);
18
+ });
19
+
20
+ it('should leave data values unchanged', () => result.should.deep.equal(document));
21
+ it('should not infer a subject from id or subject decorators', () => context.release.mock.calls.should.have.lengthOf(0));
22
+ it('should return a copy', () => result.should.not.equal(document));
23
+ it('should not share unreleased subtrees with the input', () => result.contact!.should.not.equal(document.contact));
24
+ });
@@ -61,7 +61,9 @@ export class JsonSchemaGenerator {
61
61
  const schemaProperties: Record<string, JsonSchema> = {};
62
62
  const prototype = target.prototype;
63
63
 
64
- for (const [memberName, memberType] of membersToUse.entries()) {
64
+ for (const [memberName, reflectedMemberType] of membersToUse.entries()) {
65
+ // A children collection is an array even when neither design:type nor an initializer is available.
66
+ const memberType = reflectedMemberType ?? (getChildrenFromMetadata(prototype, memberName).length > 0 ? Array : undefined);
65
67
  if (!memberType && requireResolvedTypes) {
66
68
  throw new TypeError(`Cannot determine the type of ${target.name}.${memberName}; declare @field with its runtime type.`);
67
69
  }
@@ -144,7 +146,7 @@ export class JsonSchemaGenerator {
144
146
 
145
147
  /**
146
148
  * Maps an array-typed member to a schema, resolving the element type from a
147
- * `@field(Array, { genericArguments: [ItemType] })` declaration when present.
149
+ * `@childrenFrom` child type or `@field(Array, { genericArguments: [ItemType] })` declaration when present.
148
150
  * @param declaringType - The class constructor that declares the array property.
149
151
  * @param propertyName - The array property name.
150
152
  * @returns The array schema, with the element's own compliance metadata carried onto `items` when the element is a PII concept.
@@ -173,14 +175,27 @@ export class JsonSchemaGenerator {
173
175
  return { type: 'array', items: { type: 'object' } };
174
176
  }
175
177
 
176
- if (!requireResolvedTypes) return { type: 'array', items: { type: 'object' } }; // Legacy schema compatibility.
177
- return { type: 'array', items: this.mapRuntimeTypeToSchema(elementType, undefined, undefined, true) };
178
+ const childrenMetadata = declaringType && propertyName ? getChildrenFromMetadata(declaringType.prototype, propertyName) : [];
179
+ if (!requireResolvedTypes && !childrenMetadata.length) return { type: 'array', items: { type: 'object' } }; // Legacy schema compatibility outside children.
180
+ const items = this.mapRuntimeTypeToSchema(elementType, undefined, undefined, requireResolvedTypes);
181
+ if (!requireResolvedTypes && childrenMetadata.length) {
182
+ // Match buildChildrenEntry's identifier discovery, including members whose types are unresolved.
183
+ const members = [...TypeIntrospector.getMembers(elementType).keys()];
184
+ const eventKey = childrenMetadata[0]?.key;
185
+ const identifiedBy = childrenMetadata.find(metadata => metadata.identifiedBy)?.identifiedBy ??
186
+ members.find(name => name.toLowerCase() === 'id') ??
187
+ (eventKey !== undefined ? members.find(name => name.toLowerCase() === eventKey.toLowerCase()) : undefined) ?? '$eventSourceId';
188
+ if (!items.properties || !Object.hasOwn(items.properties, identifiedBy)) {
189
+ return { type: 'array', items: { type: 'object' } };
190
+ }
191
+ }
192
+ return { type: 'array', items };
178
193
  }
179
194
 
180
195
  /**
181
- * Resolves the element type of an array property from its `@field(Array, { genericArguments: [...] })`
182
- * declaration. TypeScript erases generic type arguments at runtime, so without an explicit
183
- * `@field` declaration the element type cannot be recovered.
196
+ * Resolves the element type from an explicit `@childrenFrom` child type, then from
197
+ * `@field(Array, { genericArguments: [...] })`. TypeScript erases generic type arguments
198
+ * at runtime, so an explicit declaration is needed to recover the element type.
184
199
  * @param declaringType - The class constructor that declares the array property.
185
200
  * @param propertyName - The array property name.
186
201
  * @returns The element type constructor, or undefined when it cannot be resolved.
@@ -0,0 +1,143 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { childrenFrom } from '../../../projections/modelBound/childrenFrom.js';
8
+ import { JsonSchemaGenerator } from '../../JsonSchemaGenerator.js';
9
+
10
+ chai.should();
11
+
12
+ class ItemAdded {
13
+ itemId = '';
14
+ name = '';
15
+ }
16
+
17
+ class Item {
18
+ id = '';
19
+ name = '';
20
+ quantity = 0;
21
+ }
22
+
23
+ class Order {
24
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
25
+ options: Item[] = [];
26
+
27
+ @childrenFrom(ItemAdded, Item)
28
+ positional: Item[] = [];
29
+
30
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
31
+ withoutInitializer!: Item[];
32
+
33
+ @field(Array, { genericArguments: [Item] })
34
+ @childrenFrom(ItemAdded, 'itemId')
35
+ fieldMetadata: Item[] = [];
36
+
37
+ @childrenFrom(ItemAdded, { key: 'itemId', identifiedBy: 'id' })
38
+ unknown: Item[] = [];
39
+
40
+ @field(Array, { genericArguments: [Item] })
41
+ unrelated: Item[] = [];
42
+ }
43
+
44
+ class DeclaredItem {
45
+ @field(String) id!: string;
46
+ @field(String) name!: string;
47
+ }
48
+
49
+ class DeclaredOrder {
50
+ @childrenFrom(ItemAdded, { childType: DeclaredItem, key: 'itemId' })
51
+ items!: DeclaredItem[];
52
+ }
53
+
54
+ class UnresolvedItem {
55
+ id!: string;
56
+ name!: string;
57
+ }
58
+
59
+ class PartiallyResolvedItem {
60
+ id = '';
61
+ name!: string;
62
+ }
63
+
64
+ class MissingIdentifierItem {
65
+ id!: string;
66
+ name = '';
67
+ }
68
+
69
+ class CustomIdentifierItem {
70
+ itemId = '';
71
+ name!: string;
72
+ }
73
+
74
+ class CapitalizedIdentifierItem {
75
+ Id = '';
76
+ name!: string;
77
+ }
78
+
79
+ class FallbackOrder {
80
+ @childrenFrom(ItemAdded, { childType: UnresolvedItem, key: 'itemId', identifiedBy: 'id' })
81
+ unresolved!: UnresolvedItem[];
82
+
83
+ @childrenFrom(ItemAdded, { childType: MissingIdentifierItem, key: 'itemId', identifiedBy: 'id' })
84
+ missingIdentifier!: MissingIdentifierItem[];
85
+
86
+ @childrenFrom(ItemAdded, { childType: PartiallyResolvedItem, key: 'itemId' })
87
+ partial!: PartiallyResolvedItem[];
88
+
89
+ @childrenFrom(ItemAdded, { childType: CustomIdentifierItem, key: 'itemId' })
90
+ custom!: CustomIdentifierItem[];
91
+
92
+ @childrenFrom(ItemAdded, { childType: CapitalizedIdentifierItem, key: 'itemId' })
93
+ capitalized!: CapitalizedIdentifierItem[];
94
+
95
+ @childrenFrom(ItemAdded, { childType: MissingIdentifierItem, key: 'itemId' })
96
+ undiscoverableIdentifier!: MissingIdentifierItem[];
97
+ }
98
+
99
+ const schema = JsonSchemaGenerator.generate(Order);
100
+
101
+ describe('for JsonSchemaGenerator', () => {
102
+ describe('when generating children with legacy decorators without design metadata', () => {
103
+ for (const property of ['options', 'positional', 'withoutInitializer', 'fieldMetadata']) {
104
+ it(`should register typed items for ${property}`, () => {
105
+ chai.should().not.exist(Reflect.getMetadata('design:type', Order.prototype, property));
106
+ schema.properties![property].should.deep.equal({ type: 'array', items: JsonSchemaGenerator.generate(Item) });
107
+ schema.properties![property].items!.properties!.should.deep.equal({
108
+ id: { type: 'string' }, name: { type: 'string' }, quantity: { type: 'number' }
109
+ });
110
+ });
111
+ }
112
+
113
+ it('should resolve child properties from explicit field metadata without initializers', () => {
114
+ JsonSchemaGenerator.generate(DeclaredOrder).properties!.items.items!.properties!.should.deep.equal({
115
+ id: { type: 'string' }, name: { type: 'string' }
116
+ });
117
+ });
118
+
119
+ it('should retain untyped items when the child type is unknown', () => {
120
+ schema.properties!.unknown.should.deep.equal({ type: 'array', items: { type: 'object' } });
121
+ });
122
+
123
+ it('should retain untyped items for child members declared only with definite assignment', () => {
124
+ JsonSchemaGenerator.generate(FallbackOrder).properties!.unresolved.should.deep.equal({ type: 'array', items: { type: 'object' } });
125
+ });
126
+
127
+ for (const property of ['missingIdentifier', 'undiscoverableIdentifier']) {
128
+ it(`should retain untyped items for ${property} despite a resolved non-identifier property`, () => {
129
+ JsonSchemaGenerator.generate(FallbackOrder).properties![property].should.deep.equal({ type: 'array', items: { type: 'object' } });
130
+ });
131
+ }
132
+
133
+ for (const [property, childType] of [['partial', PartiallyResolvedItem], ['custom', CustomIdentifierItem], ['capitalized', CapitalizedIdentifierItem]] as const) {
134
+ it(`should retain typed items for ${property} with a resolved identifier`, () => {
135
+ JsonSchemaGenerator.generate(FallbackOrder).properties![property].should.deep.equal({ type: 'array', items: JsonSchemaGenerator.generate(childType) });
136
+ });
137
+ }
138
+
139
+ it('should preserve legacy array schemas outside children collections', () => {
140
+ schema.properties!.unrelated.should.deep.equal({ type: 'array', items: { type: 'object' } });
141
+ });
142
+ });
143
+ });
@@ -0,0 +1,66 @@
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 { chai, describe, it } from 'vitest';
6
+ import { childrenFrom } from '../../../projections/modelBound/childrenFrom.js';
7
+ import { JsonSchemaGenerator } from '../../JsonSchemaGenerator.js';
8
+
9
+ chai.should();
10
+
11
+ function should(value: unknown): ReturnType<typeof chai.expect> {
12
+ return (value as { should: ReturnType<typeof chai.expect> }).should;
13
+ }
14
+
15
+ class ItemAdded {
16
+ @field(String) itemId!: string;
17
+ @field(String) name!: string;
18
+ }
19
+
20
+ class Item {
21
+ @field(String) id!: string;
22
+ @field(String) name!: string;
23
+ @field(Number) quantity!: number;
24
+ }
25
+
26
+ class Order {
27
+ @field(Array)
28
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
29
+ options!: Item[];
30
+
31
+ @field(Array)
32
+ @childrenFrom(ItemAdded, Item)
33
+ positional!: Item[];
34
+
35
+ @childrenFrom(ItemAdded, { childType: Item, key: 'itemId' })
36
+ withoutField!: Item[];
37
+
38
+ @field(Array, { genericArguments: [Item] })
39
+ @childrenFrom(ItemAdded, 'itemId')
40
+ fieldMetadata!: Item[];
41
+ }
42
+
43
+ class UnknownOrder {
44
+ @field(Array)
45
+ @childrenFrom(ItemAdded, { key: 'itemId', identifiedBy: 'id' })
46
+ items!: Item[];
47
+ }
48
+
49
+ const schema = JsonSchemaGenerator.generate(Order);
50
+
51
+ describe('for JsonSchemaGenerator', () => {
52
+ describe('when generating children with standard decorators', () => {
53
+ for (const property of ['options', 'positional', 'withoutField', 'fieldMetadata']) {
54
+ it(`should register typed items for ${property}`, () => {
55
+ should(schema.properties![property]).deep.equal({ type: 'array', items: JsonSchemaGenerator.generate(Item) });
56
+ should(schema.properties![property].items!.properties!).deep.equal({
57
+ id: { type: 'string' }, name: { type: 'string' }, quantity: { type: 'number' }
58
+ });
59
+ });
60
+ }
61
+
62
+ it('should still reject an unresolved child type', () => {
63
+ should(() => JsonSchemaGenerator.generate(UnknownOrder)).throw(TypeError, 'Cannot determine the element type of UnknownOrder.items');
64
+ });
65
+ });
66
+ });