@cratis/chronicle 6.32.1 → 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 (29) 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/tsconfig.tsbuildinfo +1 -1
  12. package/package.json +1 -1
  13. package/readModels/IReadModels.ts +15 -0
  14. package/readModels/ReadModels.ts +118 -1
  15. package/readModels/for_ReadModels/given/a_stored_read_model.ts +62 -0
  16. package/readModels/for_ReadModels/when_releasing_a_document/with_a_default_subject.spec.ts +39 -0
  17. package/readModels/for_ReadModels/when_releasing_a_document/with_a_frozen_document.spec.ts +28 -0
  18. package/readModels/for_ReadModels/when_releasing_a_document/with_a_malformed_response.spec.ts +24 -0
  19. package/readModels/for_ReadModels/when_releasing_a_document/with_a_reported_failure.spec.ts +38 -0
  20. package/readModels/for_ReadModels/when_releasing_a_document/with_an_empty_released_value.spec.ts +19 -0
  21. package/readModels/for_ReadModels/when_releasing_a_document/with_an_unknown_subject_property.spec.ts +42 -0
  22. package/readModels/for_ReadModels/when_releasing_a_document/with_bookkeeping_fields.spec.ts +27 -0
  23. package/readModels/for_ReadModels/when_releasing_a_document/with_conflicting_or_missing_properties.spec.ts +32 -0
  24. package/readModels/for_ReadModels/when_releasing_a_document/with_empty_subject_metadata.spec.ts +56 -0
  25. package/readModels/for_ReadModels/when_releasing_a_document/with_invalid_subject_metadata.spec.ts +27 -0
  26. package/readModels/for_ReadModels/when_releasing_a_document/with_mixed_subjects.spec.ts +38 -0
  27. package/readModels/for_ReadModels/when_releasing_a_document/with_property_subjects.spec.ts +52 -0
  28. package/readModels/for_ReadModels/when_releasing_a_document/with_whitespace_subjects.spec.ts +24 -0
  29. package/readModels/for_ReadModels/when_releasing_a_document/without_subjects.spec.ts +24 -0
@@ -19,7 +19,7 @@ import { hasFromEventMetadata } from '../projections/modelBound/fromEvent.js';
19
19
  import { hasModelBoundProperties } from '../types/TypeDiscoverer.js';
20
20
  import { isPassive } from '../projections/modelBound/passive.js';
21
21
  import { getReducerMetadata } from '../reducers/reducer.js';
22
- import { JsonSchemaGenerator } from '../schemas/index.js';
22
+ import { JsonSchemaGenerator, type JsonSchema } from '../schemas/index.js';
23
23
  import { WellKnownSinks } from '../sinks/index.js';
24
24
  import { getReadModelMetadata, getReadModelId } from './readModel.js';
25
25
  import { buildReadModelDefinition } from './buildReadModelDefinition.js';
@@ -238,6 +238,123 @@ export class ReadModels implements IReadModels {
238
238
  return Promise.all(releasePromises);
239
239
  }
240
240
 
241
+ /** @inheritdoc */
242
+ async releaseDocument<TReadModel>(readModelType: Constructor<TReadModel>, document: Readonly<Record<string, unknown>>): Promise<Record<string, unknown>> {
243
+ const readModel = this.resolveReadModel(readModelType);
244
+ const schema = JSON.parse(this.getReadModelSchema(readModelType, readModel.identifier)) as JsonSchema;
245
+ const properties = schema.properties ?? {};
246
+ const stored = this.copyStoredDocument(document);
247
+ const storedDefaultSubject = stored.__subject;
248
+ const defaultSubject = this.isDocumentSubject(storedDefaultSubject) ? storedDefaultSubject : undefined;
249
+ const storedSubjects = this.isDocumentObject(stored.__subjects) ? stored.__subjects : {};
250
+ const subjects = new Map<string, string>();
251
+
252
+ if (storedDefaultSubject !== undefined && storedDefaultSubject !== null && typeof storedDefaultSubject !== 'string') {
253
+ throw new Error('Stored read model document has an invalid default subject.');
254
+ }
255
+ for (const [property, subject] of Object.entries(storedSubjects)) {
256
+ if (property === '__subject' || property === '__subjects') {
257
+ throw new Error('Stored read model document has an unknown subject property.');
258
+ }
259
+ // The kernel's JSON subject map ignores null/empty strings, but rejects non-string values.
260
+ if (subject !== null && typeof subject !== 'string') {
261
+ throw new Error('Stored read model document has an invalid property subject.');
262
+ }
263
+ // Cleared or removed properties can leave stale mappings in stored documents.
264
+ if (!Object.hasOwn(stored, property)) {
265
+ continue;
266
+ }
267
+ if (!Object.hasOwn(properties, property)) {
268
+ throw new Error('Stored read model document has an unknown subject property.');
269
+ }
270
+ if (this.isDocumentSubject(subject)) {
271
+ subjects.set(property, subject);
272
+ }
273
+ }
274
+
275
+ const groups = new Map<string, string[]>();
276
+ const releasedEntries: [string, unknown][] = [];
277
+ for (const [property, value] of Object.entries(stored)) {
278
+ // Only schema-declared data is returned, never storage bookkeeping.
279
+ if (property === '__subject' || property === '__subjects' || !Object.hasOwn(properties, property)) {
280
+ continue;
281
+ }
282
+ const subject = subjects.get(property) ?? defaultSubject;
283
+ if (subject === undefined) {
284
+ releasedEntries.push([property, value]);
285
+ continue;
286
+ }
287
+ const group = groups.get(subject) ?? [];
288
+ group.push(property);
289
+ groups.set(subject, group);
290
+ }
291
+
292
+ for (const [subject, propertyNames] of groups) {
293
+ const payload = Object.fromEntries(propertyNames.map(property => [property, stored[property]]));
294
+ const groupSchema: JsonSchema = {
295
+ ...schema,
296
+ properties: Object.fromEntries(propertyNames.map(property => [property, properties[property]])),
297
+ required: schema.required?.filter(property => propertyNames.includes(property))
298
+ };
299
+ const released = await this.releaseDocumentProperties(subject, groupSchema, payload);
300
+ const releasedProperties = Object.keys(released);
301
+ if (releasedProperties.length !== propertyNames.length ||
302
+ releasedProperties.some(property => !Object.hasOwn(payload, property))) {
303
+ throw new Error('Failed to release stored read model document: missing or conflicting properties.');
304
+ }
305
+ releasedEntries.push(...Object.entries(released));
306
+ }
307
+
308
+ // Publish only after every subject succeeded. Entire top-level subtrees belong to one group.
309
+ return Object.fromEntries(releasedEntries);
310
+ }
311
+
312
+ private async releaseDocumentProperties(subject: string, schema: JsonSchema, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
313
+ try {
314
+ const response = await this._connection.compliance.release({
315
+ EventStore: this._eventStore,
316
+ Namespace: this._namespace,
317
+ Subject: subject,
318
+ Schema: JSON.stringify(schema),
319
+ Payload: JSON.stringify(payload)
320
+ });
321
+ if (response.HasError !== false || typeof response.Payload !== 'string') {
322
+ throw new Error();
323
+ }
324
+ const released: unknown = JSON.parse(response.Payload);
325
+ if (!this.isDocumentObject(released)) {
326
+ throw new Error();
327
+ }
328
+ return released;
329
+ } catch {
330
+ // Kernel, transport and JSON parser errors can contain PII; do not expose them as causes.
331
+ throw new Error('Failed to release PII in stored read model document.');
332
+ }
333
+ }
334
+
335
+ private copyStoredDocument(document: Readonly<Record<string, unknown>>): Record<string, unknown> {
336
+ try {
337
+ if (!this.isDocumentObject(document)) {
338
+ throw new Error();
339
+ }
340
+ const copy: unknown = JSON.parse(JSON.stringify(document));
341
+ if (!this.isDocumentObject(copy)) {
342
+ throw new Error();
343
+ }
344
+ return copy;
345
+ } catch {
346
+ throw new Error('Stored read model document must be a JSON object.');
347
+ }
348
+ }
349
+
350
+ private isDocumentObject(value: unknown): value is Record<string, unknown> {
351
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
352
+ }
353
+
354
+ private isDocumentSubject(value: unknown): value is string {
355
+ return typeof value === 'string' && value.length > 0;
356
+ }
357
+
241
358
  private async releaseSnapshotInstances<TReadModel>(readModelType: Constructor<TReadModel>, snapshots: ReadModelSnapshot<TReadModel>[]): Promise<ReadModelSnapshot<TReadModel>[]> {
242
359
  return Promise.all(
243
360
  snapshots.map(async snapshot => ({
@@ -0,0 +1,62 @@
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 type { ReleaseRequest, ReleaseResponse } from '@cratis/chronicle.contracts';
7
+ import { vi } from 'vitest';
8
+ import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
9
+ import type { ChronicleConnection } from '../../../connection/index.js';
10
+ import { pii } from '../../../compliance/pii.js';
11
+ import { subject } from '../../../compliance/subject.js';
12
+ import { fromEvent } from '../../../projections/modelBound/fromEvent.js';
13
+ import type { IReadModels } from '../../IReadModels.js';
14
+ import { ReadModels } from '../../ReadModels.js';
15
+
16
+ class AccountOpened {}
17
+
18
+ export class ContactDetails {
19
+ @field(String)
20
+ @pii()
21
+ phone!: string;
22
+ }
23
+
24
+ @fromEvent(AccountOpened)
25
+ export class AccountDocument {
26
+ @field(String)
27
+ @subject()
28
+ id!: string;
29
+
30
+ @field(String)
31
+ @pii()
32
+ name!: string;
33
+
34
+ @field(String)
35
+ @pii()
36
+ email!: string;
37
+
38
+ @field(ContactDetails)
39
+ contact!: ContactDetails;
40
+
41
+ @field(Array, { genericArguments: [ContactDetails] })
42
+ contacts!: ContactDetails[];
43
+ }
44
+
45
+ export class a_stored_read_model {
46
+ readonly release = vi.fn<(request: ReleaseRequest) => Promise<ReleaseResponse>>()
47
+ .mockImplementation(async request => ({ HasError: false, Error: '', Payload: request.Payload }));
48
+ readonly readModels: IReadModels;
49
+
50
+ constructor() {
51
+ const connection = { compliance: { release: this.release } } as unknown as ChronicleConnection;
52
+ const artifacts: IClientArtifactsProvider = {
53
+ eventTypes: [], readModels: [AccountDocument], projections: [], reducers: [], reactors: [],
54
+ seeders: [], constraints: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
55
+ };
56
+ this.readModels = new ReadModels('store', 'tenant', connection, artifacts, 'sink');
57
+ }
58
+
59
+ requestFor(subject: string): ReleaseRequest {
60
+ return this.release.mock.calls.map(([request]) => request).find(request => request.Subject === subject)!;
61
+ }
62
+ }
@@ -0,0 +1,39 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { 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 a default subject', () => {
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.mockResolvedValue({ HasError: false, Error: '', Payload: '{"id":"account","name":"released name"}' });
17
+ result = await context.readModels.releaseDocument(AccountDocument, {
18
+ id: 'account', name: 'encrypted name', __subject: 'owner'
19
+ });
20
+ });
21
+
22
+ it('should release all present properties together', () => context.release.mock.calls.should.have.lengthOf(1));
23
+ it('should use the stored subject rather than the id', () => context.requestFor('owner').Subject.should.equal('owner'));
24
+ it('should use the current event store and namespace', () => {
25
+ const { EventStore, Namespace } = context.requestFor('owner');
26
+ ({ EventStore, Namespace }).should.deep.equal({ EventStore: 'store', Namespace: 'tenant' });
27
+ });
28
+ it('should send only data properties', () => JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ id: 'account', name: 'encrypted name' }));
29
+ it('should preserve compliance metadata in the relevant schema', () => {
30
+ const schema = JsonSchemaGenerator.generate(AccountDocument);
31
+ JSON.parse(context.requestFor('owner').Schema).should.deep.equal({
32
+ ...schema,
33
+ properties: { id: schema.properties!.id, name: schema.properties!.name },
34
+ required: ['id', 'name']
35
+ });
36
+ });
37
+ it('should return the released values', () => result.should.deep.equal({ id: 'account', name: 'released name' }));
38
+ it('should return a plain document rather than a model instance', () => Object.getPrototypeOf(result).should.equal(Object.prototype));
39
+ });
@@ -0,0 +1,28 @@
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 frozen document', () => {
10
+ let document: Readonly<Record<string, unknown>>;
11
+ let result: Record<string, unknown>;
12
+ let original: string;
13
+
14
+ beforeEach(async () => {
15
+ const context = new a_stored_read_model();
16
+ document = Object.freeze({
17
+ name: 'encrypted name', contact: Object.freeze({ phone: 'unchanged phone' }),
18
+ __subjects: Object.freeze({ name: 'owner' })
19
+ });
20
+ original = JSON.stringify(document);
21
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: '{"name":"released name"}' });
22
+ result = await context.readModels.releaseDocument(AccountDocument, document);
23
+ });
24
+
25
+ it('should not mutate the document or its metadata', () => JSON.stringify(document).should.equal(original));
26
+ it('should release into a separate document', () => result.should.deep.equal({ name: 'released name', contact: { phone: 'unchanged phone' } }));
27
+ it('should copy even the subtrees that are not released', () => result.contact!.should.not.equal(document.contact));
28
+ });
@@ -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
+ const should = chai.should();
8
+
9
+ describe.each(['private@example.test', '', 'null', '[]', '"private@example.test"', '42'])('when releasing a document with malformed response %s', payload => {
10
+ let result: Record<string, unknown> | undefined;
11
+ let error: Error;
12
+
13
+ beforeEach(async () => {
14
+ const context = new a_stored_read_model();
15
+ result = undefined;
16
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: payload });
17
+ await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', __subject: 'owner' }).then(
18
+ released => { result = released; }, caught => { error = caught as Error; }
19
+ );
20
+ });
21
+
22
+ it('should reject without returning data', () => should.not.exist(result));
23
+ it('should sanitize parsing failures', () => error.message.should.equal('Failed to release PII in stored read model document.'));
24
+ });
@@ -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
+ const should = chai.should();
8
+
9
+ describe.each(['kernel', 'transport'])('when releasing a document and a later subject reports a %s failure', failure => {
10
+ let context: a_stored_read_model;
11
+ let result: Record<string, unknown> | undefined;
12
+ let error: Error;
13
+ let document: Record<string, unknown>;
14
+ let original: string;
15
+
16
+ beforeEach(async () => {
17
+ context = new a_stored_read_model();
18
+ result = undefined;
19
+ document = { name: 'encrypted name', email: 'encrypted email', __subjects: { name: 'owner', email: 'other-owner' } };
20
+ original = JSON.stringify(document);
21
+ context.release.mockResolvedValueOnce({ HasError: false, Error: '', Payload: '{"name":"released name"}' });
22
+ if (failure === 'kernel') {
23
+ context.release.mockResolvedValueOnce({ HasError: true, Error: 'PII private@example.test', Payload: '{"email":"private@example.test"}' });
24
+ } else {
25
+ context.release.mockRejectedValueOnce(new Error('Transport details: private@example.test'));
26
+ }
27
+ await context.readModels.releaseDocument(AccountDocument, document).then(
28
+ released => { result = released; }, caught => { error = caught as Error; }
29
+ );
30
+ });
31
+
32
+ it('should have attempted both subjects', () => context.release.mock.calls.should.have.lengthOf(2));
33
+ it('should reject the whole operation', () => error.should.be.instanceOf(Error));
34
+ it('should never expose the earlier released group', () => should.not.exist(result));
35
+ it('should leave the input unchanged even after a partial release', () => JSON.stringify(document).should.equal(original));
36
+ it('should not expose PII from errors or payloads', () => error.message.should.equal('Failed to release PII in stored read model document.'));
37
+ it('should not attach an unsafe cause', () => should.not.exist(error.cause));
38
+ });
@@ -0,0 +1,19 @@
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 and the kernel reports success with an empty value', () => {
10
+ let result: Record<string, unknown>;
11
+
12
+ beforeEach(async () => {
13
+ const context = new a_stored_read_model();
14
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: '{"name":""}' });
15
+ result = await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', __subject: 'owner' });
16
+ });
17
+
18
+ it('should accept the reported success without guessing whether decryption failed', () => result.should.deep.equal({ name: '' }));
19
+ });
@@ -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
+ });