diff --git a/Documentation/client-snippets/compliance/client/combining.md b/Documentation/client-snippets/compliance/client/combining.md new file mode 100644 index 0000000..17967e2 --- /dev/null +++ b/Documentation/client-snippets/compliance/client/combining.md @@ -0,0 +1,19 @@ +```typescript +import { eventType, pii } from '@cratis/chronicle'; +import { ConceptAs } from '@cratis/fundamentals'; + +@pii() +class ComplianceClientEmailAddress extends ConceptAs { + constructor(value: string) { + super(value); + } +} + +@eventType() +class ComplianceClientCustomerRegistered { + name: ComplianceClientPersonName = new ComplianceClientPersonName(''); // encrypted via concept type + email: ComplianceClientEmailAddress = new ComplianceClientEmailAddress(''); // encrypted via concept type + @pii() phoneNumber = ''; // encrypted via property annotation + country = ''; // plaintext +} +``` diff --git a/Documentation/client-snippets/compliance/client/concept-usage.md b/Documentation/client-snippets/compliance/client/concept-usage.md new file mode 100644 index 0000000..21934ae --- /dev/null +++ b/Documentation/client-snippets/compliance/client/concept-usage.md @@ -0,0 +1,13 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +@eventType() +class ComplianceClientEmployeeRegisteredWithConcept { + constructor(readonly name: ComplianceClientPersonName, readonly department: string) {} +} + +@eventType() +class ComplianceClientEmployeeNameChanged { + constructor(readonly newName: ComplianceClientPersonName) {} // also encrypted +} +``` diff --git a/Documentation/client-snippets/compliance/client/event-source-id-restriction.md b/Documentation/client-snippets/compliance/client/event-source-id-restriction.md new file mode 100644 index 0000000..52f40bc --- /dev/null +++ b/Documentation/client-snippets/compliance/client/event-source-id-restriction.md @@ -0,0 +1,11 @@ +```typescript +import { pii } from '@cratis/chronicle'; + +// TypeScript represents the event source identifier as the conventional 'eventSourceId' +// property rather than a dedicated EventSourceId type. Marking it @pii() throws +// PIINotSupportedOnEventSourceId - event source identifiers are required for key lookup and +// cannot be encrypted. +class ComplianceClientCustomerId { + @pii() eventSourceId = ''; +} +``` diff --git a/Documentation/client-snippets/compliance/client/registering-compliance.md b/Documentation/client-snippets/compliance/client/registering-compliance.md new file mode 100644 index 0000000..8cf9e28 --- /dev/null +++ b/Documentation/client-snippets/compliance/client/registering-compliance.md @@ -0,0 +1,10 @@ +```typescript +import { ChronicleClient } from '@cratis/chronicle'; + +// TypeScript has no DI-container registration step for compliance support - the PII manager +// is available automatically as soon as you have an event store, with no separate wiring. +async function getPIIManager(chronicleClient: ChronicleClient) { + const eventStore = await chronicleClient.getEventStore('Sales'); + return eventStore.pii; +} +``` diff --git a/Documentation/client-snippets/compliance/erasure/allow-new-key.md b/Documentation/client-snippets/compliance/erasure/allow-new-key.md new file mode 100644 index 0000000..4e00de3 --- /dev/null +++ b/Documentation/client-snippets/compliance/erasure/allow-new-key.md @@ -0,0 +1,8 @@ +```typescript +import { ChronicleClient } from '@cratis/chronicle'; + +async function allowNewEncryptionKeyForPerson(chronicleClient: ChronicleClient): Promise { + const eventStore = await chronicleClient.getEventStore('Sales'); + await eventStore.pii.allowNewEncryptionKeyFor('person-42'); +} +``` diff --git a/Documentation/client-snippets/compliance/erasure/delete-key.md b/Documentation/client-snippets/compliance/erasure/delete-key.md new file mode 100644 index 0000000..da0526e --- /dev/null +++ b/Documentation/client-snippets/compliance/erasure/delete-key.md @@ -0,0 +1,8 @@ +```typescript +import { ChronicleClient } from '@cratis/chronicle'; + +async function deletePersonEncryptionKey(chronicleClient: ChronicleClient): Promise { + const eventStore = await chronicleClient.getEventStore('Sales'); + await eventStore.pii.deleteEncryptionKey('person-42'); +} +``` diff --git a/Documentation/client-snippets/compliance/pii-with-concepts/event-source-id-restriction.md b/Documentation/client-snippets/compliance/pii-with-concepts/event-source-id-restriction.md new file mode 100644 index 0000000..a7609d5 --- /dev/null +++ b/Documentation/client-snippets/compliance/pii-with-concepts/event-source-id-restriction.md @@ -0,0 +1,11 @@ +```typescript +import { pii } from '@cratis/chronicle'; + +// TypeScript has no dedicated EventSourceId type to mark PII on directly - the event +// source identifier is always the conventional 'eventSourceId' property, and marking it +// @pii() throws PIINotSupportedOnEventSourceId for the same reason C# forbids [PII] on a +// concept deriving from EventSourceId. +class PiiConceptsEmployeeId { + @pii() eventSourceId = ''; +} +``` diff --git a/Documentation/client-snippets/compliance/pii-with-concepts/surrogate-key.md b/Documentation/client-snippets/compliance/pii-with-concepts/surrogate-key.md new file mode 100644 index 0000000..da2540b --- /dev/null +++ b/Documentation/client-snippets/compliance/pii-with-concepts/surrogate-key.md @@ -0,0 +1,16 @@ +```typescript +import { eventType, Guid } from '@cratis/chronicle'; + +// ✅ Surrogate key as event source identifier - TypeScript event source identifiers are +// plain strings, so a randomly generated Guid works well with no dedicated identity type +// required. +function createSurrogateEmployeeId(): string { + return Guid.create().toString(); +} + +// ✅ Sensitive values stored in PII-marked concept properties instead +@eventType() +class PiiConceptsSurrogateEmployeeRegistered { + constructor(readonly nationalId: PiiConceptsNationalIdNumber, readonly name: PiiConceptsPersonName) {} +} +``` diff --git a/Documentation/client-snippets/compliance/pii-with-concepts/why-concept-level.md b/Documentation/client-snippets/compliance/pii-with-concepts/why-concept-level.md new file mode 100644 index 0000000..b23fe29 --- /dev/null +++ b/Documentation/client-snippets/compliance/pii-with-concepts/why-concept-level.md @@ -0,0 +1,35 @@ +```typescript +import { eventType, pii } from '@cratis/chronicle'; +import { ConceptAs } from '@cratis/fundamentals'; + +// ❌ Property-level: requires repetition across every event +@eventType() +class PiiConceptsComparisonEmployeeRegistered { + @pii() name = ''; + department = ''; +} + +@eventType() +class PiiConceptsComparisonEmployeeNameChanged { + @pii() newName = ''; // must remember @pii() again +} + +// ✅ Concept-level: declare once, apply everywhere automatically +@pii() +class PiiConceptsComparisonPersonName extends ConceptAs { + constructor(value: string) { + super(value); + } +} + +@eventType() +class PiiConceptsComparisonEmployeeRegisteredGood { + name: PiiConceptsComparisonPersonName = new PiiConceptsComparisonPersonName(''); // encrypted + department = ''; +} + +@eventType() +class PiiConceptsComparisonEmployeeNameChangedGood { + newName: PiiConceptsComparisonPersonName = new PiiConceptsComparisonPersonName(''); // also encrypted, no extra annotation needed +} +``` diff --git a/Documentation/client-snippets/compliance/pii/event-source-id-restriction.md b/Documentation/client-snippets/compliance/pii/event-source-id-restriction.md new file mode 100644 index 0000000..6796ead --- /dev/null +++ b/Documentation/client-snippets/compliance/pii/event-source-id-restriction.md @@ -0,0 +1,11 @@ +```typescript +import { pii } from '@cratis/chronicle'; + +// TypeScript has no dedicated EventSourceId type - the event source identifier is always +// the conventional 'eventSourceId' property. Marking it @pii() throws +// PIINotSupportedOnEventSourceId at decoration time, for the same reason C# forbids [PII] on +// EventSourceId: encrypting it would make its own decryption key unfindable. +class PiiAttrEmployeeId { + @pii() eventSourceId = ''; +} +``` diff --git a/Documentation/client-snippets/compliance/pii/import.md b/Documentation/client-snippets/compliance/pii/import.md new file mode 100644 index 0000000..d8ba6af --- /dev/null +++ b/Documentation/client-snippets/compliance/pii/import.md @@ -0,0 +1,3 @@ +```typescript +import { pii } from '@cratis/chronicle'; +``` diff --git a/Documentation/client-snippets/compliance/pii/nested-value-object.md b/Documentation/client-snippets/compliance/pii/nested-value-object.md new file mode 100644 index 0000000..4298f36 --- /dev/null +++ b/Documentation/client-snippets/compliance/pii/nested-value-object.md @@ -0,0 +1,23 @@ +```typescript +import { pii } from '@cratis/chronicle'; +import { ConceptAs } from '@cratis/fundamentals'; + +@pii() +class PiiAttrDateOfBirth extends ConceptAs { + constructor(value: string) { + super(value); + } +} + +// The concept sits one level down, inside a value object. +class PiiAttrVerifiedDateOfBirth { + dateOfBirth: PiiAttrDateOfBirth = new PiiAttrDateOfBirth(''); + verifiedBy = ''; +} + +// Chronicle still finds it: dateOfBirth.dateOfBirth is encrypted, verifiedBy is not. +class PiiAttrExpressVerification { + name = ''; + dateOfBirth: PiiAttrVerifiedDateOfBirth = new PiiAttrVerifiedDateOfBirth(); +} +``` diff --git a/Documentation/client-snippets/compliance/pii/value-object-class.md b/Documentation/client-snippets/compliance/pii/value-object-class.md new file mode 100644 index 0000000..c559178 --- /dev/null +++ b/Documentation/client-snippets/compliance/pii/value-object-class.md @@ -0,0 +1,16 @@ +```typescript +import { pii } from '@cratis/chronicle'; + +// Every value this type holds is personal, so mark the type once. +@pii() +class PiiAttrDiagnosis { + condition = ''; + diagnosedBy = ''; +} + +// Both condition and diagnosedBy are encrypted wherever a PiiAttrDiagnosis appears. +class PiiAttrPatientRecord { + name = ''; + diagnosis: PiiAttrDiagnosis = new PiiAttrDiagnosis(); +} +``` diff --git a/Documentation/client-snippets/compliance/read-models/projection-lineage.md b/Documentation/client-snippets/compliance/read-models/projection-lineage.md new file mode 100644 index 0000000..77e20b6 --- /dev/null +++ b/Documentation/client-snippets/compliance/read-models/projection-lineage.md @@ -0,0 +1,26 @@ +```typescript +import { eventType, fromEvent, pii, readModel } from '@cratis/chronicle'; +import { ConceptAs } from '@cratis/fundamentals'; + +@pii() +class ComplianceReadModelsPersonName extends ConceptAs { + constructor(value: string) { + super(value); + } +} + +@eventType() +class ComplianceReadModelsEmployeeRegistered { + constructor(readonly name: ComplianceReadModelsPersonName, readonly department: string) {} +} + +// Chronicle's projection pipeline carries PII lineage automatically from the source event +// property into the read model - no @pii() is needed here even though `name` is a plain +// string. It is still encrypted at rest because it came from a PII-marked event property. +@readModel() +@fromEvent(ComplianceReadModelsEmployeeRegistered) +class ComplianceReadModelsEmployee { + name = ''; + department = ''; +} +``` diff --git a/Documentation/client-snippets/compliance/read-models/querying.md b/Documentation/client-snippets/compliance/read-models/querying.md new file mode 100644 index 0000000..181d96b --- /dev/null +++ b/Documentation/client-snippets/compliance/read-models/querying.md @@ -0,0 +1,11 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ComplianceReadModelsEmployeeService { + constructor(private readonly eventStore: IEventStore) {} + + getEmployee(id: string): Promise { + return this.eventStore.readModels.getInstanceById(ComplianceReadModelsEmployee, id); + } +} +``` diff --git a/Documentation/client-snippets/compliance/read-models/reducer-explicit-pii.md b/Documentation/client-snippets/compliance/read-models/reducer-explicit-pii.md new file mode 100644 index 0000000..1487c56 --- /dev/null +++ b/Documentation/client-snippets/compliance/read-models/reducer-explicit-pii.md @@ -0,0 +1,25 @@ +```typescript +import { eventType, pii, reducer } from '@cratis/chronicle'; + +@eventType() +class ComplianceReadModelsPatientAdmitted { + constructor(readonly name: string, readonly admittedAt: Date) {} +} + +// Reducer-backed read models do not inherit PII lineage from the source event automatically - +// mark the property explicitly. +class ComplianceReadModelsPatientSummary { + @pii() name = ''; + lastAdmittedAt = new Date(); +} + +@reducer('PatientSummaryReducer', undefined, ComplianceReadModelsPatientSummary) +class ComplianceReadModelsPatientSummaryReducer { + async patientAdmitted( + event: ComplianceReadModelsPatientAdmitted, + current?: ComplianceReadModelsPatientSummary + ): Promise { + return { name: event.name, lastAdmittedAt: event.admittedAt }; + } +} +``` diff --git a/Documentation/client-snippets/concepts/correlation-identity-causation/renaming-an-identity.md b/Documentation/client-snippets/concepts/correlation-identity-causation/renaming-an-identity.md new file mode 100644 index 0000000..0d8dac2 --- /dev/null +++ b/Documentation/client-snippets/concepts/correlation-identity-causation/renaming-an-identity.md @@ -0,0 +1,7 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +async function renameAnIdentity(eventStore: IEventStore): Promise { + await eventStore.identities.rename('subject-42', 'Jane Austen'); +} +``` diff --git a/Documentation/client-snippets/concepts/designing-read-models/constructor-may-not-run.md b/Documentation/client-snippets/concepts/designing-read-models/constructor-may-not-run.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/concepts/designing-read-models/constructor-may-not-run.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/concepts/event-type-migrations/map-values.md b/Documentation/client-snippets/concepts/event-type-migrations/map-values.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/concepts/event-type-migrations/map-values.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/concepts/geospatial/events-and-read-models.md b/Documentation/client-snippets/concepts/geospatial/events-and-read-models.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/concepts/geospatial/events-and-read-models.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/concepts/geospatial/projection.md b/Documentation/client-snippets/concepts/geospatial/projection.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/concepts/geospatial/projection.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/concepts/geospatial/types.md b/Documentation/client-snippets/concepts/geospatial/types.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/concepts/geospatial/types.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/concepts/subject/implicit-with-attribute.md b/Documentation/client-snippets/concepts/subject/implicit-with-attribute.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/concepts/subject/implicit-with-attribute.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/concepts/tagging-reactors/mixed-approach.md b/Documentation/client-snippets/concepts/tagging-reactors/mixed-approach.md new file mode 100644 index 0000000..a8886c2 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging-reactors/mixed-approach.md @@ -0,0 +1,23 @@ +```typescript +import { EventContext, eventType, reactor, tag } from '@cratis/chronicle'; + +@eventType() +class TaggingReactorsOrderShipped { + constructor(readonly phoneNumber: string, readonly trackingNumber: string) {} +} + +interface TaggingReactorsSmsService { + sendShippingNotification(phoneNumber: string, trackingNumber: string): Promise; +} + +@reactor() +@tag('Notifications', 'SMS') +@tag('Customer') +class TaggingReactorsSmsNotificationReactor { + constructor(private readonly smsService: TaggingReactorsSmsService) {} + + async taggingReactorsOrderShipped(event: TaggingReactorsOrderShipped, _context: EventContext): Promise { + await this.smsService.sendShippingNotification(event.phoneNumber, event.trackingNumber); + } +} +``` diff --git a/Documentation/client-snippets/concepts/tagging-reactors/multiple-tags-multiple-attributes.md b/Documentation/client-snippets/concepts/tagging-reactors/multiple-tags-multiple-attributes.md new file mode 100644 index 0000000..0b7535f --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging-reactors/multiple-tags-multiple-attributes.md @@ -0,0 +1,24 @@ +```typescript +import { EventContext, eventType, reactor, tag } from '@cratis/chronicle'; + +@eventType() +class TaggingReactorsProductStockChanged { + constructor(readonly productId: string, readonly newQuantity: number) {} +} + +interface TaggingReactorsInventoryApi { + updateStock(productId: string, newQuantity: number): Promise; +} + +@reactor() +@tag('Integration') +@tag('ExternalAPI') +@tag('Inventory') +class TaggingReactorsInventorySyncReactor { + constructor(private readonly inventoryApi: TaggingReactorsInventoryApi) {} + + async taggingReactorsProductStockChanged(event: TaggingReactorsProductStockChanged, _context: EventContext): Promise { + await this.inventoryApi.updateStock(event.productId, event.newQuantity); + } +} +``` diff --git a/Documentation/client-snippets/concepts/tagging-reactors/multiple-tags-single-attribute.md b/Documentation/client-snippets/concepts/tagging-reactors/multiple-tags-single-attribute.md new file mode 100644 index 0000000..294f053 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging-reactors/multiple-tags-single-attribute.md @@ -0,0 +1,22 @@ +```typescript +import { EventContext, eventType, reactor, tag } from '@cratis/chronicle'; + +@eventType() +class TaggingReactorsCustomerRegistered { + constructor(readonly email: string, readonly name: string) {} +} + +interface TaggingReactorsWelcomeEmailService { + sendWelcomeEmail(email: string, name: string): Promise; +} + +@reactor() +@tag('Notifications', 'Customer', 'Email') +class TaggingReactorsCustomerNotificationReactor { + constructor(private readonly emailService: TaggingReactorsWelcomeEmailService) {} + + async taggingReactorsCustomerRegistered(event: TaggingReactorsCustomerRegistered, _context: EventContext): Promise { + await this.emailService.sendWelcomeEmail(event.email, event.name); + } +} +``` diff --git a/Documentation/client-snippets/concepts/tagging-reactors/single-tag.md b/Documentation/client-snippets/concepts/tagging-reactors/single-tag.md new file mode 100644 index 0000000..2fec910 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging-reactors/single-tag.md @@ -0,0 +1,22 @@ +```typescript +import { EventContext, eventType, reactor, tag } from '@cratis/chronicle'; + +@eventType() +class TaggingReactorsOrderPlaced { + constructor(readonly customerId: string, readonly orderId: string) {} +} + +interface TaggingReactorsEmailService { + sendOrderConfirmation(customerId: string, orderId: string): Promise; +} + +@reactor() +@tag('Notifications') +class TaggingReactorsOrderConfirmationReactor { + constructor(private readonly emailService: TaggingReactorsEmailService) {} + + async taggingReactorsOrderPlaced(event: TaggingReactorsOrderPlaced, _context: EventContext): Promise { + await this.emailService.sendOrderConfirmation(event.customerId, event.orderId); + } +} +``` diff --git a/Documentation/client-snippets/concepts/tagging-reactors/tag-categories.md b/Documentation/client-snippets/concepts/tagging-reactors/tag-categories.md new file mode 100644 index 0000000..076797c --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging-reactors/tag-categories.md @@ -0,0 +1,16 @@ +```typescript +import { reactor, tag } from '@cratis/chronicle'; + +@reactor() +// By integration type +@tag('Notifications', 'ExternalAPI', 'MessageQueue', 'FileSystem') +// By domain +@tag('Sales', 'Inventory', 'Customer', 'Shipping') +// By communication channel +@tag('Email', 'SMS', 'Push', 'Webhook') +// By purpose +@tag('Integration', 'Alerting', 'Monitoring', 'Automation') +// By stakeholder +@tag('Customer', 'Operations', 'Finance', 'Support') +class TaggingReactorsCategoryExamplesReactor {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/by-communication-channel.md b/Documentation/client-snippets/concepts/tagging/by-communication-channel.md new file mode 100644 index 0000000..abae036 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/by-communication-channel.md @@ -0,0 +1,9 @@ +```typescript +import { tag } from '@cratis/chronicle'; + +@tag('Email') +@tag('SMS') +@tag('Push') +@tag('Webhook') +class TaggingByCommunicationChannelExample {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/by-domain.md b/Documentation/client-snippets/concepts/tagging/by-domain.md new file mode 100644 index 0000000..4855b58 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/by-domain.md @@ -0,0 +1,9 @@ +```typescript +import { tag } from '@cratis/chronicle'; + +@tag('Sales') +@tag('Inventory') +@tag('Customer') +@tag('Shipping') +class TaggingByDomainExample {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/by-integration-type.md b/Documentation/client-snippets/concepts/tagging/by-integration-type.md new file mode 100644 index 0000000..cde51f7 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/by-integration-type.md @@ -0,0 +1,9 @@ +```typescript +import { tag } from '@cratis/chronicle'; + +@tag('Notifications') +@tag('ExternalAPI') +@tag('MessageQueue') +@tag('FileSystem') +class TaggingByIntegrationTypeExample {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/by-purpose.md b/Documentation/client-snippets/concepts/tagging/by-purpose.md new file mode 100644 index 0000000..c8b1738 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/by-purpose.md @@ -0,0 +1,11 @@ +```typescript +import { tag } from '@cratis/chronicle'; + +@tag('Analytics') +@tag('Reporting') +@tag('Integration') +@tag('Alerting') +@tag('Monitoring') +@tag('Automation') +class TaggingByPurposeExample {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/by-stakeholder.md b/Documentation/client-snippets/concepts/tagging/by-stakeholder.md new file mode 100644 index 0000000..1eb3d97 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/by-stakeholder.md @@ -0,0 +1,10 @@ +```typescript +import { tag } from '@cratis/chronicle'; + +@tag('Customer') +@tag('Operations') +@tag('Finance') +@tag('Support') +@tag('Executive') +class TaggingByStakeholderExample {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/dynamic-event-tags.md b/Documentation/client-snippets/concepts/tagging/dynamic-event-tags.md index 19b3207..8bbdd18 100644 --- a/Documentation/client-snippets/concepts/tagging/dynamic-event-tags.md +++ b/Documentation/client-snippets/concepts/tagging/dynamic-event-tags.md @@ -1,3 +1,15 @@ -```text -TypeScript does not support this workflow yet. +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class TaggingUserLoginService { + constructor(private readonly store: IEventStore) {} + + // The event will end up with four tags: ['analytics', 'user-action', 'production', 'critical'] + async recordLogin(eventSourceId: string): Promise { + await this.store.eventLog.append( + eventSourceId, + new TaggingUserLoggedIn('user123', new Date()), + { tags: ['production', 'critical'] }); + } +} ``` diff --git a/Documentation/client-snippets/concepts/tagging/dynamic-tags-patterns.md b/Documentation/client-snippets/concepts/tagging/dynamic-tags-patterns.md new file mode 100644 index 0000000..2511a5b --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/dynamic-tags-patterns.md @@ -0,0 +1,33 @@ +```typescript +import { eventType, IEventStore } from '@cratis/chronicle'; + +@eventType() +class TaggingDynamicTagsEventOccurred { + constructor(readonly data: string) {} +} + +class TaggingDynamicTagsService { + constructor(private readonly store: IEventStore) {} + + async recordProductionCritical(eventSourceId: string): Promise { + await this.store.eventLog.append( + eventSourceId, + new TaggingDynamicTagsEventOccurred('production issue'), + { tags: ['production', 'critical'] }); + } + + async recordDevelopmentTest(eventSourceId: string): Promise { + await this.store.eventLog.append( + eventSourceId, + new TaggingDynamicTagsEventOccurred('test run'), + { tags: ['development', 'testing'] }); + } + + async recordBatchMigration(eventSourceId: string): Promise { + await this.store.eventLog.append( + eventSourceId, + new TaggingDynamicTagsEventOccurred('batch migration'), + { tags: ['migration', 'batch-process'] }); + } +} +``` diff --git a/Documentation/client-snippets/concepts/tagging/observer-mixed-approach.md b/Documentation/client-snippets/concepts/tagging/observer-mixed-approach.md new file mode 100644 index 0000000..5a510a9 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/observer-mixed-approach.md @@ -0,0 +1,14 @@ +```typescript +import { reactor, tag, tags } from '@cratis/chronicle'; + +@reactor() +@tag('Notifications', 'SMS') +@tags('Customer') +class TaggingSmsNotificationReactor {} + +// Or mix single and multiple attributes the other way around +@reactor() +@tag('Integration') +@tags('ExternalAPI', 'Inventory') +class TaggingInventorySyncReactorMixed {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/observer-multiple-tags-multiple-attributes.md b/Documentation/client-snippets/concepts/tagging/observer-multiple-tags-multiple-attributes.md new file mode 100644 index 0000000..6e1fcf9 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/observer-multiple-tags-multiple-attributes.md @@ -0,0 +1,9 @@ +```typescript +import { reactor, tag } from '@cratis/chronicle'; + +@reactor() +@tag('Integration') +@tag('ExternalAPI') +@tag('Inventory') +class TaggingInventorySyncReactor {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/observer-multiple-tags-single-attribute.md b/Documentation/client-snippets/concepts/tagging/observer-multiple-tags-single-attribute.md new file mode 100644 index 0000000..9a62cfb --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/observer-multiple-tags-single-attribute.md @@ -0,0 +1,12 @@ +```typescript +import { reactor, tag, tags } from '@cratis/chronicle'; + +@reactor() +@tag('Notifications', 'Customer', 'Email') +class TaggingCustomerNotificationReactor {} + +// @tags() (plural) is equivalent — use whichever reads more naturally +@reactor() +@tags('Notifications', 'Customer', 'Email') +class TaggingCustomerNotificationReactorAlternate {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/observer-single-tag.md b/Documentation/client-snippets/concepts/tagging/observer-single-tag.md new file mode 100644 index 0000000..66be54c --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/observer-single-tag.md @@ -0,0 +1,7 @@ +```typescript +import { reactor, tag } from '@cratis/chronicle'; + +@reactor() +@tag('Notifications') +class TaggingOrderConfirmationReactor {} +``` diff --git a/Documentation/client-snippets/concepts/tagging/read-model-tags.md b/Documentation/client-snippets/concepts/tagging/read-model-tags.md new file mode 100644 index 0000000..131a6d3 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/read-model-tags.md @@ -0,0 +1,8 @@ +```typescript +import { tag } from '@cratis/chronicle'; + +@tag('Reporting', 'Analytics') +class TaggingConceptsSalesReport { + constructor(readonly totalSales: number, readonly orderCount: number) {} +} +``` diff --git a/Documentation/client-snippets/concepts/tagging/static-event-tags.md b/Documentation/client-snippets/concepts/tagging/static-event-tags.md new file mode 100644 index 0000000..bdfc140 --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/static-event-tags.md @@ -0,0 +1,24 @@ +```typescript +import { eventType, tag, tags } from '@cratis/chronicle'; + +@eventType() +@tag('analytics', 'user-action') +class TaggingUserLoggedIn { + constructor(readonly userId: string, readonly loggedInAt: Date) {} +} + +// @tags() (plural) is equivalent to @tag() — use whichever reads more naturally +@eventType() +@tags('analytics', 'user-action') +class TaggingUserLoggedInAlternate { + constructor(readonly userId: string, readonly loggedInAt: Date) {} +} + +// Mixing @tag() and @tags() on the same type merges all the tags +@eventType() +@tag('security') +@tags('audit') +class TaggingUserPasswordChanged { + constructor(readonly userId: string, readonly changedAt: Date) {} +} +``` diff --git a/Documentation/client-snippets/concepts/tagging/tags-in-event-context.md b/Documentation/client-snippets/concepts/tagging/tags-in-event-context.md new file mode 100644 index 0000000..00aabfa --- /dev/null +++ b/Documentation/client-snippets/concepts/tagging/tags-in-event-context.md @@ -0,0 +1,27 @@ +```typescript +import { EventContext, reducer } from '@cratis/chronicle'; + +class TaggingUserAnalytics { + loginCount = 0; + criticalLoginCount = 0; +} + +@reducer('', undefined, TaggingUserAnalytics) +class TaggingUserAnalyticsReducer { + taggingUserLoggedIn( + event: TaggingUserLoggedIn, + current: TaggingUserAnalytics | undefined, + context: EventContext + ): TaggingUserAnalytics { + const analytics = current ?? new TaggingUserAnalytics(); + + // Access tags from the event context + const isCritical = context.tags.some(tag => tag.value === 'critical'); + + return { + loginCount: analytics.loginCount + 1, + criticalLoginCount: analytics.criticalLoginCount + (isCritical ? 1 : 0) + }; + } +} +``` diff --git a/Documentation/client-snippets/configuration/camel-casing/aspnetcore-basic.md b/Documentation/client-snippets/configuration/camel-casing/aspnetcore-basic.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/camel-casing/aspnetcore-basic.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/camel-casing/aspnetcore-with-options.md b/Documentation/client-snippets/configuration/camel-casing/aspnetcore-with-options.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/camel-casing/aspnetcore-with-options.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/camel-casing/direct-client.md b/Documentation/client-snippets/configuration/camel-casing/direct-client.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/camel-casing/direct-client.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/camel-casing/event.md b/Documentation/client-snippets/configuration/camel-casing/event.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/camel-casing/event.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/camel-casing/projection.md b/Documentation/client-snippets/configuration/camel-casing/projection.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/camel-casing/projection.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/camel-casing/read-model.md b/Documentation/client-snippets/configuration/camel-casing/read-model.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/camel-casing/read-model.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/camel-casing/worker-host.md b/Documentation/client-snippets/configuration/camel-casing/worker-host.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/camel-casing/worker-host.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/chronicle-options/connection-string.md b/Documentation/client-snippets/configuration/chronicle-options/connection-string.md new file mode 100644 index 0000000..c6d0420 --- /dev/null +++ b/Documentation/client-snippets/configuration/chronicle-options/connection-string.md @@ -0,0 +1,7 @@ +```typescript +import { ChronicleOptions } from '@cratis/chronicle'; + +function createChronicleOptionsConnectionString(): ChronicleOptions { + return ChronicleOptions.fromConnectionString('chronicle://myserver:35000'); +} +``` diff --git a/Documentation/client-snippets/configuration/chronicle-options/enable-validation.md b/Documentation/client-snippets/configuration/chronicle-options/enable-validation.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/chronicle-options/enable-validation.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/chronicle-options/sql-sink.md b/Documentation/client-snippets/configuration/chronicle-options/sql-sink.md new file mode 100644 index 0000000..f467d05 --- /dev/null +++ b/Documentation/client-snippets/configuration/chronicle-options/sql-sink.md @@ -0,0 +1,9 @@ +```typescript +import { ChronicleOptions, WellKnownSinks } from '@cratis/chronicle'; + +function createChronicleOptionsSqlSink(): ChronicleOptions { + return ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { + defaultSinkTypeId: WellKnownSinks.SQL + }); +} +``` diff --git a/Documentation/client-snippets/configuration/grpc-message-size/configure.md b/Documentation/client-snippets/configuration/grpc-message-size/configure.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/grpc-message-size/configure.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/grpc-message-size/null-values.md b/Documentation/client-snippets/configuration/grpc-message-size/null-values.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/grpc-message-size/null-values.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/index/register.md b/Documentation/client-snippets/configuration/index/register.md new file mode 100644 index 0000000..caf9ea5 --- /dev/null +++ b/Documentation/client-snippets/configuration/index/register.md @@ -0,0 +1,8 @@ +```typescript +import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle'; + +async function runConfigurationIndexRegistrationExample(): Promise { + const client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000')); + const eventStore = await client.getEventStore('my-store'); +} +``` diff --git a/Documentation/client-snippets/configuration/structural-dependencies/aspnetcore-builder.md b/Documentation/client-snippets/configuration/structural-dependencies/aspnetcore-builder.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/structural-dependencies/aspnetcore-builder.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/structural-dependencies/chronicle-builder.md b/Documentation/client-snippets/configuration/structural-dependencies/chronicle-builder.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/structural-dependencies/chronicle-builder.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/structural-dependencies/custom-artifacts-provider-usage.md b/Documentation/client-snippets/configuration/structural-dependencies/custom-artifacts-provider-usage.md new file mode 100644 index 0000000..9184bf8 --- /dev/null +++ b/Documentation/client-snippets/configuration/structural-dependencies/custom-artifacts-provider-usage.md @@ -0,0 +1,9 @@ +```typescript +import { ChronicleOptions } from '@cratis/chronicle'; + +function createStructuralDependenciesCustomArtifactsProviderOptions(): ChronicleOptions { + return ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { + clientArtifactsProvider: new StructuralDepsMyArtifactsProvider() + }); +} +``` diff --git a/Documentation/client-snippets/configuration/structural-dependencies/custom-artifacts-provider.md b/Documentation/client-snippets/configuration/structural-dependencies/custom-artifacts-provider.md new file mode 100644 index 0000000..ceac4fb --- /dev/null +++ b/Documentation/client-snippets/configuration/structural-dependencies/custom-artifacts-provider.md @@ -0,0 +1,38 @@ +```typescript +import { Constructor } from '@cratis/fundamentals'; +import { eventType, IClientArtifactsProvider, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class StructuralDepsBookBorrowed { + constructor(readonly bookId: string) {} +} + +@eventType() +class StructuralDepsBookReturned { + constructor(readonly bookId: string) {} +} + +class StructuralDepsBorrowedBook { + bookId = ''; +} + +@projection() +class StructuralDepsBorrowedBooksProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder.from(StructuralDepsBookBorrowed, _ => _ + .set(m => m.bookId).to(e => e.bookId)); + } +} + +class StructuralDepsMyArtifactsProvider implements IClientArtifactsProvider { + readonly eventTypes: Constructor[] = [StructuralDepsBookBorrowed, StructuralDepsBookReturned]; + readonly readModels: Constructor[] = []; + readonly reactors: Constructor[] = []; + readonly reducers: Constructor[] = []; + readonly seeders: Constructor[] = []; + readonly constraints: Constructor[] = []; + readonly projections: Constructor[] = [StructuralDepsBorrowedBooksProjection]; + readonly webhooks: Constructor[] = []; + readonly eventTypeMigrations: Constructor[] = []; +} +``` diff --git a/Documentation/client-snippets/configuration/structural-dependencies/default-artifacts-provider.md b/Documentation/client-snippets/configuration/structural-dependencies/default-artifacts-provider.md new file mode 100644 index 0000000..3a89b5c --- /dev/null +++ b/Documentation/client-snippets/configuration/structural-dependencies/default-artifacts-provider.md @@ -0,0 +1,10 @@ +```typescript +import { DefaultClientArtifactsProvider, TypeDiscoverer } from '@cratis/chronicle'; + +// TypeScript discovers artifacts by scanning files matching glob patterns rather than +// scanning loaded assemblies - TypeDiscoverer.default is backed by ChronicleOptions' +// discoveryPatterns. +function createStructuralDependenciesDefaultArtifactsProvider(): DefaultClientArtifactsProvider { + return new DefaultClientArtifactsProvider(TypeDiscoverer.default); +} +``` diff --git a/Documentation/client-snippets/configuration/structural-dependencies/direct-client.md b/Documentation/client-snippets/configuration/structural-dependencies/direct-client.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/structural-dependencies/direct-client.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/tls/client-options.md b/Documentation/client-snippets/configuration/tls/client-options.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/configuration/tls/client-options.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/configuration/tls/connection-string-skip-validation.md b/Documentation/client-snippets/configuration/tls/connection-string-skip-validation.md new file mode 100644 index 0000000..03d35d0 --- /dev/null +++ b/Documentation/client-snippets/configuration/tls/connection-string-skip-validation.md @@ -0,0 +1,7 @@ +```typescript +import { ChronicleOptions } from '@cratis/chronicle'; + +function createTlsConnectionStringSkipValidation(): ChronicleOptions { + return ChronicleOptions.fromConnectionString('chronicle://localhost:35000?skipTlsValidation=true'); +} +``` diff --git a/Documentation/client-snippets/configuration/tls/validation-enabled.md b/Documentation/client-snippets/configuration/tls/validation-enabled.md new file mode 100644 index 0000000..a0427b0 --- /dev/null +++ b/Documentation/client-snippets/configuration/tls/validation-enabled.md @@ -0,0 +1,7 @@ +```typescript +import { ChronicleOptions } from '@cratis/chronicle'; + +function createConfigurationTlsValidationEnabled(): ChronicleOptions { + return ChronicleOptions.fromConnectionString('chronicle://my-server:35000?skipTlsValidation=false'); +} +``` diff --git a/Documentation/client-snippets/connection-strings/configuration/register.md b/Documentation/client-snippets/connection-strings/configuration/register.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/connection-strings/configuration/register.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/connection-strings/dotnet-client/development-defaults-equivalent.md b/Documentation/client-snippets/connection-strings/dotnet-client/development-defaults-equivalent.md new file mode 100644 index 0000000..5be60eb --- /dev/null +++ b/Documentation/client-snippets/connection-strings/dotnet-client/development-defaults-equivalent.md @@ -0,0 +1,9 @@ +```typescript +import { ChronicleOptions } from '@cratis/chronicle'; + +function createConnectionStringsDevelopmentDefaultsEquivalent(): ChronicleOptions { + return ChronicleOptions.fromConnectionString( + 'chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000' + ); +} +``` diff --git a/Documentation/client-snippets/connection-strings/dotnet-client/development-defaults.md b/Documentation/client-snippets/connection-strings/dotnet-client/development-defaults.md new file mode 100644 index 0000000..a7a0227 --- /dev/null +++ b/Documentation/client-snippets/connection-strings/dotnet-client/development-defaults.md @@ -0,0 +1,9 @@ +```typescript +import { ChronicleOptions } from '@cratis/chronicle'; + +// ChronicleOptions.development() points at the local dev kernel on chronicle://localhost:35000 +// using the built-in development client credentials. +function createConnectionStringsDevelopmentDefaults(): ChronicleOptions { + return ChronicleOptions.development(); +} +``` diff --git a/Documentation/client-snippets/connection-strings/dotnet-client/fluent-builder.md b/Documentation/client-snippets/connection-strings/dotnet-client/fluent-builder.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/connection-strings/dotnet-client/fluent-builder.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/connection-strings/dotnet-client/from-connection-string.md b/Documentation/client-snippets/connection-strings/dotnet-client/from-connection-string.md new file mode 100644 index 0000000..c11c435 --- /dev/null +++ b/Documentation/client-snippets/connection-strings/dotnet-client/from-connection-string.md @@ -0,0 +1,7 @@ +```typescript +import { ChronicleOptions } from '@cratis/chronicle'; + +function createConnectionStringsFromConnectionString(): ChronicleOptions { + return ChronicleOptions.fromConnectionString('chronicle://myserver:35000'); +} +``` diff --git a/Documentation/client-snippets/connection-strings/dotnet-client/redacting-for-logs.md b/Documentation/client-snippets/connection-strings/dotnet-client/redacting-for-logs.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/connection-strings/dotnet-client/redacting-for-logs.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/constraints/declarative/unique-event-type/mutually-exclusive.md b/Documentation/client-snippets/constraints/declarative/unique-event-type/mutually-exclusive.md new file mode 100644 index 0000000..f9a788d --- /dev/null +++ b/Documentation/client-snippets/constraints/declarative/unique-event-type/mutually-exclusive.md @@ -0,0 +1,24 @@ +```typescript +import { constraint, eventType, IConstraint, IConstraintBuilder, Guid } from '@cratis/chronicle'; + +@eventType() +class ConstraintsPersonAliasedTo { + constructor(readonly target: Guid) {} +} + +@eventType() +class ConstraintsPersonErased { +} + +@constraint() +class ConstraintsPersonTerminalOutcome implements IConstraint { + // Both declarations share one constraint name, so they become a single constraint: + // at most one event drawn from { ConstraintsPersonAliasedTo, ConstraintsPersonErased } + // per person. A person merged away can no longer be erased, and neither event can + // occur twice. + define(builder: IConstraintBuilder): void { + builder.uniqueFor(ConstraintsPersonAliasedTo, 'This person already has a terminal outcome.', 'PersonTerminal'); + builder.uniqueFor(ConstraintsPersonErased, 'This person already has a terminal outcome.', 'PersonTerminal'); + } +} +``` diff --git a/Documentation/client-snippets/constraints/declarative/unique-event-type/releasing-several-events.md b/Documentation/client-snippets/constraints/declarative/unique-event-type/releasing-several-events.md new file mode 100644 index 0000000..5682fbc --- /dev/null +++ b/Documentation/client-snippets/constraints/declarative/unique-event-type/releasing-several-events.md @@ -0,0 +1,29 @@ +```typescript +import { constraint, eventType, IConstraint, IConstraintBuilder } from '@cratis/chronicle'; + +@eventType() +class ConstraintsUniqueEventTypeSeveralLoanCheckedOut { + constructor(readonly title: string) {} +} + +@eventType() +class ConstraintsUniqueEventTypeSeveralLoanReturned { +} + +@eventType() +class ConstraintsUniqueEventTypeSeveralLoanWrittenOff { +} + +@constraint() +class ConstraintsUniqueEventTypeSeveralOneOpenLoan implements IConstraint { + // A loan is open until it is returned or written off. Both end the cycle, so the + // borrower can take the next loan whichever way the previous one finished. + define(builder: IConstraintBuilder): void { + builder.unique(unique => + unique + .on(ConstraintsUniqueEventTypeSeveralLoanCheckedOut) + .removedWith(ConstraintsUniqueEventTypeSeveralLoanReturned) + .removedWith(ConstraintsUniqueEventTypeSeveralLoanWrittenOff)); + } +} +``` diff --git a/Documentation/client-snippets/constraints/declarative/unique-event-type/releasing.md b/Documentation/client-snippets/constraints/declarative/unique-event-type/releasing.md new file mode 100644 index 0000000..c805e3a --- /dev/null +++ b/Documentation/client-snippets/constraints/declarative/unique-event-type/releasing.md @@ -0,0 +1,25 @@ +```typescript +import { constraint, eventType, IConstraint, IConstraintBuilder } from '@cratis/chronicle'; + +@eventType() +class ConstraintsUniqueEventTypeShiftStarted { + constructor(readonly location: string) {} +} + +@eventType() +class ConstraintsUniqueEventTypeShiftEnded { +} + +@constraint() +class ConstraintsUniqueEventTypeOneOpenShift implements IConstraint { + // At most one open shift per employee. Ending the shift releases the constraint, + // so the next shift is allowed - without it the constraint could only say + // "at most one, ever", and the employee's second shift would be refused forever. + define(builder: IConstraintBuilder): void { + builder.unique(unique => + unique + .on(ConstraintsUniqueEventTypeShiftStarted) + .removedWith(ConstraintsUniqueEventTypeShiftEnded)); + } +} +``` diff --git a/Documentation/client-snippets/constraints/declarative/unique-event-type/violation-message-callback.md b/Documentation/client-snippets/constraints/declarative/unique-event-type/violation-message-callback.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/constraints/declarative/unique-event-type/violation-message-callback.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/constraints/declarative/unique/releasing-several-events.md b/Documentation/client-snippets/constraints/declarative/unique/releasing-several-events.md new file mode 100644 index 0000000..da44527 --- /dev/null +++ b/Documentation/client-snippets/constraints/declarative/unique/releasing-several-events.md @@ -0,0 +1,32 @@ +```typescript +import { constraint, eventType, IConstraint, IConstraintBuilder } from '@cratis/chronicle'; + +@eventType() +class ConstraintsUniqueSeveralInvitationSent { + constructor(readonly emailAddress: string) {} +} + +@eventType() +class ConstraintsUniqueSeveralInvitationAccepted { +} + +@eventType() +class ConstraintsUniqueSeveralInvitationRevoked { +} + +@eventType() +class ConstraintsUniqueSeveralInvitationExpired { +} + +@constraint() +class ConstraintsUniqueSeveralInvitedAddress implements IConstraint { + define(builder: IConstraintBuilder): void { + builder.unique(unique => + unique + .on(ConstraintsUniqueSeveralInvitationSent, e => e.emailAddress) + .removedWith(ConstraintsUniqueSeveralInvitationAccepted) + .removedWith(ConstraintsUniqueSeveralInvitationRevoked) + .removedWith(ConstraintsUniqueSeveralInvitationExpired)); + } +} +``` diff --git a/Documentation/client-snippets/constraints/declarative/unique/violation-message-callback.md b/Documentation/client-snippets/constraints/declarative/unique/violation-message-callback.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/constraints/declarative/unique/violation-message-callback.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/constraints/model-bound/unique/several-removal-events.md b/Documentation/client-snippets/constraints/model-bound/unique/several-removal-events.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/constraints/model-bound/unique/several-removal-events.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/constraints/model-bound/unique/strongly-typed.md b/Documentation/client-snippets/constraints/model-bound/unique/strongly-typed.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/constraints/model-bound/unique/strongly-typed.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/constraints/model-bound/unique/violation-message.md b/Documentation/client-snippets/constraints/model-bound/unique/violation-message.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/constraints/model-bound/unique/violation-message.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/data-contract.md b/Documentation/client-snippets/contributing/kernel/contracts/data-contract.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/data-contract.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/datetimeoffset-usage.md b/Documentation/client-snippets/contributing/kernel/contracts/datetimeoffset-usage.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/datetimeoffset-usage.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/oneof-message.md b/Documentation/client-snippets/contributing/kernel/contracts/oneof-message.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/oneof-message.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/oneof-service.md b/Documentation/client-snippets/contributing/kernel/contracts/oneof-service.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/oneof-service.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/oneof-usage.md b/Documentation/client-snippets/contributing/kernel/contracts/oneof-usage.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/oneof-usage.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/serializable-datetimeoffset.md b/Documentation/client-snippets/contributing/kernel/contracts/serializable-datetimeoffset.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/serializable-datetimeoffset.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/service-contract.md b/Documentation/client-snippets/contributing/kernel/contracts/service-contract.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/service-contract.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/contracts/streaming.md b/Documentation/client-snippets/contributing/kernel/contracts/streaming.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/contracts/streaming.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/patches/index/basic-structure.md b/Documentation/client-snippets/contributing/kernel/patches/index/basic-structure.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/patches/index/basic-structure.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/patches/index/dependencies.md b/Documentation/client-snippets/contributing/kernel/patches/index/dependencies.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/patches/index/dependencies.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/patches/index/idempotency.md b/Documentation/client-snippets/contributing/kernel/patches/index/idempotency.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/patches/index/idempotency.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/patches/index/implement-down.md b/Documentation/client-snippets/contributing/kernel/patches/index/implement-down.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/patches/index/implement-down.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/patches/index/rename-reactors-example.md b/Documentation/client-snippets/contributing/kernel/patches/index/rename-reactors-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/patches/index/rename-reactors-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/patches/index/semantic-logging.md b/Documentation/client-snippets/contributing/kernel/patches/index/semantic-logging.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/patches/index/semantic-logging.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/contributing/kernel/patches/index/spec.md b/Documentation/client-snippets/contributing/kernel/patches/index/spec.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/contributing/kernel/patches/index/spec.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/corrected-seed-set.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/corrected-seed-set.md new file mode 100644 index 0000000..aea3ff5 --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/corrected-seed-set.md @@ -0,0 +1,9 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +async function registerEvtSeedingCorrection(eventStore: IEventStore): Promise { + eventStore.seeding.for('user-123', [new EvtSeedingUserRegistered('john@example.com', 'John Doe')]); + + await eventStore.seeding.register(); +} +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/development-only.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/development-only.md new file mode 100644 index 0000000..02aafb6 --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/development-only.md @@ -0,0 +1,13 @@ +```typescript +import { ICanSeedEvents, IEventSeedingBuilder, seeder } from '@cratis/chronicle'; + +// Only include this seeder's file in your development build/discovery patterns - +// TypeScript has no build-time equivalent of C#'s #if DEBUG, so keep it out of what +// ChronicleOptions.discoveryPatterns picks up for production. +@seeder() +class EvtSeedingDevelopmentSeeding implements ICanSeedEvents { + seed(builder: IEventSeedingBuilder): void { + builder.for('dev-user-1', [new EvtSeedingUserRegistered('dev@example.com', 'Dev User')]); + } +} +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/events.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/events.md new file mode 100644 index 0000000..ed61ae1 --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/events.md @@ -0,0 +1,23 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +@eventType() +class EvtSeedingUserRegistered { + constructor(readonly email: string, readonly displayName: string) {} +} + +@eventType() +class EvtSeedingEmailVerified { + constructor(readonly email: string) {} +} + +@eventType() +class EvtSeedingProfileUpdated { + constructor(readonly displayName: string) {} +} + +@eventType() +class EvtSeedingOrderPlaced { + constructor(readonly userId: string, readonly amount: number) {} +} +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/namespace-scoped.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/namespace-scoped.md new file mode 100644 index 0000000..e34c4ec --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/namespace-scoped.md @@ -0,0 +1,38 @@ +```typescript +import { eventType, ICanSeedEvents, IEventSeedingBuilder, seeder } from '@cratis/chronicle'; + +@eventType() +class EvtSeedingProductCreated { + constructor(readonly name: string, readonly price: number) {} +} + +@eventType() +class EvtSeedingOrganizationCreated { + constructor(readonly name: string) {} +} + +@eventType() +class EvtSeedingBillingSetUp { + constructor(readonly billingEmail: string) {} +} + +@seeder() +class EvtSeedingTenantSeeding implements ICanSeedEvents { + seed(builder: IEventSeedingBuilder): void { + // Global seed data — applied to every namespace + builder.for('product-1', [new EvtSeedingProductCreated('Laptop', 1299.0)]); + + // Namespace-scoped seed data — applied only to the "acme" namespace + builder.forNamespace('acme') + .for('user-1', [new EvtSeedingUserRegistered('admin@acme.com', 'Acme Admin')]); + + // A second namespace with different seed data + builder.forNamespace('contoso') + .for('user-1', [new EvtSeedingUserRegistered('admin@contoso.com', 'Contoso Admin')]) + .forEventSource('org-1', [ + new EvtSeedingOrganizationCreated('Contoso'), + new EvtSeedingBillingSetUp('contoso@billing.com') + ]); + } +} +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/organize-by-feature.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/organize-by-feature.md new file mode 100644 index 0000000..02c3c88 --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/organize-by-feature.md @@ -0,0 +1,17 @@ +```typescript +import { ICanSeedEvents, IEventSeedingBuilder, seeder } from '@cratis/chronicle'; + +@seeder() +class EvtSeedingUserFeatureSeeding implements ICanSeedEvents { + seed(builder: IEventSeedingBuilder): void { + builder.for('test-user-1', [new EvtSeedingUserRegistered('test1@example.com', 'Test User 1')]); + } +} + +@seeder() +class EvtSeedingOrderFeatureSeeding implements ICanSeedEvents { + seed(builder: IEventSeedingBuilder): void { + builder.for('test-order-1', [new EvtSeedingOrderPlaced('test-user-1', 100.0)]); + } +} +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/seed-mixed-types.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/seed-mixed-types.md new file mode 100644 index 0000000..c9f35ff --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/seed-mixed-types.md @@ -0,0 +1,14 @@ +```typescript +import { ICanSeedEvents, IEventSeedingBuilder, seeder } from '@cratis/chronicle'; + +@seeder() +class EvtSeedingMixedTypesSeeding implements ICanSeedEvents { + seed(builder: IEventSeedingBuilder): void { + builder.forEventSource('user-123', [ + new EvtSeedingUserRegistered('john@example.com', 'John'), + new EvtSeedingEmailVerified('john@example.com'), + new EvtSeedingProfileUpdated('John Doe') + ]); + } +} +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/seed-multiple-same-type.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/seed-multiple-same-type.md new file mode 100644 index 0000000..d657d03 --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/seed-multiple-same-type.md @@ -0,0 +1,13 @@ +```typescript +import { ICanSeedEvents, IEventSeedingBuilder, seeder } from '@cratis/chronicle'; + +@seeder() +class EvtSeedingMultipleSameTypeSeeding implements ICanSeedEvents { + seed(builder: IEventSeedingBuilder): void { + builder.for('user-123', [ + new EvtSeedingUserRegistered('john@example.com', 'John'), + new EvtSeedingUserRegistered('jane@example.com', 'Jane') + ]); + } +} +``` diff --git a/Documentation/client-snippets/event-seeding/seeding-with-csharp/seeder.md b/Documentation/client-snippets/event-seeding/seeding-with-csharp/seeder.md new file mode 100644 index 0000000..9aae063 --- /dev/null +++ b/Documentation/client-snippets/event-seeding/seeding-with-csharp/seeder.md @@ -0,0 +1,15 @@ +```typescript +import { ICanSeedEvents, IEventSeedingBuilder, seeder } from '@cratis/chronicle'; + +@seeder() +class EvtSeedingUserSeeding implements ICanSeedEvents { + seed(builder: IEventSeedingBuilder): void { + builder + .for('user-123', [new EvtSeedingUserRegistered('john@example.com', 'John')]) + .forEventSource('user-456', [ + new EvtSeedingUserRegistered('jane@example.com', 'Jane'), + new EvtSeedingEmailVerified('jane@example.com') + ]); + } +} +``` diff --git a/Documentation/client-snippets/events/appending-with-tags/append-many.md b/Documentation/client-snippets/events/appending-with-tags/append-many.md index 7943a87..b22272b 100644 --- a/Documentation/client-snippets/events/appending-with-tags/append-many.md +++ b/Documentation/client-snippets/events/appending-with-tags/append-many.md @@ -1,4 +1,26 @@ -```text -The TypeScript Chronicle client does not support this workflow yet. -Its append-many options do not currently expose dynamic event tags. +```typescript +import { eventType, EventForEventSourceId, IEventStore } from '@cratis/chronicle'; + +@eventType() +class TaggedMoneyWithdrawn { + constructor(readonly amount: number) {} +} + +@eventType() +class TaggedMoneyDeposited { + constructor(readonly amount: number) {} +} + +class TaggedTransferService { + constructor(private readonly store: IEventStore) {} + + async transfer(fromAccountId: string, toAccountId: string, amount: number): Promise { + const events: EventForEventSourceId[] = [ + { eventSourceId: fromAccountId, event: new TaggedMoneyWithdrawn(amount) }, + { eventSourceId: toAccountId, event: new TaggedMoneyDeposited(amount) } + ]; + + await this.store.eventLog.appendMany(events, { tags: ['transfer', 'audit'] }); + } +} ``` diff --git a/Documentation/client-snippets/events/appending-with-tags/append.md b/Documentation/client-snippets/events/appending-with-tags/append.md index 0515efe..01d5129 100644 --- a/Documentation/client-snippets/events/appending-with-tags/append.md +++ b/Documentation/client-snippets/events/appending-with-tags/append.md @@ -1,4 +1,19 @@ -```text -The TypeScript Chronicle client does not support this workflow yet. -Its append options do not currently expose dynamic event tags. +```typescript +import { eventType, IEventStore } from '@cratis/chronicle'; + +@eventType() +class TaggedOrderPlaced { + constructor(readonly customerId: string, readonly total: number) {} +} + +class TaggedCheckoutService { + constructor(private readonly store: IEventStore) {} + + async placeOrder(orderId: string, customerId: string, total: number): Promise { + await this.store.eventLog.append( + orderId, + new TaggedOrderPlaced(customerId, total), + { tags: ['checkout', 'priority'] }); + } +} ``` diff --git a/Documentation/client-snippets/events/appending/occurred.md b/Documentation/client-snippets/events/appending/occurred.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/appending/occurred.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/concurrency/append-many.md b/Documentation/client-snippets/events/concurrency/append-many.md new file mode 100644 index 0000000..08904b3 --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/append-many.md @@ -0,0 +1,37 @@ +```typescript +import { eventType, EventForEventSourceId, getEventTypeFor, IEventLog } from '@cratis/chronicle'; + +@eventType() +class ConcurrencyMoneyWithdrawnForTransfer { + constructor(readonly amount: number) {} +} + +@eventType() +class ConcurrencyMoneyDepositedForTransfer { + constructor(readonly amount: number) {} +} + +class ConcurrencyTransferService { + constructor(private readonly eventLog: IEventLog) {} + + async transferMoney(fromAccount: string, toAccount: string, amount: number): Promise { + const events: EventForEventSourceId[] = [ + { eventSourceId: fromAccount, event: new ConcurrencyMoneyWithdrawnForTransfer(amount) }, + { eventSourceId: toAccount, event: new ConcurrencyMoneyDepositedForTransfer(amount) } + ]; + + await this.eventLog.appendMany(events, { + concurrencyScopes: { + [fromAccount]: { + sequenceNumber: 50n, + eventTypes: [getEventTypeFor(ConcurrencyMoneyWithdrawnForTransfer)] + }, + [toAccount]: { + sequenceNumber: 25n, + eventTypes: [getEventTypeFor(ConcurrencyMoneyDepositedForTransfer)] + } + } + }); + } +} +``` diff --git a/Documentation/client-snippets/events/concurrency/builder.md b/Documentation/client-snippets/events/concurrency/builder.md new file mode 100644 index 0000000..9d07216 --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/builder.md @@ -0,0 +1,27 @@ +```typescript +import { eventType, getEventTypeFor, IEventLog } from '@cratis/chronicle'; + +@eventType() +class ConcurrencyMoneyDeposited { + constructor(readonly amount: number) {} +} + +@eventType() +class ConcurrencyMoneyWithdrawn { + constructor(readonly amount: number) {} +} + +class ConcurrencyAccountTransactionService { + constructor(private readonly eventLog: IEventLog) {} + + async processTransaction(accountId: string, amount: number): Promise { + await this.eventLog.append(accountId, new ConcurrencyMoneyDeposited(amount), { + concurrencyScope: { + sequenceNumber: 15n, + eventStreamType: 'Transactions', + eventTypes: [getEventTypeFor(ConcurrencyMoneyDeposited), getEventTypeFor(ConcurrencyMoneyWithdrawn)] + } + }); + } +} +``` diff --git a/Documentation/client-snippets/events/concurrency/event-types.md b/Documentation/client-snippets/events/concurrency/event-types.md new file mode 100644 index 0000000..ac1377d --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/event-types.md @@ -0,0 +1,36 @@ +```typescript +import { eventType, getEventTypeFor, IEventLog } from '@cratis/chronicle'; + +@eventType() +class ConcurrencyPaymentProcessed { + constructor(readonly amount: number) {} +} + +@eventType() +class ConcurrencyPaymentFailed { + constructor(readonly amount: number) {} +} + +@eventType() +class ConcurrencyPaymentRefunded { + constructor(readonly amount: number) {} +} + +class ConcurrencyAccountService { + constructor(private readonly eventLog: IEventLog) {} + + async processPayment(accountId: string, amount: number): Promise { + // Only check concurrency for payment-related events + await this.eventLog.append(accountId, new ConcurrencyPaymentProcessed(amount), { + concurrencyScope: { + sequenceNumber: 20n, + eventTypes: [ + getEventTypeFor(ConcurrencyPaymentProcessed), + getEventTypeFor(ConcurrencyPaymentFailed), + getEventTypeFor(ConcurrencyPaymentRefunded) + ] + } + }); + } +} +``` diff --git a/Documentation/client-snippets/events/concurrency/first-append-check.md b/Documentation/client-snippets/events/concurrency/first-append-check.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/first-append-check.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/concurrency/for-event-source-operations.md b/Documentation/client-snippets/events/concurrency/for-event-source-operations.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/for-event-source-operations.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/concurrency/handling-violations.md b/Documentation/client-snippets/events/concurrency/handling-violations.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/handling-violations.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/concurrency/source-and-stream-type.md b/Documentation/client-snippets/events/concurrency/source-and-stream-type.md new file mode 100644 index 0000000..9f9b8ec --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/source-and-stream-type.md @@ -0,0 +1,27 @@ +```typescript +import { eventType, IEventLog } from '@cratis/chronicle'; + +@eventType() +class ConcurrencyAccountSettingsUpdated { + constructor(readonly settings: string) {} +} + +class ConcurrencyAccountManagementService { + constructor(private readonly eventLog: IEventLog) {} + + async updateAccountSettings(accountId: string, settings: string): Promise { + await this.eventLog.appendMany([{ + eventSourceId: accountId, + event: new ConcurrencyAccountSettingsUpdated(settings), + eventSourceType: 'BankAccount', + eventStreamType: 'AccountManagement' + }], { + concurrencyScope: { + sequenceNumber: 10n, + eventSourceType: 'BankAccount', + eventStreamType: 'AccountManagement' + } + }); + } +} +``` diff --git a/Documentation/client-snippets/events/concurrency/strategies.md b/Documentation/client-snippets/events/concurrency/strategies.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/strategies.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/concurrency/stream-id.md b/Documentation/client-snippets/events/concurrency/stream-id.md new file mode 100644 index 0000000..bca7097 --- /dev/null +++ b/Documentation/client-snippets/events/concurrency/stream-id.md @@ -0,0 +1,29 @@ +```typescript +import { eventType, IEventLog } from '@cratis/chronicle'; + +@eventType() +class ConcurrencyMonthlyReportGenerated { + constructor(readonly month: string) {} +} + +class ConcurrencyMonthlyReportService { + constructor(private readonly eventLog: IEventLog) {} + + async generateMonthlyReport(accountId: string, month: Date): Promise { + const monthKey = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}`; + + await this.eventLog.appendMany([{ + eventSourceId: accountId, + event: new ConcurrencyMonthlyReportGenerated(monthKey), + eventStreamType: 'Reporting', + eventStreamId: monthKey + }], { + concurrencyScope: { + sequenceNumber: 5n, + eventStreamType: 'Reporting', + eventStreamId: monthKey + } + }); + } +} +``` diff --git a/Documentation/client-snippets/events/cross-cutting-properties/provider.md b/Documentation/client-snippets/events/cross-cutting-properties/provider.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/cross-cutting-properties/provider.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/event-source-id/conversions.md b/Documentation/client-snippets/events/event-source-id/conversions.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/event-source-id/conversions.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/event-source-id/typed.md b/Documentation/client-snippets/events/event-source-id/typed.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/event-source-id/typed.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/filtering/by-event-source-type/reactor.md b/Documentation/client-snippets/events/filtering/by-event-source-type/reactor.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/filtering/by-event-source-type/reactor.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/filtering/by-event-source-type/reducer.md b/Documentation/client-snippets/events/filtering/by-event-source-type/reducer.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/filtering/by-event-source-type/reducer.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/filtering/by-event-stream-type/reactor.md b/Documentation/client-snippets/events/filtering/by-event-stream-type/reactor.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/filtering/by-event-stream-type/reactor.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/filtering/by-event-stream-type/reducer.md b/Documentation/client-snippets/events/filtering/by-event-stream-type/reducer.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/filtering/by-event-stream-type/reducer.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/filtering/by-tag/multiple-filters.md b/Documentation/client-snippets/events/filtering/by-tag/multiple-filters.md new file mode 100644 index 0000000..83c11fd --- /dev/null +++ b/Documentation/client-snippets/events/filtering/by-tag/multiple-filters.md @@ -0,0 +1,15 @@ +```typescript +import { eventType, filterEventsByTag, reactor } from '@cratis/chronicle'; + +@eventType() +class FilterByTagMultiCustomerRegistered { + constructor(readonly emailAddress: string) {} +} + +@reactor() +@filterEventsByTag('vip') +@filterEventsByTag('priority') +class FilterByTagMultiPriorityNotificationsReactor { + async filterByTagMultiCustomerRegistered(_event: FilterByTagMultiCustomerRegistered): Promise {} +} +``` diff --git a/Documentation/client-snippets/events/filtering/by-tag/reactor.md b/Documentation/client-snippets/events/filtering/by-tag/reactor.md new file mode 100644 index 0000000..b5969f8 --- /dev/null +++ b/Documentation/client-snippets/events/filtering/by-tag/reactor.md @@ -0,0 +1,26 @@ +```typescript +import { EventContext, eventType, filterEventsByTag, IEventStore, reactor, tag } from '@cratis/chronicle'; + +@eventType() +@tag('customer-lifecycle') +class FilterByTagCustomerRegistered { + constructor(readonly emailAddress: string) {} +} + +class FilterByTagCustomerRegistrationService { + constructor(private readonly store: IEventStore) {} + + async register(eventSourceId: string, emailAddress: string): Promise { + await this.store.eventLog.append( + eventSourceId, + new FilterByTagCustomerRegistered(emailAddress), + { tags: ['vip', 'onboarding'] }); + } +} + +@reactor() +@filterEventsByTag('vip') +class FilterByTagVipWelcomeReactor { + async filterByTagCustomerRegistered(_event: FilterByTagCustomerRegistered, _context: EventContext): Promise {} +} +``` diff --git a/Documentation/client-snippets/events/filtering/by-tag/reducer.md b/Documentation/client-snippets/events/filtering/by-tag/reducer.md new file mode 100644 index 0000000..ab1c52b --- /dev/null +++ b/Documentation/client-snippets/events/filtering/by-tag/reducer.md @@ -0,0 +1,34 @@ +```typescript +import { eventType, filterEventsByTag, IEventStore, reducer } from '@cratis/chronicle'; + +@eventType() +class FilterByTagOrderPlaced { + constructor(readonly totalAmount: number) {} +} + +class FilterByTagPriorityOrderTotals { + totalAmount = 0; +} + +@reducer('', undefined, FilterByTagPriorityOrderTotals) +@filterEventsByTag('priority') +class FilterByTagPriorityOrderTotalsReducer { + filterByTagOrderPlaced( + event: FilterByTagOrderPlaced, + current: FilterByTagPriorityOrderTotals | undefined + ): FilterByTagPriorityOrderTotals { + return { totalAmount: (current?.totalAmount ?? 0) + event.totalAmount }; + } +} + +class FilterByTagCheckoutService { + constructor(private readonly store: IEventStore) {} + + async placePriorityOrder(eventSourceId: string, totalAmount: number): Promise { + await this.store.eventLog.append( + eventSourceId, + new FilterByTagOrderPlaced(totalAmount), + { tags: ['priority'] }); + } +} +``` diff --git a/Documentation/client-snippets/events/getting-state/tail-for-event-source.md b/Documentation/client-snippets/events/getting-state/tail-for-event-source.md new file mode 100644 index 0000000..964dc37 --- /dev/null +++ b/Documentation/client-snippets/events/getting-state/tail-for-event-source.md @@ -0,0 +1,28 @@ +```typescript +import { eventType, EventSequenceNumber, IEventLog } from '@cratis/chronicle'; + +@eventType() +class GettingStateInventoryAdjusted { + constructor(readonly sku: string, readonly delta: number) {} +} + +@eventType() +class GettingStateInventoryReserved { + constructor(readonly sku: string, readonly quantity: number) {} +} + +class GettingStateInventoryCheckpoint { + constructor(private readonly eventLog: IEventLog) {} + + // Scopes the tail to a specific stream of inventory events. + captureFor(inventoryId: string): Promise { + return this.eventLog.getTailSequenceNumber( + inventoryId, + undefined, + undefined, + undefined, + [GettingStateInventoryAdjusted, GettingStateInventoryReserved] + ); + } +} +``` diff --git a/Documentation/client-snippets/events/getting-state/tail-for-observer.md b/Documentation/client-snippets/events/getting-state/tail-for-observer.md new file mode 100644 index 0000000..7620b5f --- /dev/null +++ b/Documentation/client-snippets/events/getting-state/tail-for-observer.md @@ -0,0 +1,13 @@ +```typescript +import { Constructor } from '@cratis/fundamentals'; +import { EventSequenceNumber, IEventSequence } from '@cratis/chronicle'; + +class GettingStateObserverProgress { + constructor(private readonly eventSequence: IEventSequence) {} + + // Uses the observer's event type filters to compute the relevant tail. + getRelevantTail(observerType: Constructor): Promise { + return this.eventSequence.getTailSequenceNumberForObserver(observerType); + } +} +``` diff --git a/Documentation/client-snippets/events/redaction/reactor.md b/Documentation/client-snippets/events/redaction/reactor.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/redaction/reactor.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/redaction/reducer.md b/Documentation/client-snippets/events/redaction/reducer.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/redaction/reducer.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/schema-representation/basic.md b/Documentation/client-snippets/events/schema-representation/basic.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/schema-representation/basic.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/events/transactions/ordered-exact-batch.md b/Documentation/client-snippets/events/transactions/ordered-exact-batch.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/events/transactions/ordered-exact-batch.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/aspnetcore/endpoint.md b/Documentation/client-snippets/get-started/aspnetcore/endpoint.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/aspnetcore/endpoint.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/aspnetcore/mongo-registration.md b/Documentation/client-snippets/get-started/aspnetcore/mongo-registration.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/aspnetcore/mongo-registration.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/aspnetcore/register-artifact.md b/Documentation/client-snippets/get-started/aspnetcore/register-artifact.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/aspnetcore/register-artifact.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/aspnetcore/register-by-convention.md b/Documentation/client-snippets/get-started/aspnetcore/register-by-convention.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/aspnetcore/register-by-convention.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/aspnetcore/register.md b/Documentation/client-snippets/get-started/aspnetcore/register.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/aspnetcore/register.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/choose-hosting-model/basic-apphost.md b/Documentation/client-snippets/get-started/choose-hosting-model/basic-apphost.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/choose-hosting-model/basic-apphost.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/choose-hosting-model/mongo-database.md b/Documentation/client-snippets/get-started/choose-hosting-model/mongo-database.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/choose-hosting-model/mongo-database.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/choose-hosting-model/postgres-database.md b/Documentation/client-snippets/get-started/choose-hosting-model/postgres-database.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/choose-hosting-model/postgres-database.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/choose-hosting-model/sqlite-database.md b/Documentation/client-snippets/get-started/choose-hosting-model/sqlite-database.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/choose-hosting-model/sqlite-database.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/choose-hosting-model/sqlserver-database.md b/Documentation/client-snippets/get-started/choose-hosting-model/sqlserver-database.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/choose-hosting-model/sqlserver-database.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/common/mongo-query.md b/Documentation/client-snippets/get-started/common/mongo-query.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/common/mongo-query.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/mongodb/conventions.md b/Documentation/client-snippets/get-started/mongodb/conventions.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/mongodb/conventions.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/worker/mongo-registration.md b/Documentation/client-snippets/get-started/worker/mongo-registration.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/worker/mongo-registration.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/worker/register-by-convention.md b/Documentation/client-snippets/get-started/worker/register-by-convention.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/worker/register-by-convention.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/worker/register.md b/Documentation/client-snippets/get-started/worker/register.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/worker/register.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/get-started/worker/worker-service.md b/Documentation/client-snippets/get-started/worker/worker-service.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/get-started/worker/worker-service.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/certificates.md b/Documentation/client-snippets/hosting/aspire/certificates.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/certificates.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/complete-example-dev.md b/Documentation/client-snippets/hosting/aspire/complete-example-dev.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/complete-example-dev.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/complete-example.md b/Documentation/client-snippets/hosting/aspire/complete-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/complete-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/connecting-client.md b/Documentation/client-snippets/hosting/aspire/connecting-client.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/connecting-client.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/dev-mode.md b/Documentation/client-snippets/hosting/aspire/dev-mode.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/dev-mode.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/mongo-replica-set-helper.md b/Documentation/client-snippets/hosting/aspire/mongo-replica-set-helper.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/mongo-replica-set-helper.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/mongo-replica-set.md b/Documentation/client-snippets/hosting/aspire/mongo-replica-set.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/mongo-replica-set.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/mongo.md b/Documentation/client-snippets/hosting/aspire/mongo.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/mongo.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/pin-image.md b/Documentation/client-snippets/hosting/aspire/pin-image.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/pin-image.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/postgres.md b/Documentation/client-snippets/hosting/aspire/postgres.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/postgres.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/rotation-previous-certificate.md b/Documentation/client-snippets/hosting/aspire/rotation-previous-certificate.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/rotation-previous-certificate.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/sqlite.md b/Documentation/client-snippets/hosting/aspire/sqlite.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/sqlite.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/aspire/sqlserver.md b/Documentation/client-snippets/hosting/aspire/sqlserver.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/aspire/sqlserver.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/configuration/storage/in-memory.md b/Documentation/client-snippets/hosting/configuration/storage/in-memory.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/configuration/storage/in-memory.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/hosting/local-certificates/client-configuration.md b/Documentation/client-snippets/hosting/local-certificates/client-configuration.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/hosting/local-certificates/client-configuration.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/combine.md b/Documentation/client-snippets/migrations/dotnet-client/combine.md new file mode 100644 index 0000000..6285ad1 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/combine.md @@ -0,0 +1,27 @@ +```typescript +import { eventType, eventTypeMigration, IEventMigrationBuilder, IEventTypeMigration } from '@cratis/chronicle'; + +@eventType('dotnet-client-shipping-address-recorded', 2) +class MigrationsDotnetClientCombineShippingAddressRecorded { + constructor(readonly fullAddress: string) {} +} + +@eventType('dotnet-client-shipping-address-recorded', 1) +class MigrationsDotnetClientCombineShippingAddressRecordedV1 { + constructor(readonly street: string, readonly city: string) {} +} + +@eventTypeMigration(MigrationsDotnetClientCombineShippingAddressRecorded, MigrationsDotnetClientCombineShippingAddressRecordedV1) +class MigrationsDotnetClientCombineShippingAddressRecordedMigration implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .combine('fullAddress', ' ', 'street', 'city')); + } + + downcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .split('street', 'fullAddress', ' ', 0) + .split('city', 'fullAddress', ' ', 1)); + } +} +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/default-value.md b/Documentation/client-snippets/migrations/dotnet-client/default-value.md new file mode 100644 index 0000000..64db5c1 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/default-value.md @@ -0,0 +1,32 @@ +```typescript +import { eventType, eventTypeMigration, IEventMigrationBuilder, IEventTypeMigration } from '@cratis/chronicle'; + +@eventType('dotnet-client-task-created', 2) +class MigrationsDotnetClientDefaultValueTaskCreated { + constructor( + readonly title: string, + readonly status: string, + readonly retryCount: number, + readonly enabled: boolean + ) {} +} + +@eventType('dotnet-client-task-created', 1) +class MigrationsDotnetClientDefaultValueTaskCreatedV1 { + constructor(readonly title: string) {} +} + +@eventTypeMigration(MigrationsDotnetClientDefaultValueTaskCreated, MigrationsDotnetClientDefaultValueTaskCreatedV1) +class MigrationsDotnetClientDefaultValueTaskCreatedMigration implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .defaultValue('status', 'active') + .defaultValue('retryCount', 0) + .defaultValue('enabled', true)); + } + + downcast(builder: IEventMigrationBuilder): void { + // status, retryCount, and enabled did not exist in generation 1 — nothing to map back + } +} +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/generations.md b/Documentation/client-snippets/migrations/dotnet-client/generations.md new file mode 100644 index 0000000..3dfd566 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/generations.md @@ -0,0 +1,16 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +// Generation 2 (current) — Name has been split into FirstName and LastName +@eventType('dotnet-client-author-registered', 2) +class MigrationsDotnetClientAuthorRegistered { + constructor(readonly firstName: string, readonly lastName: string) {} +} + +// Generation 1 (original) — same id, generation 1, kept only so the migration below can +// upcast from it. It is not the "current" shape of the event any more. +@eventType('dotnet-client-author-registered', 1) +class MigrationsDotnetClientAuthorRegisteredV1 { + constructor(readonly name: string) {} +} +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/map-values-one-direction.md b/Documentation/client-snippets/migrations/dotnet-client/map-values-one-direction.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/map-values-one-direction.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/map-values.md b/Documentation/client-snippets/migrations/dotnet-client/map-values.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/map-values.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/migrator.md b/Documentation/client-snippets/migrations/dotnet-client/migrator.md new file mode 100644 index 0000000..2b1c24e --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/migrator.md @@ -0,0 +1,17 @@ +```typescript +import { eventTypeMigration, IEventMigrationBuilder, IEventTypeMigration } from '@cratis/chronicle'; + +@eventTypeMigration(MigrationsDotnetClientAuthorRegistered, MigrationsDotnetClientAuthorRegisteredV1) +class MigrationsDotnetClientAuthorRegisteredMigration implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .split('firstName', 'name', ' ', 0) + .split('lastName', 'name', ' ', 1)); + } + + downcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .combine('name', ' ', 'firstName', 'lastName')); + } +} +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/multi-generation.md b/Documentation/client-snippets/migrations/dotnet-client/multi-generation.md new file mode 100644 index 0000000..6546839 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/multi-generation.md @@ -0,0 +1,47 @@ +```typescript +import { eventType, eventTypeMigration, IEventMigrationBuilder, IEventTypeMigration } from '@cratis/chronicle'; + +@eventType('dotnet-client-multi-gen-person-registered', 3) +class MigrationsDotnetClientMultiGenPersonRegistered { + constructor(readonly email: string, readonly firstName: string, readonly lastName: string) {} +} + +@eventType('dotnet-client-multi-gen-person-registered', 2) +class MigrationsDotnetClientMultiGenPersonRegisteredV2 { + constructor(readonly email: string, readonly name: string) {} +} + +@eventType('dotnet-client-multi-gen-person-registered', 1) +class MigrationsDotnetClientMultiGenPersonRegisteredV1 { + constructor(readonly emailAddress: string, readonly name: string) {} +} + +// Generation 1 → 2: rename emailAddress to email +@eventTypeMigration(MigrationsDotnetClientMultiGenPersonRegisteredV2, MigrationsDotnetClientMultiGenPersonRegisteredV1) +class MigrationsDotnetClientMultiGenPersonRegisteredV1ToV2 implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .renamedFrom('email', 'emailAddress')); + } + + downcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .renamedFrom('emailAddress', 'email')); + } +} + +// Generation 2 → 3: split name into firstName / lastName +@eventTypeMigration(MigrationsDotnetClientMultiGenPersonRegistered, MigrationsDotnetClientMultiGenPersonRegisteredV2) +class MigrationsDotnetClientMultiGenPersonRegisteredV2ToV3 implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .split('firstName', 'name', ' ', 0) + .split('lastName', 'name', ' ', 1)); + } + + downcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .combine('name', ' ', 'firstName', 'lastName')); + } +} +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/renamed-from.md b/Documentation/client-snippets/migrations/dotnet-client/renamed-from.md new file mode 100644 index 0000000..6b53525 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/renamed-from.md @@ -0,0 +1,26 @@ +```typescript +import { eventType, eventTypeMigration, IEventMigrationBuilder, IEventTypeMigration } from '@cratis/chronicle'; + +@eventType('dotnet-client-customer-registered', 2) +class MigrationsDotnetClientRenamedFromCustomerRegistered { + constructor(readonly email: string) {} +} + +@eventType('dotnet-client-customer-registered', 1) +class MigrationsDotnetClientRenamedFromCustomerRegisteredV1 { + constructor(readonly emailAddress: string) {} +} + +@eventTypeMigration(MigrationsDotnetClientRenamedFromCustomerRegistered, MigrationsDotnetClientRenamedFromCustomerRegisteredV1) +class MigrationsDotnetClientRenamedFromCustomerRegisteredMigration implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .renamedFrom('email', 'emailAddress')); + } + + downcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .renamedFrom('emailAddress', 'email')); + } +} +``` diff --git a/Documentation/client-snippets/migrations/dotnet-client/split.md b/Documentation/client-snippets/migrations/dotnet-client/split.md new file mode 100644 index 0000000..0b97fb1 --- /dev/null +++ b/Documentation/client-snippets/migrations/dotnet-client/split.md @@ -0,0 +1,27 @@ +```typescript +import { eventType, eventTypeMigration, IEventMigrationBuilder, IEventTypeMigration } from '@cratis/chronicle'; + +@eventType('dotnet-client-person-registered', 2) +class MigrationsDotnetClientSplitPersonRegistered { + constructor(readonly firstName: string, readonly lastName: string) {} +} + +@eventType('dotnet-client-person-registered', 1) +class MigrationsDotnetClientSplitPersonRegisteredV1 { + constructor(readonly fullName: string) {} +} + +@eventTypeMigration(MigrationsDotnetClientSplitPersonRegistered, MigrationsDotnetClientSplitPersonRegisteredV1) +class MigrationsDotnetClientSplitPersonRegisteredMigration implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .split('firstName', 'fullName', ' ', 0) + .split('lastName', 'fullName', ' ', 1)); + } + + downcast(builder: IEventMigrationBuilder): void { + builder.properties(propertyBuilder => propertyBuilder + .combine('fullName', ' ', 'firstName', 'lastName')); + } +} +``` diff --git a/Documentation/client-snippets/migrations/validation/default-value.md b/Documentation/client-snippets/migrations/validation/default-value.md new file mode 100644 index 0000000..558d3ca --- /dev/null +++ b/Documentation/client-snippets/migrations/validation/default-value.md @@ -0,0 +1,26 @@ +```typescript +import { eventType, eventTypeMigration, IEventMigrationBuilder, IEventTypeMigration } from '@cratis/chronicle'; + +@eventType('validation-author-registered', 2) +class MigrationsValidationAuthorRegistered { + constructor(readonly name: string, readonly status: string) {} +} + +@eventType('validation-author-registered', 1) +class MigrationsValidationAuthorRegisteredV1 { + constructor(readonly name: string) {} +} + +@eventTypeMigration(MigrationsValidationAuthorRegistered, MigrationsValidationAuthorRegisteredV1) +class MigrationsValidationAuthorRegisteredMigration implements IEventTypeMigration { + upcast(builder: IEventMigrationBuilder): void { + // name is unchanged between generations — no operation needed for it + builder.properties(propertyBuilder => propertyBuilder + .defaultValue('status', 'active')); + } + + downcast(builder: IEventMigrationBuilder): void { + // status does not exist in generation 1 — no mapping needed + } +} +``` diff --git a/Documentation/client-snippets/migrations/validation/enable-validation.md b/Documentation/client-snippets/migrations/validation/enable-validation.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/migrations/validation/enable-validation.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/aspnetcore/builder-custom-resolver.md b/Documentation/client-snippets/namespaces/aspnetcore/builder-custom-resolver.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/aspnetcore/builder-custom-resolver.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/aspnetcore/http-header-resolver.md b/Documentation/client-snippets/namespaces/aspnetcore/http-header-resolver.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/aspnetcore/http-header-resolver.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/aspnetcore/subdomain-resolver.md b/Documentation/client-snippets/namespaces/aspnetcore/subdomain-resolver.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/aspnetcore/subdomain-resolver.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/aspnetcore/type-based-custom-resolver.md b/Documentation/client-snippets/namespaces/aspnetcore/type-based-custom-resolver.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/aspnetcore/type-based-custom-resolver.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/aspnetcore/web-app-example.md b/Documentation/client-snippets/namespaces/aspnetcore/web-app-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/aspnetcore/web-app-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/dotnet-client/claims-based.md b/Documentation/client-snippets/namespaces/dotnet-client/claims-based.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/dotnet-client/claims-based.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/dotnet-client/default-resolver.md b/Documentation/client-snippets/namespaces/dotnet-client/default-resolver.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/dotnet-client/default-resolver.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/dotnet-client/hosted-app-configuration.md b/Documentation/client-snippets/namespaces/dotnet-client/hosted-app-configuration.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/dotnet-client/hosted-app-configuration.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/dotnet-client/resolver-shape.md b/Documentation/client-snippets/namespaces/dotnet-client/resolver-shape.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/dotnet-client/resolver-shape.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/dotnet-client/tenant-resolver-usage.md b/Documentation/client-snippets/namespaces/dotnet-client/tenant-resolver-usage.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/dotnet-client/tenant-resolver-usage.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/namespaces/dotnet-client/tenant-resolver.md b/Documentation/client-snippets/namespaces/dotnet-client/tenant-resolver.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/namespaces/dotnet-client/tenant-resolver.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/declarative/auto-map/aggregate-only.md b/Documentation/client-snippets/projections/declarative/auto-map/aggregate-only.md new file mode 100644 index 0000000..b006e94 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/auto-map/aggregate-only.md @@ -0,0 +1,28 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DeclAggArrangementSet { + constructor(readonly location: string) {} +} + +@eventType() +class DeclAggCandidateSubmitted { + constructor(readonly name: string, readonly location: string) {} +} + +class DeclAggAssignmentSummary { + location = ''; + candidateCount = 0; +} + +@projection() +class DeclAggAssignmentProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(DeclAggArrangementSet) + .from(DeclAggCandidateSubmitted, _ => _ + .count(m => m.candidateCount)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/auto-map/re-enable-child-scope.md b/Documentation/client-snippets/projections/declarative/auto-map/re-enable-child-scope.md new file mode 100644 index 0000000..5923bd4 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/auto-map/re-enable-child-scope.md @@ -0,0 +1,40 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class AutoMapTeamFormed { + constructor(readonly teamName: string) {} +} + +@eventType() +class AutoMapMemberJoinedTeam { + constructor(readonly memberId: string, readonly displayName: string) {} +} + +class AutoMapTeamMember { + memberId = ''; + displayName = ''; +} + +class AutoMapTeam { + name = ''; + createdAt = new Date(); + members: AutoMapTeamMember[] = []; +} + +@projection() +class AutoMapTeamProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .noAutoMap() + .from(AutoMapTeamFormed, _ => _ + .set(m => m.name).to(e => e.teamName) + .set(m => m.createdAt).toEventContextProperty('occurred')) + .children(m => m.members, children => children + .identifiedBy(m => m.memberId) + .autoMap() + .from(AutoMapMemberJoinedTeam, _ => _ + .usingKey(e => e.memberId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/basic.md b/Documentation/client-snippets/projections/declarative/children/basic.md new file mode 100644 index 0000000..963138c --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/basic.md @@ -0,0 +1,50 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class GroupCreatedForChildren { + constructor(readonly name: string, readonly description: string) {} +} + +@eventType() +class UserAddedToGroupForChildren { + constructor(readonly userId: string, readonly role: string) {} +} + +@eventType() +class UserRoleChangedForChildren { + constructor(readonly userId: string, readonly role: string) {} +} + +@eventType() +class UserRemovedFromGroupForChildren { + constructor(readonly userId: string) {} +} + +class GroupMemberForChildren { + userId = ''; + role = ''; +} + +class GroupForChildren { + name = ''; + description = ''; + members: GroupMemberForChildren[] = []; +} + +@projection() +class GroupProjectionForChildren implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(GroupCreatedForChildren) + .children(m => m.members, children => children + .identifiedBy(m => m.userId) + .from(UserAddedToGroupForChildren, b => b + .usingKey(e => e.userId)) + .from(UserRoleChangedForChildren, b => b + .usingKey(e => e.userId)) + .removedWith(UserRemovedFromGroupForChildren, b => b + .usingKey(e => e.userId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/context-parent-key.md b/Documentation/client-snippets/projections/declarative/children/context-parent-key.md new file mode 100644 index 0000000..f81a4b5 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/context-parent-key.md @@ -0,0 +1,36 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class GroupCreatedWithContextParentKey { + constructor(readonly name: string) {} +} + +@eventType() +class UserAddedWithContextParentKey { + constructor(readonly userId: string, readonly role: string) {} +} + +class GroupMemberWithContextParentKey { + userId = ''; + role = ''; +} + +class GroupWithContextParentKey { + name = ''; + members: GroupMemberWithContextParentKey[] = []; +} + +@projection() +class GroupWithContextParentKeyProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(GroupCreatedWithContextParentKey) + .children(m => m.members, children => children + .identifiedBy(m => m.userId) + .from(UserAddedWithContextParentKey, b => b + .usingParentKeyFromContext('eventSourceId') + .usingKey(e => e.userId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/default-parent-key-append.md b/Documentation/client-snippets/projections/declarative/children/default-parent-key-append.md new file mode 100644 index 0000000..705987e --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/default-parent-key-append.md @@ -0,0 +1,11 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class GroupMembershipWithDefaultParentKey { + constructor(private readonly eventStore: IEventStore) {} + + addUserToGroup(groupId: string, userId: string, role: string): Promise { + return this.eventStore.eventLog.append(groupId, new UserAddedWithDefaultParentKey(userId, role)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/default-parent-key.md b/Documentation/client-snippets/projections/declarative/children/default-parent-key.md new file mode 100644 index 0000000..8cf2b00 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/default-parent-key.md @@ -0,0 +1,35 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class GroupCreatedWithDefaultParentKey { + constructor(readonly name: string) {} +} + +@eventType() +class UserAddedWithDefaultParentKey { + constructor(readonly userId: string, readonly role: string) {} +} + +class GroupMemberWithDefaultParentKey { + userId = ''; + role = ''; +} + +class GroupWithDefaultParentKey { + name = ''; + members: GroupMemberWithDefaultParentKey[] = []; +} + +@projection() +class GroupWithDefaultParentKeyProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(GroupCreatedWithDefaultParentKey) + .children(m => m.members, children => children + .identifiedBy(m => m.userId) + .from(UserAddedWithDefaultParentKey, b => b + .usingKey(e => e.userId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/event-parent-key-append.md b/Documentation/client-snippets/projections/declarative/children/event-parent-key-append.md new file mode 100644 index 0000000..ca4f9d0 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/event-parent-key-append.md @@ -0,0 +1,11 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class GroupMembershipWithEventParentKey { + constructor(private readonly eventStore: IEventStore) {} + + addUserToGroup(userId: string, groupId: string, role: string): Promise { + return this.eventStore.eventLog.append(userId, new UserAddedWithEventParentKey(groupId, userId, role)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/event-parent-key.md b/Documentation/client-snippets/projections/declarative/children/event-parent-key.md new file mode 100644 index 0000000..01c03f8 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/event-parent-key.md @@ -0,0 +1,36 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class GroupCreatedWithEventParentKey { + constructor(readonly name: string) {} +} + +@eventType() +class UserAddedWithEventParentKey { + constructor(readonly groupId: string, readonly userId: string, readonly role: string) {} +} + +class GroupMemberWithEventParentKey { + userId = ''; + role = ''; +} + +class GroupWithEventParentKey { + name = ''; + members: GroupMemberWithEventParentKey[] = []; +} + +@projection() +class GroupWithEventParentKeyProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(GroupCreatedWithEventParentKey) + .children(m => m.members, children => children + .identifiedBy(m => m.userId) + .from(UserAddedWithEventParentKey, b => b + .usingParentKey(e => e.groupId) + .usingKey(e => e.userId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/events.md b/Documentation/client-snippets/projections/declarative/children/events.md new file mode 100644 index 0000000..2b7c933 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/events.md @@ -0,0 +1,23 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +@eventType() +class GroupCreatedForChildEvents { + constructor(readonly name: string, readonly description: string) {} +} + +@eventType() +class UserAddedToGroupForChildEvents { + constructor(readonly userId: string, readonly role: string) {} +} + +@eventType() +class UserRoleChangedForChildEvents { + constructor(readonly userId: string, readonly role: string) {} +} + +@eventType() +class UserRemovedFromGroupForChildEvents { + constructor(readonly userId: string) {} +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/multiple-collections.md b/Documentation/client-snippets/projections/declarative/children/multiple-collections.md new file mode 100644 index 0000000..9d67524 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/multiple-collections.md @@ -0,0 +1,50 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class GroupCreatedWithMultipleCollections { + constructor(readonly name: string) {} +} + +@eventType() +class MemberAddedToGroup { + constructor(readonly userId: string, readonly role: string) {} +} + +@eventType() +class TaskAssignedToGroup { + constructor(readonly taskId: string, readonly title: string) {} +} + +class GroupMemberInMultipleCollections { + userId = ''; + role = ''; +} + +class GroupTaskInMultipleCollections { + taskId = ''; + title = ''; +} + +class GroupWithMultipleCollections { + name = ''; + members: GroupMemberInMultipleCollections[] = []; + tasks: GroupTaskInMultipleCollections[] = []; +} + +@projection() +class GroupWithMultipleCollectionsProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(GroupCreatedWithMultipleCollections) + .children(m => m.members, children => children + .identifiedBy(m => m.userId) + .from(MemberAddedToGroup, b => b + .usingKey(e => e.userId))) + .children(m => m.tasks, children => children + .identifiedBy(m => m.taskId) + .from(TaskAssignedToGroup, b => b + .usingKey(e => e.taskId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/read-model.md b/Documentation/client-snippets/projections/declarative/children/read-model.md new file mode 100644 index 0000000..7663bbc --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/read-model.md @@ -0,0 +1,12 @@ +```typescript +class GroupMember { + userId = ''; + role = ''; +} + +class GroupWithMembers { + name = ''; + description = ''; + members: GroupMember[] = []; +} +``` diff --git a/Documentation/client-snippets/projections/declarative/children/removing.md b/Documentation/client-snippets/projections/declarative/children/removing.md new file mode 100644 index 0000000..ab47169 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/children/removing.md @@ -0,0 +1,42 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class GroupCreatedWithRemoval { + constructor(readonly name: string) {} +} + +@eventType() +class UserAddedWithRemoval { + constructor(readonly userId: string, readonly role: string) {} +} + +@eventType() +class UserRemovedWithRemoval { + constructor(readonly userId: string) {} +} + +class GroupMemberWithRemoval { + userId = ''; + role = ''; +} + +class GroupWithRemoval { + name = ''; + members: GroupMemberWithRemoval[] = []; +} + +@projection() +class GroupWithRemovalProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(GroupCreatedWithRemoval) + .children(m => m.members, children => children + .identifiedBy(m => m.userId) + .from(UserAddedWithRemoval, b => b + .usingKey(e => e.userId)) + .removedWith(UserRemovedWithRemoval, b => b + .usingKey(e => e.userId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/constant-key/basic.md b/Documentation/client-snippets/projections/declarative/constant-key/basic.md new file mode 100644 index 0000000..124e315 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/constant-key/basic.md @@ -0,0 +1,22 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecConstantKeyOrderPlaced { + constructor(readonly total: number) {} +} + +class DecConstantKeyGlobalCounter { + totalOrders = 0; +} + +@projection() +class DecConstantKeyGlobalCounterProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(DecConstantKeyOrderPlaced, _ => _ + .usingConstantKey('global') + .count(m => m.totalOrders)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/constant-key/constant-parent-key.md b/Documentation/client-snippets/projections/declarative/constant-key/constant-parent-key.md new file mode 100644 index 0000000..d9615fa --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/constant-key/constant-parent-key.md @@ -0,0 +1,29 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecConstantKeyUserJoined { + constructor(readonly userId: string, readonly userName: string) {} +} + +class DecConstantKeyTeamMember { + userId = ''; + name = ''; +} + +class DecConstantKeyTeam { + members: DecConstantKeyTeamMember[] = []; +} + +@projection() +class DecConstantKeyTeamActivityProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .children(m => m.members, children => children + .identifiedBy(e => e.userId) + .from(DecConstantKeyUserJoined, _ => _ + .usingConstantParentKey('main-team') + .set(m => m.name).to(e => e.userName))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/constant-key/multi-event-metrics.md b/Documentation/client-snippets/projections/declarative/constant-key/multi-event-metrics.md new file mode 100644 index 0000000..53ec34a --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/constant-key/multi-event-metrics.md @@ -0,0 +1,40 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecConstantKeyPageViewed { + constructor(readonly pageUrl: string) {} +} + +@eventType() +class DecConstantKeyButtonClicked { + constructor(readonly buttonId: string) {} +} + +@eventType() +class DecConstantKeyFormSubmitted { + constructor(readonly formId: string) {} +} + +class DecConstantKeyEngagementMetrics { + pageViews = 0; + buttonClicks = 0; + formSubmissions = 0; +} + +@projection() +class DecConstantKeyEngagementMetricsProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(DecConstantKeyPageViewed, _ => _ + .usingConstantKey('metrics') + .count(m => m.pageViews)) + .from(DecConstantKeyButtonClicked, _ => _ + .usingConstantKey('metrics') + .count(m => m.buttonClicks)) + .from(DecConstantKeyFormSubmitted, _ => _ + .usingConstantKey('metrics') + .count(m => m.formSubmissions)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/event-context/children-context.md b/Documentation/client-snippets/projections/declarative/event-context/children-context.md new file mode 100644 index 0000000..2611e39 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/event-context/children-context.md @@ -0,0 +1,32 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecEventContextActivityPerformed { + constructor(readonly activityId: string, readonly activityType: string) {} +} + +class DecEventContextActivityLogEntry { + activityId = ''; + timestamp = new Date(); + sequenceNumber = 0n; +} + +class DecEventContextUserWithActivityLog { + activityLog: DecEventContextActivityLogEntry[] = []; +} + +@projection() +class DecEventContextUserActivityLogProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .children(m => m.activityLog, children => children + .identifiedBy(e => e.activityId) + .autoMap() + .from(DecEventContextActivityPerformed, _ => _ + .usingKey(e => e.activityId) + .set(m => m.timestamp).toEventContextProperty('occurred') + .set(m => m.sequenceNumber).toEventContextProperty('sequenceNumber'))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/event-context/composite-key.md b/Documentation/client-snippets/projections/declarative/event-context/composite-key.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/event-context/composite-key.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/declarative/from-every/with-children.md b/Documentation/client-snippets/projections/declarative/from-every/with-children.md new file mode 100644 index 0000000..5fe6b84 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/from-every/with-children.md @@ -0,0 +1,54 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class OrderCreatedDeclarativeEveryChildren { + constructor(readonly orderNumber: string) {} +} + +@eventType() +class ItemAddedDeclarativeEveryChildren { + constructor( + readonly orderId: string, + readonly productId: string, + readonly productName: string, + readonly quantity: number + ) {} +} + +@eventType() +class ItemQuantityChangedDeclarativeEveryChildren { + constructor(readonly orderId: string, readonly productId: string, readonly quantity: number) {} +} + +class OrderItemDeclarativeEveryChildren { + productId = ''; + productName = ''; + quantity = 0; +} + +class OrderDeclarativeEveryChildren { + orderNumber = ''; + lastModified = new Date(); + items: OrderItemDeclarativeEveryChildren[] = []; +} + +@projection() +class OrderDeclarativeEveryChildrenProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(OrderCreatedDeclarativeEveryChildren) + .fromEvery(_ => _ + .set(m => m.lastModified) + .toEventContextProperty('occurred')) + .children(m => m.items, children => children + .identifiedBy(m => m.productId) + .from(ItemAddedDeclarativeEveryChildren, _ => _ + .usingKey(e => e.productId) + .usingParentKey(e => e.orderId)) + .from(ItemQuantityChangedDeclarativeEveryChildren, _ => _ + .usingKey(e => e.productId) + .usingParentKey(e => e.orderId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/functions/add-subtract.md b/Documentation/client-snippets/projections/declarative/functions/add-subtract.md new file mode 100644 index 0000000..b0bf08a --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/functions/add-subtract.md @@ -0,0 +1,37 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecFunctionsAccountOpened { + constructor(readonly number: string) {} +} + +@eventType() +class DecFunctionsMoneyDeposited { + constructor(readonly amount: number) {} +} + +@eventType() +class DecFunctionsMoneyWithdrawn { + constructor(readonly amount: number) {} +} + +class DecFunctionsAccount { + number = ''; + balance = 0; +} + +@projection() +class DecFunctionsAccountProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .autoMap() + .from(DecFunctionsAccountOpened, _ => _ + .set(m => m.balance).toValue(0)) + .from(DecFunctionsMoneyDeposited, _ => _ + .add(m => m.balance).with(e => e.amount)) + .from(DecFunctionsMoneyWithdrawn, _ => _ + .subtract(m => m.balance).with(e => e.amount)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/functions/combined.md b/Documentation/client-snippets/projections/declarative/functions/combined.md new file mode 100644 index 0000000..bb8f0d5 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/functions/combined.md @@ -0,0 +1,25 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecFunctionsTransaction { + constructor(readonly amount: number) {} +} + +class DecFunctionsTransactionSummary { + transactionCount = 0; + totalAmount = 0; + processedEvents = 0; +} + +@projection() +class DecFunctionsTransactionSummaryProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(DecFunctionsTransaction, _ => _ + .count(m => m.transactionCount) + .add(m => m.totalAmount).with(e => e.amount) + .increment(m => m.processedEvents)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/functions/count.md b/Documentation/client-snippets/projections/declarative/functions/count.md new file mode 100644 index 0000000..552c894 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/functions/count.md @@ -0,0 +1,31 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecFunctionsUserLoggedIn { + constructor(readonly username: string) {} +} + +@eventType() +class DecFunctionsUserPerformedAction { + constructor(readonly username: string, readonly actionType: string) {} +} + +class DecFunctionsUserActivity { + username = ''; + loginCount = 0; + actionCount = 0; +} + +@projection() +class DecFunctionsUserActivityProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .autoMap() + .from(DecFunctionsUserLoggedIn, _ => _ + .count(m => m.loginCount)) + .from(DecFunctionsUserPerformedAction, _ => _ + .count(m => m.actionCount)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/joins/basic.md b/Documentation/client-snippets/projections/declarative/joins/basic.md index de7b505..7460f76 100644 --- a/Documentation/client-snippets/projections/declarative/joins/basic.md +++ b/Documentation/client-snippets/projections/declarative/joins/basic.md @@ -1,28 +1,5 @@ ```typescript -import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; - -@eventType() -class DecJoinsUserCreated { - name = ''; - email = ''; -} - -@eventType() -class DecJoinsUserAssignedToGroup { - userId = ''; - groupId = ''; -} - -@eventType() -class DecJoinsGroupCreated { - name = ''; - description = ''; -} - -@eventType() -class DecJoinsGroupRenamed { - newName = ''; -} +import { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; @projection() class DecJoinsUserProjection implements IProjectionFor { diff --git a/Documentation/client-snippets/projections/declarative/joins/children-join.md b/Documentation/client-snippets/projections/declarative/joins/children-join.md new file mode 100644 index 0000000..ece35b0 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/joins/children-join.md @@ -0,0 +1,37 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecJoinsChildTaskAssigned { + constructor(readonly taskId: string, readonly projectId: string) {} +} + +@eventType() +class DecJoinsChildProjectCreated { + constructor(readonly name: string) {} +} + +class DecJoinsChildTask { + taskId = ''; + projectId = ''; + projectName: string | null = null; +} + +class DecJoinsChildProjectBoard { + tasks: DecJoinsChildTask[] = []; +} + +@projection() +class DecJoinsChildProjectBoardProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .children(m => m.tasks, children => children + .identifiedBy(e => e.taskId) + .autoMap() + .from(DecJoinsChildTaskAssigned, b => b + .usingKey(e => e.taskId)) + .join(DecJoinsChildProjectCreated, j => j + .on(m => m.projectId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/joins/events.md b/Documentation/client-snippets/projections/declarative/joins/events.md new file mode 100644 index 0000000..a93ac2f --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/joins/events.md @@ -0,0 +1,25 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +// User stream events +@eventType() +class DecJoinsUserCreated { + constructor(readonly name: string, readonly email: string) {} +} + +@eventType() +class DecJoinsUserAssignedToGroup { + constructor(readonly userId: string, readonly groupId: string) {} +} + +// Group stream events +@eventType() +class DecJoinsGroupCreated { + constructor(readonly name: string, readonly description: string) {} +} + +@eventType() +class DecJoinsGroupRenamed { + constructor(readonly newName: string) {} +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/automap.md b/Documentation/client-snippets/projections/declarative/nested/automap.md new file mode 100644 index 0000000..1522ca3 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/automap.md @@ -0,0 +1,44 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class SliceCreatedForNestedAutoMap { + constructor(readonly name: string) {} +} + +@eventType() +class CommandSetForNestedAutoMap { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandUpdatedForNestedAutoMap { + constructor(readonly schema: string) {} +} + +@eventType() +class CommandClearedForNestedAutoMap { +} + +class CommandItemForNestedAutoMap { + name = ''; + schema = ''; +} + +class SliceForNestedAutoMap { + name = ''; + command: CommandItemForNestedAutoMap | null = null; +} + +@projection() +class SliceProjectionForNestedAutoMap implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(SliceCreatedForNestedAutoMap) + .nested(m => m.command, nested => nested + .from(CommandSetForNestedAutoMap) + .from(CommandUpdatedForNestedAutoMap) + .clearWith(CommandClearedForNestedAutoMap)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/basic.md b/Documentation/client-snippets/projections/declarative/nested/basic.md new file mode 100644 index 0000000..9af4096 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/basic.md @@ -0,0 +1,38 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class SliceCreatedForNestedBasic { + constructor(readonly name: string) {} +} + +@eventType() +class CommandSetForDeclarativeNestedBasic { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandClearedForDeclarativeNestedBasic { +} + +class CommandItemForNestedBasic { + name = ''; + schema = ''; +} + +class SliceForNestedBasic { + name = ''; + command: CommandItemForNestedBasic | null = null; +} + +@projection() +class SliceProjectionForNestedBasic implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(SliceCreatedForNestedBasic) + .nested(m => m.command, nested => nested + .from(CommandSetForDeclarativeNestedBasic) + .clearWith(CommandClearedForDeclarativeNestedBasic)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/employee-contract-events.md b/Documentation/client-snippets/projections/declarative/nested/employee-contract-events.md new file mode 100644 index 0000000..b1e2acc --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/employee-contract-events.md @@ -0,0 +1,27 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +@eventType() +class EmployeeHiredForNestedContractEvents { + constructor(readonly name: string, readonly department: string) {} +} + +@eventType() +class ContractStartedForNestedContractEvents { + constructor( + readonly contractId: string, + readonly startDate: string, + readonly endDate: string, + readonly type: string + ) {} +} + +@eventType() +class ContractExtendedForNestedContractEvents { + constructor(readonly newEndDate: string) {} +} + +@eventType() +class ContractEndedForNestedContractEvents { +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/employee-contract.md b/Documentation/client-snippets/projections/declarative/nested/employee-contract.md new file mode 100644 index 0000000..6a83505 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/employee-contract.md @@ -0,0 +1,53 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class EmployeeHiredWithNestedContract { + constructor(readonly name: string, readonly department: string) {} +} + +@eventType() +class ContractStartedWithNestedContract { + constructor( + readonly contractId: string, + readonly startDate: string, + readonly endDate: string, + readonly type: string + ) {} +} + +@eventType() +class ContractExtendedWithNestedContract { + constructor(readonly newEndDate: string) {} +} + +@eventType() +class ContractEndedWithNestedContract { +} + +class ContractForNestedEmployee { + contractId = ''; + startDate = ''; + endDate = ''; + type = ''; +} + +class EmployeeWithNestedContract { + name = ''; + department = ''; + activeContract: ContractForNestedEmployee | null = null; +} + +@projection() +class EmployeeProjectionWithNestedContract implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(EmployeeHiredWithNestedContract) + .nested(m => m.activeContract, contract => contract + .from(ContractStartedWithNestedContract) + .from(ContractExtendedWithNestedContract, b => b + .set(m => m.endDate).to(e => e.newEndDate)) + .clearWith(ContractEndedWithNestedContract)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/events.md b/Documentation/client-snippets/projections/declarative/nested/events.md new file mode 100644 index 0000000..a92bc98 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/events.md @@ -0,0 +1,17 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +@eventType() +class SliceCreatedForNestedEvents { + constructor(readonly name: string) {} +} + +@eventType() +class CommandSetForNestedEvents { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandClearedForNestedEvents { +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/multiple-from-events.md b/Documentation/client-snippets/projections/declarative/nested/multiple-from-events.md new file mode 100644 index 0000000..f552870 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/multiple-from-events.md @@ -0,0 +1,52 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class SliceCreatedForNestedUpdates { + constructor(readonly name: string) {} +} + +@eventType() +class CommandSetForNestedUpdates { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandRenamedForNestedUpdates { + constructor(readonly newName: string) {} +} + +@eventType() +class CommandSchemaUpdatedForNestedUpdates { + constructor(readonly updatedSchema: string) {} +} + +@eventType() +class CommandClearedForNestedUpdates { +} + +class CommandItemForNestedUpdates { + name = ''; + schema = ''; +} + +class SliceForNestedUpdates { + name = ''; + command: CommandItemForNestedUpdates | null = null; +} + +@projection() +class SliceProjectionForNestedUpdates implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(SliceCreatedForNestedUpdates) + .nested(m => m.command, nested => nested + .from(CommandSetForNestedUpdates) + .from(CommandRenamedForNestedUpdates, b => b + .set(m => m.name).to(e => e.newName)) + .from(CommandSchemaUpdatedForNestedUpdates, b => b + .set(m => m.schema).to(e => e.updatedSchema)) + .clearWith(CommandClearedForNestedUpdates)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/multiple-nested.md b/Documentation/client-snippets/projections/declarative/nested/multiple-nested.md new file mode 100644 index 0000000..fc628a0 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/multiple-nested.md @@ -0,0 +1,55 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class SliceCreatedWithMultipleNested { + constructor(readonly name: string) {} +} + +@eventType() +class CommandSetWithMultipleNested { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandClearedWithMultipleNested { +} + +@eventType() +class ValidationConfiguredWithMultipleNested { + constructor(readonly ruleName: string) {} +} + +@eventType() +class ValidationRemovedWithMultipleNested { +} + +class CommandItemWithMultipleNested { + name = ''; + schema = ''; +} + +class ValidationConfigWithMultipleNested { + ruleName = ''; +} + +class SliceWithMultipleNested { + name = ''; + command: CommandItemWithMultipleNested | null = null; + validation: ValidationConfigWithMultipleNested | null = null; +} + +@projection() +class SliceProjectionWithMultipleNested implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(SliceCreatedWithMultipleNested) + .nested(m => m.command, nested => nested + .from(CommandSetWithMultipleNested) + .clearWith(CommandClearedWithMultipleNested)) + .nested(m => m.validation, nested => nested + .from(ValidationConfiguredWithMultipleNested) + .clearWith(ValidationRemovedWithMultipleNested)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/nested-in-children.md b/Documentation/client-snippets/projections/declarative/nested/nested-in-children.md new file mode 100644 index 0000000..72e34d3 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/nested-in-children.md @@ -0,0 +1,55 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class ProjectCreatedWithNestedChildren { + constructor(readonly name: string) {} +} + +@eventType() +class TaskAddedWithNestedChild { + constructor(readonly taskId: string, readonly title: string) {} +} + +@eventType() +class TaskAssignedWithNestedChild { + constructor(readonly taskId: string, readonly name: string, readonly email: string) {} +} + +@eventType() +class TaskUnassignedWithNestedChild { + constructor(readonly taskId: string) {} +} + +class AssigneeForNestedChild { + name = ''; + email = ''; +} + +class TaskWithNestedAssignee { + taskId = ''; + title = ''; + assignee: AssigneeForNestedChild | null = null; +} + +class ProjectWithDeclarativeNestedChildren { + name = ''; + tasks: TaskWithNestedAssignee[] = []; +} + +@projection() +class ProjectProjectionWithDeclarativeNestedChildren implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(ProjectCreatedWithNestedChildren) + .children(m => m.tasks, tasks => tasks + .identifiedBy(m => m.taskId) + .from(TaskAddedWithNestedChild, b => b + .usingKey(e => e.taskId)) + .nested(m => m.assignee, assignee => assignee + .from(TaskAssignedWithNestedChild, b => b + .usingKey(e => e.taskId)) + .clearWith(TaskUnassignedWithNestedChild))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/product-promotion.md b/Documentation/client-snippets/projections/declarative/nested/product-promotion.md new file mode 100644 index 0000000..bad98a6 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/product-promotion.md @@ -0,0 +1,40 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class ProductListedWithNestedPromotion { + constructor(readonly name: string, readonly basePrice: number) {} +} + +@eventType() +class PromotionAppliedWithNestedPromotion { + constructor(readonly label: string, readonly discountPercent: number, readonly validUntil: Date) {} +} + +@eventType() +class PromotionRemovedWithNestedPromotion { +} + +class PromotionForNestedProduct { + label = ''; + discountPercent = 0; + validUntil = new Date(); +} + +class ProductWithNestedPromotion { + name = ''; + basePrice = 0; + promotion: PromotionForNestedProduct | null = null; +} + +@projection() +class ProductProjectionWithNestedPromotion implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(ProductListedWithNestedPromotion) + .nested(m => m.promotion, promotion => promotion + .from(PromotionAppliedWithNestedPromotion) + .clearWith(PromotionRemovedWithNestedPromotion)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/nested/read-model.md b/Documentation/client-snippets/projections/declarative/nested/read-model.md new file mode 100644 index 0000000..29527d5 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/nested/read-model.md @@ -0,0 +1,11 @@ +```typescript +class CommandItemForNestedCommand { + name = ''; + schema = ''; +} + +class SliceWithNestedCommand { + name = ''; + command: CommandItemForNestedCommand | null = null; +} +``` diff --git a/Documentation/client-snippets/projections/declarative/remove-with-join/basic.md b/Documentation/client-snippets/projections/declarative/remove-with-join/basic.md new file mode 100644 index 0000000..186ca7f --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/remove-with-join/basic.md @@ -0,0 +1,51 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecRemoveWithJoinBasicUserCreated { + constructor(readonly name: string) {} +} + +@eventType() +class DecRemoveWithJoinBasicUserAddedToGroup { + constructor(readonly userId: string, readonly groupId: string) {} +} + +@eventType() +class DecRemoveWithJoinBasicGroupCreated { + constructor(readonly name: string) {} +} + +@eventType() +class DecRemoveWithJoinBasicGroupDeleted { +} + +class DecRemoveWithJoinBasicUserGroup { + groupId = ''; + name = ''; + joinedAt = new Date(); +} + +class DecRemoveWithJoinBasicUser { + name = ''; + groups: DecRemoveWithJoinBasicUserGroup[] = []; +} + +@projection() +class DecRemoveWithJoinBasicUserProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .autoMap() + .from(DecRemoveWithJoinBasicUserCreated) + .children(m => m.groups, children => children + .identifiedBy(e => e.groupId) + .autoMap() + .from(DecRemoveWithJoinBasicUserAddedToGroup, _ => _ + .usingParentKey(e => e.userId) + .set(m => m.joinedAt).toEventContextProperty('occurred')) + .join(DecRemoveWithJoinBasicGroupCreated, _ => _ + .on(m => m.groupId)) + .removedWithJoin(DecRemoveWithJoinBasicGroupDeleted)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/remove-with-join/events.md b/Documentation/client-snippets/projections/declarative/remove-with-join/events.md new file mode 100644 index 0000000..1e95abf --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/remove-with-join/events.md @@ -0,0 +1,60 @@ +```typescript +import { eventType } from '@cratis/chronicle'; + +@eventType() +class DecRemoveWithJoinUserRegistered { + constructor(readonly username: string, readonly email: string) {} +} + +@eventType() +class DecRemoveWithJoinUserJoinedGroup { + constructor(readonly userId: string, readonly groupId: string, readonly role: string) {} +} + +@eventType() +class DecRemoveWithJoinUserLeftGroup { + constructor(readonly userId: string, readonly groupId: string) {} +} + +@eventType() +class DecRemoveWithJoinGroupCreated { + constructor(readonly groupName: string, readonly groupType: string) {} +} + +@eventType() +class DecRemoveWithJoinGroupDisbanded { +} + +@eventType() +class DecRemoveWithJoinDeveloperOnboarded { + constructor(readonly name: string, readonly skills: string[]) {} +} + +@eventType() +class DecRemoveWithJoinDeveloperAssignedToProject { + constructor( + readonly developerId: string, + readonly projectId: string, + readonly role: string, + readonly allocation: number + ) {} +} + +@eventType() +class DecRemoveWithJoinDeveloperUnassignedFromProject { + constructor(readonly developerId: string, readonly projectId: string) {} +} + +@eventType() +class DecRemoveWithJoinProjectInitiated { + constructor(readonly projectName: string, readonly priority: string, readonly deadline: Date) {} +} + +@eventType() +class DecRemoveWithJoinProjectCancelled { +} + +@eventType() +class DecRemoveWithJoinProjectCompleted { +} +``` diff --git a/Documentation/client-snippets/projections/declarative/remove-with-join/explicit-keys.md b/Documentation/client-snippets/projections/declarative/remove-with-join/explicit-keys.md new file mode 100644 index 0000000..713033d --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/remove-with-join/explicit-keys.md @@ -0,0 +1,54 @@ +```typescript +import { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class DecRemoveWithJoinExplicitEmployeeHired { + constructor(readonly name: string) {} +} + +@eventType() +class DecRemoveWithJoinExplicitEmployeeAssignedToProject { + constructor(readonly employeeId: string, readonly projectId: string) {} +} + +@eventType() +class DecRemoveWithJoinExplicitProjectCreated { + constructor(readonly name: string) {} +} + +@eventType() +class DecRemoveWithJoinExplicitProjectCancelled { + constructor(readonly projectId: string) {} +} + +class DecRemoveWithJoinExplicitEmployeeProject { + projectId = ''; + name = ''; + assignedAt = new Date(); +} + +class DecRemoveWithJoinExplicitEmployee { + name = ''; + projects: DecRemoveWithJoinExplicitEmployeeProject[] = []; +} + +@projection() +class DecRemoveWithJoinExplicitEmployeeProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .autoMap() + .from(DecRemoveWithJoinExplicitEmployeeHired) + .children(m => m.projects, children => children + .identifiedBy(e => e.projectId) + .autoMap() + .from(DecRemoveWithJoinExplicitEmployeeAssignedToProject, _ => _ + .usingParentKey(e => e.employeeId) + .usingKey(e => e.projectId) + .set(m => m.assignedAt).toEventContextProperty('occurred')) + .join(DecRemoveWithJoinExplicitProjectCreated, _ => _ + .on(m => m.projectId)) + .removedWithJoin(DecRemoveWithJoinExplicitProjectCancelled, _ => _ + .usingKey(e => e.projectId))); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/remove-with-join/project-assignments-example.md b/Documentation/client-snippets/projections/declarative/remove-with-join/project-assignments-example.md new file mode 100644 index 0000000..0eaf1f0 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/remove-with-join/project-assignments-example.md @@ -0,0 +1,27 @@ +```typescript +import { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@projection() +class DecRemoveWithJoinDeveloperProjectsProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .autoMap() + .from(DecRemoveWithJoinDeveloperOnboarded, _ => _ + .set(m => m.developerId).toEventSourceId() + .set(m => m.onboardedAt).toEventContextProperty('occurred')) + .children(m => m.currentProjects, children => children + .identifiedBy(e => e.projectId) + .autoMap() + .from(DecRemoveWithJoinDeveloperAssignedToProject, _ => _ + .usingParentKey(e => e.developerId) + .usingKey(e => e.projectId) + .set(m => m.assignedAt).toEventContextProperty('occurred')) + .join(DecRemoveWithJoinProjectInitiated, _ => _ + .on(m => m.projectId)) + .removedWith(DecRemoveWithJoinDeveloperUnassignedFromProject, _ => _ + .usingKey(e => e.projectId)) + .removedWithJoin(DecRemoveWithJoinProjectCancelled) + .removedWithJoin(DecRemoveWithJoinProjectCompleted)); + } +} +``` diff --git a/Documentation/client-snippets/projections/declarative/remove-with-join/read-models.md b/Documentation/client-snippets/projections/declarative/remove-with-join/read-models.md new file mode 100644 index 0000000..b2d12ee --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/remove-with-join/read-models.md @@ -0,0 +1,35 @@ +```typescript +class DecRemoveWithJoinGroupMembership { + groupId = ''; + groupName = ''; + groupType = ''; + joinedAt = new Date(); + role = ''; +} + +class DecRemoveWithJoinUserProfile { + userId = ''; + username = ''; + email = ''; + registeredAt = new Date(); + memberships: DecRemoveWithJoinGroupMembership[] = []; +} + +class DecRemoveWithJoinProjectAssignment { + projectId = ''; + projectName = ''; + priority = ''; + deadline = new Date(); + assignedAt = new Date(); + role = ''; + allocation = 0; +} + +class DecRemoveWithJoinDeveloperProfile { + developerId = ''; + name = ''; + skills: string[] = []; + onboardedAt = new Date(); + currentProjects: DecRemoveWithJoinProjectAssignment[] = []; +} +``` diff --git a/Documentation/client-snippets/projections/declarative/remove-with-join/user-groups-example.md b/Documentation/client-snippets/projections/declarative/remove-with-join/user-groups-example.md new file mode 100644 index 0000000..d106833 --- /dev/null +++ b/Documentation/client-snippets/projections/declarative/remove-with-join/user-groups-example.md @@ -0,0 +1,26 @@ +```typescript +import { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@projection() +class DecRemoveWithJoinGroupMembershipProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .autoMap() + .from(DecRemoveWithJoinUserRegistered, _ => _ + .set(m => m.userId).toEventSourceId() + .set(m => m.registeredAt).toEventContextProperty('occurred')) + .children(m => m.memberships, children => children + .identifiedBy(e => e.groupId) + .autoMap() + .from(DecRemoveWithJoinUserJoinedGroup, _ => _ + .usingParentKey(e => e.userId) + .usingKey(e => e.groupId) + .set(m => m.joinedAt).toEventContextProperty('occurred')) + .join(DecRemoveWithJoinGroupCreated, _ => _ + .on(m => m.groupId)) + .removedWith(DecRemoveWithJoinUserLeftGroup, _ => _ + .usingKey(e => e.groupId)) + .removedWithJoin(DecRemoveWithJoinGroupDisbanded)); + } +} +``` diff --git a/Documentation/client-snippets/projections/filtering/tagging.md b/Documentation/client-snippets/projections/filtering/tagging.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/filtering/tagging.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/filtering/with-reactor.md b/Documentation/client-snippets/projections/filtering/with-reactor.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/filtering/with-reactor.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/filtering/with-reducer.md b/Documentation/client-snippets/projections/filtering/with-reducer.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/filtering/with-reducer.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/model-bound/children/no-automap.md b/Documentation/client-snippets/projections/model-bound/children/no-automap.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/children/no-automap.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/model-bound/clearing/basic.md b/Documentation/client-snippets/projections/model-bound/clearing/basic.md new file mode 100644 index 0000000..2be1d9b --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/clearing/basic.md @@ -0,0 +1,19 @@ +```typescript title="Clear a scalar member" +import { clearWith, eventType, fromEvent, readModel, setFrom } from '@cratis/chronicle'; + +@eventType() +class MbClearingProjectNoted { + note = ''; +} + +@eventType() +class MbClearingProjectNoteCleared {} + +@readModel() +@fromEvent(MbClearingProjectNoted) +class MbClearingProjectNotes { + @setFrom(MbClearingProjectNoted, 'note') + @clearWith(MbClearingProjectNoteCleared) + note: string | undefined = undefined; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/clearing/child.md b/Documentation/client-snippets/projections/model-bound/clearing/child.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/clearing/child.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/model-bound/clearing/fluent.md b/Documentation/client-snippets/projections/model-bound/clearing/fluent.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/clearing/fluent.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/model-bound/clearing/nested-member.md b/Documentation/client-snippets/projections/model-bound/clearing/nested-member.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/clearing/nested-member.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/model-bound/clearing/nullable.md b/Documentation/client-snippets/projections/model-bound/clearing/nullable.md new file mode 100644 index 0000000..08af8a2 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/clearing/nullable.md @@ -0,0 +1,28 @@ +```typescript title="A member has to be able to hold no value" +import { clearWith, eventType, fromEvent, readModel, setFrom } from '@cratis/chronicle'; + +@eventType() +class MbClearingShiftPlanned { + constructor( + readonly assignee: string, + readonly hours: number + ) {} +} + +@eventType() +class MbClearingShiftReleased {} + +@readModel() +@fromEvent(MbClearingShiftPlanned) +class MbClearingShift { + // Optional, so "nobody is assigned" is a state the member can actually hold. + @setFrom(MbClearingShiftPlanned, 'assignee') + @clearWith(MbClearingShiftReleased) + assignee: string | undefined = undefined; + + // Optional for the same reason: 0 hours is a number of hours, not the absence of one. + @setFrom(MbClearingShiftPlanned, 'hours') + @clearWith(MbClearingShiftReleased) + hours: number | undefined = undefined; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/clearing/set-value-null.md b/Documentation/client-snippets/projections/model-bound/clearing/set-value-null.md new file mode 100644 index 0000000..ecbb435 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/clearing/set-value-null.md @@ -0,0 +1,21 @@ +```typescript +import { eventType, fromEvent, Guid, readModel, setValue } from '@cratis/chronicle'; + +@eventType() +class MbClearingInvoiceIssued { + constructor(readonly reference: string) {} +} + +@eventType() +class MbClearingInvoiceVoided { +} + +@readModel() +@fromEvent(MbClearingInvoiceIssued) +class MbClearingInvoice { + id: Guid = Guid.empty; + + @setValue(MbClearingInvoiceVoided, null) + reference: string | null = null; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/convention-based/aggregate-only.md b/Documentation/client-snippets/projections/model-bound/convention-based/aggregate-only.md new file mode 100644 index 0000000..c96437d --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/convention-based/aggregate-only.md @@ -0,0 +1,27 @@ +```typescript +import { count, eventType, fromEvent, Guid, readModel, setFrom } from '@cratis/chronicle'; + +@eventType() +class AggOnlyArrangementSet { + constructor(readonly location: string) {} +} + +@eventType() +class AggOnlyCandidateSubmitted { + constructor(readonly name: string, readonly location: string) {} +} + +// AggOnlyCandidateSubmitted is subscribed only to be counted, so its identically named +// location is not auto-mapped over the value sourced from AggOnlyArrangementSet. +@readModel() +@fromEvent(AggOnlyArrangementSet) +class AggOnlyAssignmentSummary { + id: Guid = Guid.empty; + + @setFrom(AggOnlyArrangementSet, 'location') + location = ''; + + @count(AggOnlyCandidateSubmitted) + candidateCount = 0; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/convention-based/no-auto-map-property.md b/Documentation/client-snippets/projections/model-bound/convention-based/no-auto-map-property.md new file mode 100644 index 0000000..1f625f7 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/convention-based/no-auto-map-property.md @@ -0,0 +1,33 @@ +```typescript title="Exclude a single property from convention mapping" +import { eventType, fromEvent, noAutoMap, readModel, setFrom } from '@cratis/chronicle'; + +@eventType() +class NoAutoMapWorkArrangementSet { + constructor( + readonly location: string, + readonly workMode: number + ) {} +} + +@eventType() +class NoAutoMapCandidateSubmitted { + constructor( + readonly name: string, + readonly location: string + ) {} +} + +@readModel() +@fromEvent(NoAutoMapWorkArrangementSet) +class NoAutoMapAssignmentSummary { + // location is sourced only from NoAutoMapWorkArrangementSet. NoAutoMapCandidateSubmitted is + // value-mapped (for candidateName) and also carries a location; @noAutoMap stops that location + // from being auto-mapped over the explicit value, while every other property keeps mapping. + @setFrom(NoAutoMapWorkArrangementSet, 'location') + @noAutoMap + location = ''; + + @setFrom(NoAutoMapCandidateSubmitted, 'name') + candidateName = ''; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/event-sequence-source/event-log.md b/Documentation/client-snippets/projections/model-bound/event-sequence-source/event-log.md new file mode 100644 index 0000000..46d698a --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/event-sequence-source/event-log.md @@ -0,0 +1,16 @@ +```typescript +import { eventLog, eventType, fromEvent, readModel, setFrom } from '@cratis/chronicle'; + +@eventType() +class MbEventSeqLocalEvent { + data = ''; +} + +@readModel() +@fromEvent(MbEventSeqLocalEvent) +@eventLog +class MbEventSeqLocalSnapshot { + @setFrom(MbEventSeqLocalEvent, 'data') + data = ''; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/event-sequence-source/model-bound.md b/Documentation/client-snippets/projections/model-bound/event-sequence-source/model-bound.md new file mode 100644 index 0000000..a0d8881 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/event-sequence-source/model-bound.md @@ -0,0 +1,16 @@ +```typescript +import { eventSequence, eventType, fromEvent, readModel, setFrom } from '@cratis/chronicle'; + +@eventType() +class MbEventSeqOrderPlaced { + amount = 0; +} + +@readModel() +@fromEvent(MbEventSeqOrderPlaced) +@eventSequence('custom-sequence') +class MbEventSeqOrderSummary { + @setFrom(MbEventSeqOrderPlaced, 'amount') + totalAmount = 0; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/from-all/attribute-convention.md b/Documentation/client-snippets/projections/model-bound/from-all/attribute-convention.md new file mode 100644 index 0000000..83743a4 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/from-all/attribute-convention.md @@ -0,0 +1,30 @@ +```typescript title="Convention-based fromAll property" +import { eventType, fromAll, fromEvent, readModel } from '@cratis/chronicle'; + +@eventType() +class ProductRenamedFromAllConvention { + constructor( + readonly name: string, + readonly version: number + ) {} +} + +@eventType() +class ProductPriceChangedFromAllConvention { + constructor( + readonly price: number, + readonly version: number + ) {} +} + +@readModel() +@fromEvent(ProductRenamedFromAllConvention) +@fromEvent(ProductPriceChangedFromAllConvention) +class ProductVersionFromAllConvention { + name = ''; + price = 0; + + @fromAll() + version = 0; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/from-all/fluent-context.md b/Documentation/client-snippets/projections/model-bound/from-all/fluent-context.md new file mode 100644 index 0000000..d6ce99c --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/from-all/fluent-context.md @@ -0,0 +1,31 @@ +```typescript +import { eventType, Guid, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle'; + +@eventType() +class InventoryRegisteredFromAll { + constructor(readonly productName: string) {} +} + +@eventType() +class InventoryAdjustedFromAll { + constructor(readonly quantity: number) {} +} + +class InventoryStatusFromAll { + id: Guid = Guid.empty; + productName = ''; + lastUpdated = new Date(); +} + +@projection() +class InventoryStatusFromAllProjection implements IProjectionFor { + define(builder: IProjectionBuilderFor): void { + builder + .from(InventoryRegisteredFromAll) + .from(InventoryAdjustedFromAll) + .fromEvery(_ => _ + .set(m => m.lastUpdated) + .toEventContextProperty('occurred')); + } +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/basic-lifecycle.md b/Documentation/client-snippets/projections/model-bound/nested/basic-lifecycle.md new file mode 100644 index 0000000..e5d195d --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/basic-lifecycle.md @@ -0,0 +1,29 @@ +```typescript +import { clearWith, eventType, fromEvent, Guid, nested, readModel } from '@cratis/chronicle'; + +@eventType() +class CommandSetForNestedBasic { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandClearedForNestedBasic { +} + +@fromEvent(CommandSetForNestedBasic) +@clearWith(CommandClearedForNestedBasic) +class CommandItemNestedBasic { + name = ''; + schema = ''; +} + +@readModel() +@fromEvent(CommandSetForNestedBasic) +class SliceWithNestedCommandBasic { + id: Guid = Guid.empty; + name = ''; + + @nested + command: CommandItemNestedBasic | null = null; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/clear-with.md b/Documentation/client-snippets/projections/model-bound/nested/clear-with.md new file mode 100644 index 0000000..daa800d --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/clear-with.md @@ -0,0 +1,19 @@ +```typescript +import { clearWith, eventType, fromEvent } from '@cratis/chronicle'; + +@eventType() +class CommandSetForNestedClear { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandClearedForNestedClear { +} + +@fromEvent(CommandSetForNestedClear) +@clearWith(CommandClearedForNestedClear) +class CommandItemNestedClear { + name = ''; + schema = ''; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/complete.md b/Documentation/client-snippets/projections/model-bound/nested/complete.md new file mode 100644 index 0000000..c40b98d --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/complete.md @@ -0,0 +1,73 @@ +```typescript +import { clearWith, eventType, fromEvent, Guid, nested, readModel, setFrom } from '@cratis/chronicle'; + +@eventType() +class SliceCreatedForNestedComplete { + constructor(readonly name: string) {} +} + +@eventType() +class CommandSetForNestedComplete { + constructor( + readonly commandId: Guid, + readonly name: string, + readonly schema: string, + readonly rules: string, + readonly stateSchema: string + ) {} +} + +@eventType() +class CommandRenamedForNestedComplete { + constructor(readonly commandId: Guid, readonly name: string) {} +} + +@eventType() +class CommandDefinitionUpdatedForNestedComplete { + constructor( + readonly commandId: Guid, + readonly schema: string, + readonly rules: string, + readonly stateSchema: string + ) {} +} + +@eventType() +class CommandClearedForNestedComplete { +} + +@fromEvent(CommandSetForNestedComplete) +@fromEvent(CommandRenamedForNestedComplete) +@fromEvent(CommandDefinitionUpdatedForNestedComplete) +@clearWith(CommandClearedForNestedComplete) +class CommandItemNestedComplete { + @setFrom(CommandSetForNestedComplete, 'commandId') + id: Guid = Guid.empty; + + @setFrom(CommandSetForNestedComplete, 'name') + @setFrom(CommandRenamedForNestedComplete, 'name') + name = ''; + + @setFrom(CommandSetForNestedComplete, 'schema') + @setFrom(CommandDefinitionUpdatedForNestedComplete, 'schema') + schema = ''; + + @setFrom(CommandSetForNestedComplete, 'rules') + @setFrom(CommandDefinitionUpdatedForNestedComplete, 'rules') + rules = ''; + + @setFrom(CommandSetForNestedComplete, 'stateSchema') + @setFrom(CommandDefinitionUpdatedForNestedComplete, 'stateSchema') + stateSchema = ''; +} + +@readModel() +@fromEvent(SliceCreatedForNestedComplete) +class SliceNestedComplete { + id: Guid = Guid.empty; + name = ''; + + @nested + command: CommandItemNestedComplete | null = null; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/explicit-mapping.md b/Documentation/client-snippets/projections/model-bound/nested/explicit-mapping.md new file mode 100644 index 0000000..1239a6e --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/explicit-mapping.md @@ -0,0 +1,29 @@ +```typescript +import { clearWith, eventType, fromEvent, setFrom } from '@cratis/chronicle'; + +@eventType() +class CommandSetForNestedExplicit { + constructor(readonly commandName: string, readonly jsonSchema: string) {} +} + +@eventType() +class CommandSchemaUpdatedForNestedExplicit { + constructor(readonly updatedSchema: string) {} +} + +@eventType() +class CommandClearedForNestedExplicit { +} + +@fromEvent(CommandSetForNestedExplicit) +@fromEvent(CommandSchemaUpdatedForNestedExplicit) +@clearWith(CommandClearedForNestedExplicit) +class CommandItemNestedExplicit { + @setFrom(CommandSetForNestedExplicit, 'commandName') + name = ''; + + @setFrom(CommandSetForNestedExplicit, 'jsonSchema') + @setFrom(CommandSchemaUpdatedForNestedExplicit, 'updatedSchema') + schema = ''; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/multiple-clear-events.md b/Documentation/client-snippets/projections/model-bound/nested/multiple-clear-events.md new file mode 100644 index 0000000..c97fb3a --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/multiple-clear-events.md @@ -0,0 +1,24 @@ +```typescript +import { clearWith, eventType, fromEvent } from '@cratis/chronicle'; + +@eventType() +class CommandSetForNestedMultipleClear { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandClearedForNestedMultipleClear { +} + +@eventType() +class SliceArchivedForNestedMultipleClear { +} + +@fromEvent(CommandSetForNestedMultipleClear) +@clearWith(CommandClearedForNestedMultipleClear) +@clearWith(SliceArchivedForNestedMultipleClear) +class CommandItemNestedMultipleClear { + name = ''; + schema = ''; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/multiple-from-events.md b/Documentation/client-snippets/projections/model-bound/nested/multiple-from-events.md new file mode 100644 index 0000000..ca53ad0 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/multiple-from-events.md @@ -0,0 +1,31 @@ +```typescript +import { clearWith, eventType, fromEvent } from '@cratis/chronicle'; + +@eventType() +class CommandSetForNestedMultipleFrom { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandRenamedForNestedMultipleFrom { + constructor(readonly name: string) {} +} + +@eventType() +class CommandSchemaUpdatedForNestedMultipleFrom { + constructor(readonly schema: string) {} +} + +@eventType() +class CommandClearedForNestedMultipleFrom { +} + +@fromEvent(CommandSetForNestedMultipleFrom) +@fromEvent(CommandRenamedForNestedMultipleFrom) +@fromEvent(CommandSchemaUpdatedForNestedMultipleFrom) +@clearWith(CommandClearedForNestedMultipleFrom) +class CommandItemNestedMultipleFrom { + name = ''; + schema = ''; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/multiple-nested.md b/Documentation/client-snippets/projections/model-bound/nested/multiple-nested.md new file mode 100644 index 0000000..42ade05 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/multiple-nested.md @@ -0,0 +1,45 @@ +```typescript +import { clearWith, eventType, fromEvent, nested } from '@cratis/chronicle'; + +@eventType() +class CommandSetForNestedMultiple { + constructor(readonly name: string, readonly schema: string) {} +} + +@eventType() +class CommandClearedForNestedMultiple { +} + +@eventType() +class ValidationConfiguredForNestedMultiple { + constructor(readonly rules: string, readonly isStrict: boolean) {} +} + +@eventType() +class ValidationRemovedForNestedMultiple { +} + +@fromEvent(CommandSetForNestedMultiple) +@clearWith(CommandClearedForNestedMultiple) +class CommandItemNestedMultiple { + name = ''; + schema = ''; +} + +@fromEvent(ValidationConfiguredForNestedMultiple) +@clearWith(ValidationRemovedForNestedMultiple) +class ValidationConfigNestedMultiple { + rules = ''; + isStrict = false; +} + +class SliceWithMultipleNestedObjects { + name = ''; + + @nested + command: CommandItemNestedMultiple | null = null; + + @nested + validation: ValidationConfigNestedMultiple | null = null; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/nested-in-children.md b/Documentation/client-snippets/projections/model-bound/nested/nested-in-children.md new file mode 100644 index 0000000..0c1853a --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/nested-in-children.md @@ -0,0 +1,41 @@ +```typescript +import { childrenFrom, clearWith, eventType, fromEvent, Guid, nested } from '@cratis/chronicle'; + +@eventType() +class TaskAddedForNestedChildren { + constructor(readonly taskId: Guid, readonly title: string) {} +} + +@eventType() +class TaskAssignedForNestedChildren { + constructor(readonly taskId: Guid, readonly name: string, readonly email: string) {} +} + +@eventType() +class TaskUnassignedForNestedChildren { + constructor(readonly taskId: Guid) {} +} + +@fromEvent(TaskAssignedForNestedChildren) +@clearWith(TaskUnassignedForNestedChildren) +class TaskAssigneeNestedChild { + name = ''; + email = ''; +} + +class ProjectTaskWithNestedAssignee { + taskId: Guid = Guid.empty; + title = ''; + + @nested + assignee: TaskAssigneeNestedChild | null = null; +} + +class ProjectWithNestedChildren { + id: Guid = Guid.empty; + name = ''; + + @childrenFrom(TaskAddedForNestedChildren, 'taskId') + tasks: ProjectTaskWithNestedAssignee[] = []; +} +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/no-automap.md b/Documentation/client-snippets/projections/model-bound/nested/no-automap.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/no-automap.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/model-bound/nested/parent-property.md b/Documentation/client-snippets/projections/model-bound/nested/parent-property.md new file mode 100644 index 0000000..f835722 --- /dev/null +++ b/Documentation/client-snippets/projections/model-bound/nested/parent-property.md @@ -0,0 +1,13 @@ +```typescript +import { nested } from '@cratis/chronicle'; + +class NestedPropertyChild { + name = ''; + description = ''; +} + +class ParentWithNestedProperty { + @nested + child: NestedPropertyChild | null = null; +} +``` diff --git a/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/basic.md b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/basic.md new file mode 100644 index 0000000..8c7dab2 --- /dev/null +++ b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/basic.md @@ -0,0 +1,12 @@ +```typescript +interface PdlOrderSummary { + orderId: string; +} + +const result = await store.projections.query(` + projection OrderSummary + from OrderPlaced +`); + +const summaries = result.readModelEntries.map(json => JSON.parse(json) as PdlOrderSummary); +``` diff --git a/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/custom-sequence.md b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/custom-sequence.md new file mode 100644 index 0000000..7e98462 --- /dev/null +++ b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/custom-sequence.md @@ -0,0 +1,8 @@ +```typescript +const result = await store.projections.query( + ` + projection InboxMessages + from MessageReceived + `, + 'inbox'); +``` diff --git a/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/error-handling.md b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/error-handling.md new file mode 100644 index 0000000..ea55fb6 --- /dev/null +++ b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/error-handling.md @@ -0,0 +1,14 @@ +```typescript +import { UnableToQueryProjection } from '@cratis/chronicle'; + +try { + const result = await store.projections.query(` + projection Orders + from OrderPlaced + `); +} catch (error) { + if (error instanceof UnableToQueryProjection) { + console.log(error.message); + } +} +``` diff --git a/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/inferred-vs-explicit.md b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/inferred-vs-explicit.md new file mode 100644 index 0000000..81fe417 --- /dev/null +++ b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/inferred-vs-explicit.md @@ -0,0 +1,15 @@ +```typescript +// Inferred - schema derived from OrderPlaced and OrderShipped event properties +const inferred = await store.projections.query(` + projection Orders + from OrderPlaced + from OrderShipped +`); + +// Explicit - schema comes from the registered 'PdlOrderReadModel' type +const explicitResult = await store.projections.query(` + projection Orders => PdlOrderReadModel + from OrderPlaced + from OrderShipped +`); +``` diff --git a/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/type-mismatch.md b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/type-mismatch.md new file mode 100644 index 0000000..d534299 --- /dev/null +++ b/Documentation/client-snippets/projections/projection-declaration-language/adhoc-querying/type-mismatch.md @@ -0,0 +1,9 @@ +```typescript +// This declaration will throw UnableToQueryProjection: +// OrderPlaced.value is a string, but OrderShipped.value is a number +const result = await store.projections.query(` + projection Bad + from OrderPlaced // value: string + from OrderShipped // value: number -> incompatible types +`); +``` diff --git a/Documentation/client-snippets/projections/tagging-projections/mixed-approach.md b/Documentation/client-snippets/projections/tagging-projections/mixed-approach.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/tagging-projections/mixed-approach.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/tagging-projections/model-bound.md b/Documentation/client-snippets/projections/tagging-projections/model-bound.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/tagging-projections/model-bound.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/tagging-projections/multiple-tags-multiple-attributes.md b/Documentation/client-snippets/projections/tagging-projections/multiple-tags-multiple-attributes.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/tagging-projections/multiple-tags-multiple-attributes.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/tagging-projections/multiple-tags-single-attribute.md b/Documentation/client-snippets/projections/tagging-projections/multiple-tags-single-attribute.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/tagging-projections/multiple-tags-single-attribute.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/tagging-projections/single-tag.md b/Documentation/client-snippets/projections/tagging-projections/single-tag.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/tagging-projections/single-tag.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/projections/tagging-projections/tag-categories.md b/Documentation/client-snippets/projections/tagging-projections/tag-categories.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/projections/tagging-projections/tag-categories.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/delivery-identity/basic.md b/Documentation/client-snippets/reactors/delivery-identity/basic.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/delivery-identity/basic.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/delivery-identity/with-once-only.md b/Documentation/client-snippets/reactors/delivery-identity/with-once-only.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/delivery-identity/with-once-only.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/event-processing/dependencies.md b/Documentation/client-snippets/reactors/event-processing/dependencies.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/event-processing/dependencies.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/event-processing/read-model-key.md b/Documentation/client-snippets/reactors/event-processing/read-model-key.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/event-processing/read-model-key.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/event-processing/supported-signatures.md b/Documentation/client-snippets/reactors/event-processing/supported-signatures.md new file mode 100644 index 0000000..70f69bd --- /dev/null +++ b/Documentation/client-snippets/reactors/event-processing/supported-signatures.md @@ -0,0 +1,17 @@ +```typescript +import { EventContext } from '@cratis/chronicle'; + +interface ReactorHandlerSignatures { + methodName(event: TEvent): void; + methodName(event: TEvent, context: EventContext): void; + + methodNameAsync(event: TEvent): Promise; + methodNameAsync(event: TEvent, context: EventContext): Promise; + + methodNameReturningAsync(event: TEvent): Promise; + methodNameReturningAsync(event: TEvent, context: EventContext): Promise; + + methodNameReturning(event: TEvent): TResult; + methodNameReturning(event: TEvent, context: EventContext): TResult; +} +``` diff --git a/Documentation/client-snippets/reactors/event-sequence/event-log-attribute.md b/Documentation/client-snippets/reactors/event-sequence/event-log-attribute.md new file mode 100644 index 0000000..50ae50a --- /dev/null +++ b/Documentation/client-snippets/reactors/event-sequence/event-log-attribute.md @@ -0,0 +1,18 @@ +```typescript +import { EventContext, eventType, Guid, reactor } from '@cratis/chronicle'; + +@eventType() +class EventSequenceLogReactorOrderPlaced { + constructor(readonly orderId: Guid) {} +} + +// No eventSequenceId given - observes the default event log +@reactor() +class EventSequenceLocalAuditReactor { + async eventSequenceLogReactorOrderPlaced(event: EventSequenceLogReactorOrderPlaced, context: EventContext): Promise { + await this.writeAudit(event.orderId, context.occurred); + } + + private async writeAudit(orderId: Guid, occurred: Date): Promise {} +} +``` diff --git a/Documentation/client-snippets/reactors/event-sequence/event-sequence-attribute.md b/Documentation/client-snippets/reactors/event-sequence/event-sequence-attribute.md new file mode 100644 index 0000000..10bd0d5 --- /dev/null +++ b/Documentation/client-snippets/reactors/event-sequence/event-sequence-attribute.md @@ -0,0 +1,17 @@ +```typescript +import { EventContext, eventType, reactor } from '@cratis/chronicle'; + +@eventType() +class EventSequenceReactorShipmentDispatched { + constructor(readonly trackingNumber: string) {} +} + +@reactor('', 'fulfillment-events') +class EventSequenceShipmentReactor { + async eventSequenceReactorShipmentDispatched(event: EventSequenceReactorShipmentDispatched, context: EventContext): Promise { + await this.notifyCarrier(event.trackingNumber); + } + + private async notifyCarrier(trackingNumber: string): Promise {} +} +``` diff --git a/Documentation/client-snippets/reactors/external-event-store-subscriptions/automatic-routing.md b/Documentation/client-snippets/reactors/external-event-store-subscriptions/automatic-routing.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/external-event-store-subscriptions/automatic-routing.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/external-event-store-subscriptions/observer-level-event-store.md b/Documentation/client-snippets/reactors/external-event-store-subscriptions/observer-level-event-store.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/external-event-store-subscriptions/observer-level-event-store.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/filtering/by-event-source-type.md b/Documentation/client-snippets/reactors/filtering/by-event-source-type.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/filtering/by-event-source-type.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/filtering/by-event-stream-type.md b/Documentation/client-snippets/reactors/filtering/by-event-stream-type.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/filtering/by-event-stream-type.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/filtering/by-tag.md b/Documentation/client-snippets/reactors/filtering/by-tag.md new file mode 100644 index 0000000..4bd602b --- /dev/null +++ b/Documentation/client-snippets/reactors/filtering/by-tag.md @@ -0,0 +1,25 @@ +```typescript +import { EventContext, eventType, filterEventsByTag, IEventStore, reactor } from '@cratis/chronicle'; + +@eventType() +class ReactorsFilteringByTagOrderPlaced { + constructor(readonly totalAmount: number) {} +} + +class ReactorsFilteringByTagOrderService { + constructor(private readonly store: IEventStore) {} + + async placePriorityOrder(eventSourceId: string, totalAmount: number): Promise { + await this.store.eventLog.append( + eventSourceId, + new ReactorsFilteringByTagOrderPlaced(totalAmount), + { tags: ['priority'] }); + } +} + +@reactor() +@filterEventsByTag('priority') +class ReactorsFilteringPriorityOrderNotifier { + async reactorsFilteringByTagOrderPlaced(_event: ReactorsFilteringByTagOrderPlaced, _context: EventContext): Promise {} +} +``` diff --git a/Documentation/client-snippets/reactors/filtering/combine-filters.md b/Documentation/client-snippets/reactors/filtering/combine-filters.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/filtering/combine-filters.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/filtering/metadata-example.md b/Documentation/client-snippets/reactors/filtering/metadata-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/filtering/metadata-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/filtering/multiple-tags.md b/Documentation/client-snippets/reactors/filtering/multiple-tags.md new file mode 100644 index 0000000..c0a8a80 --- /dev/null +++ b/Documentation/client-snippets/reactors/filtering/multiple-tags.md @@ -0,0 +1,15 @@ +```typescript +import { EventContext, eventType, filterEventsByTag, reactor } from '@cratis/chronicle'; + +@eventType() +class ReactorsFilteringMultiTagOrderPlaced { + constructor(readonly totalAmount: number) {} +} + +@reactor() +@filterEventsByTag('priority') +@filterEventsByTag('express') +class ReactorsFilteringFastTrackOrderNotifier { + async reactorsFilteringMultiTagOrderPlaced(_event: ReactorsFilteringMultiTagOrderPlaced, _context: EventContext): Promise {} +} +``` diff --git a/Documentation/client-snippets/reactors/filtering/tag-vs-filter.md b/Documentation/client-snippets/reactors/filtering/tag-vs-filter.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/filtering/tag-vs-filter.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/once-only/basic.md b/Documentation/client-snippets/reactors/once-only/basic.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/once-only/basic.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/side-effects/custom-handler.md b/Documentation/client-snippets/reactors/side-effects/custom-handler.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/custom-handler.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/side-effects/explicit-metadata.md b/Documentation/client-snippets/reactors/side-effects/explicit-metadata.md new file mode 100644 index 0000000..9b06c11 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/explicit-metadata.md @@ -0,0 +1,25 @@ +```typescript +import { EventContext, EventForEventSourceId, eventType, reactor } from '@cratis/chronicle'; + +@eventType() +class ExplicitMetadataBookReserved { + constructor(readonly isbn: string = '', readonly memberId: string = '') {} +} + +@eventType() +class ExplicitMetadataMemberActivityRecorded { + constructor(readonly isbn: string = '') {} +} + +@reactor() +class ExplicitMetadataReactor { + async explicitMetadataBookReserved(event: ExplicitMetadataBookReserved, context: EventContext): Promise { + return { + eventSourceId: event.memberId, + event: new ExplicitMetadataMemberActivityRecorded(event.isbn), + eventStreamType: 'members', + subject: event.memberId + }; + } +} +``` diff --git a/Documentation/client-snippets/reactors/side-effects/fan-out.md b/Documentation/client-snippets/reactors/side-effects/fan-out.md new file mode 100644 index 0000000..417444f --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/fan-out.md @@ -0,0 +1,28 @@ +```typescript +import { EventContext, EventForEventSourceId, eventType, reactor } from '@cratis/chronicle'; + +@eventType() +class FanOutBookReserved { + constructor(readonly isbn: string = '', readonly memberId: string = '') {} +} + +@eventType() +class FanOutMemberActivityRecorded { + constructor(readonly isbn: string = '') {} +} + +@eventType() +class FanOutStockDecreased { + constructor(readonly isbn: string = '', readonly quantity: number = 0) {} +} + +@reactor() +class ReservationFanOutReactor { + async fanOutBookReserved(event: FanOutBookReserved, context: EventContext): Promise { + return [ + { eventSourceId: event.memberId, event: new FanOutMemberActivityRecorded(event.isbn) }, + { eventSourceId: event.isbn, event: new FanOutStockDecreased(event.isbn, 1) } + ]; + } +} +``` diff --git a/Documentation/client-snippets/reactors/side-effects/provide-event-source-id.md b/Documentation/client-snippets/reactors/side-effects/provide-event-source-id.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/provide-event-source-id.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/side-effects/provide-event-stream-id.md b/Documentation/client-snippets/reactors/side-effects/provide-event-stream-id.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/provide-event-stream-id.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/side-effects/provide-subject.md b/Documentation/client-snippets/reactors/side-effects/provide-subject.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/provide-subject.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/side-effects/source-event.md b/Documentation/client-snippets/reactors/side-effects/source-event.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/source-event.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/side-effects/stream-and-source-type.md b/Documentation/client-snippets/reactors/side-effects/stream-and-source-type.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/stream-and-source-type.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reactors/side-effects/with-concurrency-scopes.md b/Documentation/client-snippets/reactors/side-effects/with-concurrency-scopes.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reactors/side-effects/with-concurrency-scopes.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/empty-child-collections/declaring.md b/Documentation/client-snippets/read-models/empty-child-collections/declaring.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/empty-child-collections/declaring.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/materialized-pagination/named-constants.md b/Documentation/client-snippets/read-models/materialized-pagination/named-constants.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/materialized-pagination/named-constants.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/materialized-pagination/observing-in-service.md b/Documentation/client-snippets/read-models/materialized-pagination/observing-in-service.md new file mode 100644 index 0000000..08fda69 --- /dev/null +++ b/Documentation/client-snippets/read-models/materialized-pagination/observing-in-service.md @@ -0,0 +1,13 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class MaterializedPaginationProductDashboard { + constructor(private readonly store: IEventStore) {} + + async start(updateView: (products: MaterializedPaginationProduct[]) => void): Promise { + for await (const products of this.store.readModels.materialized.observeInstances(MaterializedPaginationProduct, 0, 100)) { + updateView(products); + } + } +} +``` diff --git a/Documentation/client-snippets/read-models/materialized-pagination/paged-endpoint.md b/Documentation/client-snippets/read-models/materialized-pagination/paged-endpoint.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/materialized-pagination/paged-endpoint.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/reacting-to-changes/collection.md b/Documentation/client-snippets/read-models/reacting-to-changes/collection.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/reacting-to-changes/collection.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/reacting-to-changes/dependencies.md b/Documentation/client-snippets/read-models/reacting-to-changes/dependencies.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/reacting-to-changes/dependencies.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/reacting-to-changes/materialized.md b/Documentation/client-snippets/read-models/reacting-to-changes/materialized.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/reacting-to-changes/materialized.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/reacting-to-changes/reactor.md b/Documentation/client-snippets/read-models/reacting-to-changes/reactor.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/reacting-to-changes/reactor.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/reacting-to-changes/side-effects.md b/Documentation/client-snippets/read-models/reacting-to-changes/side-effects.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/read-models/reacting-to-changes/side-effects.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/read-models/releasing-pii/collection.md b/Documentation/client-snippets/read-models/releasing-pii/collection.md new file mode 100644 index 0000000..ef08755 --- /dev/null +++ b/Documentation/client-snippets/read-models/releasing-pii/collection.md @@ -0,0 +1,11 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ReleasingPiiSupportTicketBatchService { + constructor(private readonly store: IEventStore) {} + + releaseAll(tickets: ReleasingPiiSupportTicket[]): Promise { + return this.store.readModels.releaseMany(ReleasingPiiSupportTicket, tickets); + } +} +``` diff --git a/Documentation/client-snippets/read-models/releasing-pii/read-model.md b/Documentation/client-snippets/read-models/releasing-pii/read-model.md new file mode 100644 index 0000000..45d508b --- /dev/null +++ b/Documentation/client-snippets/read-models/releasing-pii/read-model.md @@ -0,0 +1,32 @@ +```typescript +import { EventContext, eventType, pii, reducer, subject } from '@cratis/chronicle'; + +@eventType() +class ReleasingPiiSupportTicketOpened { + constructor(readonly customerId: string, readonly requesterName: string) {} +} + +class ReleasingPiiSupportTicket { + // The ticket's own id identifies the ticket, not the person the PII belongs to - @subject() + // tells release() to use customerId as the encryption key's owner instead. Without it, + // release() would fall back to id. + id = ''; + + @subject() + customerId = ''; + + @pii('The name of the person who opened the ticket') + requesterName = ''; +} + +@reducer('', undefined, ReleasingPiiSupportTicket) +class ReleasingPiiSupportTicketReducer { + releasingPiiSupportTicketOpened( + event: ReleasingPiiSupportTicketOpened, + current: ReleasingPiiSupportTicket | undefined, + context: EventContext + ): ReleasingPiiSupportTicket { + return { id: context.eventSourceId, customerId: event.customerId, requesterName: event.requesterName }; + } +} +``` diff --git a/Documentation/client-snippets/read-models/releasing-pii/single-instance.md b/Documentation/client-snippets/read-models/releasing-pii/single-instance.md new file mode 100644 index 0000000..1a04df6 --- /dev/null +++ b/Documentation/client-snippets/read-models/releasing-pii/single-instance.md @@ -0,0 +1,11 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ReleasingPiiSupportTicketService { + constructor(private readonly store: IEventStore) {} + + release(ticket: ReleasingPiiSupportTicket): Promise { + return this.store.readModels.release(ReleasingPiiSupportTicket, ticket); + } +} +``` diff --git a/Documentation/client-snippets/read-models/releasing-pii/watch.md b/Documentation/client-snippets/read-models/releasing-pii/watch.md new file mode 100644 index 0000000..7a67479 --- /dev/null +++ b/Documentation/client-snippets/read-models/releasing-pii/watch.md @@ -0,0 +1,18 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ReleasingPiiSupportTicketWatcher { + constructor(private readonly store: IEventStore) {} + + async start(): Promise { + for await (const changeset of this.store.readModels.watch(ReleasingPiiSupportTicket)) { + if (changeset.removed) { + continue; + } + + const ticket = await this.store.readModels.release(ReleasingPiiSupportTicket, changeset.readModel); + console.log(`${changeset.key}: ${ticket.requesterName}`); + } + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/accumulation-pattern.md b/Documentation/client-snippets/reducers/event-processing/accumulation-pattern.md new file mode 100644 index 0000000..25ac897 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/accumulation-pattern.md @@ -0,0 +1,24 @@ +```typescript +import { eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingMetricRecorded { + constructor(readonly value: number) {} +} + +class EventProcessingStatistics { + sum = 0; + count = 0; + average = 0; +} + +@reducer('', undefined, EventProcessingStatistics) +class EventProcessingStatisticsReducer { + eventProcessingMetricRecorded(event: EventProcessingMetricRecorded, current: EventProcessingStatistics | undefined): EventProcessingStatistics { + const sum = (current?.sum ?? 0) + event.value; + const count = (current?.count ?? 0) + 1; + + return { sum, count, average: sum / count }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/async-patterns.md b/Documentation/client-snippets/reducers/event-processing/async-patterns.md new file mode 100644 index 0000000..b876b56 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/async-patterns.md @@ -0,0 +1,11 @@ +```typescript +import { EventContext } from '@cratis/chronicle'; + +interface EventProcessingAsyncPatterns { + // Async without context + process(event: TEvent, current: TReadModel | undefined): Promise; + + // Async with context + processWithContext(event: TEvent, current: TReadModel | undefined, context: EventContext): Promise; +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/basic-sync-pattern.md b/Documentation/client-snippets/reducers/event-processing/basic-sync-pattern.md new file mode 100644 index 0000000..a575334 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/basic-sync-pattern.md @@ -0,0 +1,6 @@ +```typescript +interface EventProcessingBasicSyncPattern { + // Process event and return new state + process(event: TEvent, current: TReadModel | undefined): TReadModel; +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/collection-building-pattern.md b/Documentation/client-snippets/reducers/event-processing/collection-building-pattern.md new file mode 100644 index 0000000..544cefe --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/collection-building-pattern.md @@ -0,0 +1,34 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingCustomerAction { + constructor(readonly type: string, readonly description: string) {} +} + +class EventProcessingActivity { + type = ''; + timestamp = new Date(); + description = ''; +} + +class EventProcessingCustomerActivityLog { + activities: EventProcessingActivity[] = []; +} + +@reducer('', undefined, EventProcessingCustomerActivityLog) +class EventProcessingCustomerActivityLogReducer { + eventProcessingCustomerAction( + event: EventProcessingCustomerAction, + current: EventProcessingCustomerActivityLog | undefined, + context: EventContext + ): EventProcessingCustomerActivityLog { + // Copy rather than mutate — current.activities may still be referenced by a held snapshot + const activities = [...(current?.activities ?? [])]; + + activities.push({ type: event.type, timestamp: context.occurred, description: event.description }); + + return { activities }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/conditional-processing-pattern.md b/Documentation/client-snippets/reducers/event-processing/conditional-processing-pattern.md new file mode 100644 index 0000000..92d766a --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/conditional-processing-pattern.md @@ -0,0 +1,49 @@ +```typescript +import { EventContext, eventType, Guid, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingAccountOpened { + constructor(readonly accountId: Guid) {} +} + +@eventType() +class EventProcessingDepositMade { + constructor(readonly amount: number) {} +} + +@eventType() +class EventProcessingAccountClosed {} + +class EventProcessingAccount { + accountId: Guid = Guid.empty; + balance = 0; + isActive = false; +} + +@reducer('', undefined, EventProcessingAccount) +class EventProcessingAccountReducer { + eventProcessingAccountOpened(event: EventProcessingAccountOpened, current: EventProcessingAccount | undefined): EventProcessingAccount { + return { accountId: event.accountId, balance: 0, isActive: true }; + } + + eventProcessingDepositMade( + event: EventProcessingDepositMade, + current: EventProcessingAccount | undefined, + context: EventContext + ): EventProcessingAccount | undefined { + // Skip if account doesn't exist or is not active + if (!current || !current.isActive) return current; + + return { ...current, balance: current.balance + event.amount }; + } + + eventProcessingAccountClosed( + event: EventProcessingAccountClosed, + current: EventProcessingAccount | undefined + ): EventProcessingAccount | undefined { + if (!current) return undefined; + + return { ...current, isActive: false }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/event-context-shape.md b/Documentation/client-snippets/reducers/event-processing/event-context-shape.md new file mode 100644 index 0000000..b1c1245 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/event-context-shape.md @@ -0,0 +1,14 @@ +```typescript +import { CausationEntry, EventType } from '@cratis/chronicle'; + +// Illustrative subset of the real EventContext shape from '@cratis/chronicle' +interface EventProcessingEventContextShape { + readonly sequenceNumber: bigint; + readonly eventSourceId: string; + readonly eventType: EventType; + readonly occurred: Date; + readonly correlationId: string; + readonly causation: ReadonlyArray; +} +// ... see EventContext for the authoritative member list +``` diff --git a/Documentation/client-snippets/reducers/event-processing/first-event.md b/Documentation/client-snippets/reducers/event-processing/first-event.md new file mode 100644 index 0000000..b059f26 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/first-event.md @@ -0,0 +1,42 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingDataRecorded { + constructor(readonly value: number) {} +} + +class EventProcessingAnalytics { + eventCount = 0; + firstEventTime = new Date(); + lastEventTime = new Date(); + totalValue = 0; +} + +@reducer('', undefined, EventProcessingAnalytics) +class EventProcessingAnalyticsReducer { + eventProcessingDataRecorded( + event: EventProcessingDataRecorded, + current: EventProcessingAnalytics | undefined, + context: EventContext + ): EventProcessingAnalytics { + if (!current) { + // First event - initialize state + return { + eventCount: 1, + firstEventTime: context.occurred, + lastEventTime: context.occurred, + totalValue: event.value + }; + } + + // Update existing state + return { + ...current, + eventCount: current.eventCount + 1, + lastEventTime: context.occurred, + totalValue: current.totalValue + event.value + }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/method-discovery.md b/Documentation/client-snippets/reducers/event-processing/method-discovery.md new file mode 100644 index 0000000..474aa18 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/method-discovery.md @@ -0,0 +1,42 @@ +```typescript +import { EventContext, eventType, Guid, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingOrderCreated { + constructor(readonly orderId: Guid) {} +} + +@eventType() +class EventProcessingItemAdded { + constructor(readonly price: number) {} +} + +class EventProcessingOrderSummary { + orderId: Guid = Guid.empty; + total = 0; + lastUpdated = new Date(); +} + +// Method names must be the exact camelCase of the event's class name - +// Chronicle discovers handlers by name, not by parameter type. +@reducer('', undefined, EventProcessingOrderSummary) +class EventProcessingOrderSummaryReducer { + eventProcessingOrderCreated( + event: EventProcessingOrderCreated, + current: EventProcessingOrderSummary | undefined, + context: EventContext + ): EventProcessingOrderSummary { + return { orderId: event.orderId, total: 0, lastUpdated: context.occurred }; + } + + eventProcessingItemAdded( + event: EventProcessingItemAdded, + current: EventProcessingOrderSummary | undefined, + context: EventContext + ): EventProcessingOrderSummary | undefined { + if (!current) return undefined; // Skip if no order exists + + return { ...current, total: current.total + event.price, lastUpdated: context.occurred }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/minimize-object-creation.md b/Documentation/client-snippets/reducers/event-processing/minimize-object-creation.md new file mode 100644 index 0000000..e3dc371 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/minimize-object-creation.md @@ -0,0 +1,28 @@ +```typescript +import { eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingMinimalMetricRecorded { + constructor(readonly value: number) {} +} + +class EventProcessingMinimalStats { + count = 0; + sum = 0; +} + +@reducer('', undefined, EventProcessingMinimalStats) +class EventProcessingMinimalStatsReducer { + // Efficient - only creates a new object when needed + eventProcessingMinimalMetricRecorded( + event: EventProcessingMinimalMetricRecorded, + current: EventProcessingMinimalStats | undefined + ): EventProcessingMinimalStats { + if (!current) { + return { count: 1, sum: event.value }; + } + + return { count: current.count + 1, sum: current.sum + event.value }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/pattern-with-context.md b/Documentation/client-snippets/reducers/event-processing/pattern-with-context.md new file mode 100644 index 0000000..a05f0c8 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/pattern-with-context.md @@ -0,0 +1,8 @@ +```typescript +import { EventContext } from '@cratis/chronicle'; + +interface EventProcessingPatternWithContext { + // Access occurred time, correlation ID, etc. + process(event: TEvent, current: TReadModel | undefined, context: EventContext): TReadModel; +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/recording-errors.md b/Documentation/client-snippets/reducers/event-processing/recording-errors.md new file mode 100644 index 0000000..1ffc4bd --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/recording-errors.md @@ -0,0 +1,25 @@ +```typescript +import { eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingInvalidDataDetected { + constructor(readonly reason: string) {} +} + +class EventProcessingValidationResult { + isValid = true; + errors: string[] = []; +} + +@reducer('', undefined, EventProcessingValidationResult) +class EventProcessingValidationResultReducer { + eventProcessingInvalidDataDetected( + event: EventProcessingInvalidDataDetected, + current: EventProcessingValidationResult | undefined + ): EventProcessingValidationResult { + const errors = [...(current?.errors ?? []), event.reason]; + + return { isValid: false, errors }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/reuse-collections.md b/Documentation/client-snippets/reducers/event-processing/reuse-collections.md new file mode 100644 index 0000000..c51b5a0 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/reuse-collections.md @@ -0,0 +1,30 @@ +```typescript +import { eventType, Guid, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingReuseItemAdded { + constructor(readonly itemId: Guid, readonly name: string) {} +} + +class EventProcessingItem { + itemId: Guid = Guid.empty; + name = ''; +} + +class EventProcessingItemList { + items: EventProcessingItem[] = []; +} + +@reducer('', undefined, EventProcessingItemList) +class EventProcessingItemListReducer { + eventProcessingReuseItemAdded( + event: EventProcessingReuseItemAdded, + current: EventProcessingItemList | undefined + ): EventProcessingItemList { + // Copy rather than mutate current.items directly — a held snapshot may still reference it + const items = [...(current?.items ?? []), { itemId: event.itemId, name: event.name }]; + + return { items }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/skip-invalid-state.md b/Documentation/client-snippets/reducers/event-processing/skip-invalid-state.md new file mode 100644 index 0000000..a971dff --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/skip-invalid-state.md @@ -0,0 +1,26 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingSkipItemAdded { + constructor(readonly price: number) {} +} + +class EventProcessingSkipOrderSummary { + total = 0; +} + +@reducer('', undefined, EventProcessingSkipOrderSummary) +class EventProcessingSkipOrderSummaryReducer { + eventProcessingSkipItemAdded( + event: EventProcessingSkipItemAdded, + current: EventProcessingSkipOrderSummary | undefined, + context: EventContext + ): EventProcessingSkipOrderSummary | undefined { + // Can't add items if order doesn't exist + if (!current) return undefined; + + return { total: current.total + event.price }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/state-transitions-pattern.md b/Documentation/client-snippets/reducers/event-processing/state-transitions-pattern.md new file mode 100644 index 0000000..d815566 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/state-transitions-pattern.md @@ -0,0 +1,56 @@ +```typescript +import { EventContext, eventType, Guid, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingOrderCreatedForStatus { + constructor(readonly orderId: Guid) {} +} + +@eventType() +class EventProcessingOrderPaid { + constructor(readonly orderId: Guid) {} +} + +@eventType() +class EventProcessingOrderShipped { + constructor(readonly orderId: Guid) {} +} + +@eventType() +class EventProcessingOrderDelivered { + constructor(readonly orderId: Guid) {} +} + +@eventType() +class EventProcessingOrderCancelled { + constructor(readonly orderId: Guid) {} +} + +class EventProcessingOrderStatus { + state = ''; + lastUpdated = new Date(); +} + +@reducer('', undefined, EventProcessingOrderStatus) +class EventProcessingOrderStatusReducer { + eventProcessingOrderCreatedForStatus(event: EventProcessingOrderCreatedForStatus, current: EventProcessingOrderStatus | undefined, context: EventContext): EventProcessingOrderStatus { + return { state: 'Created', lastUpdated: context.occurred }; + } + + eventProcessingOrderPaid(event: EventProcessingOrderPaid, current: EventProcessingOrderStatus | undefined, context: EventContext): EventProcessingOrderStatus { + return { state: 'Paid', lastUpdated: context.occurred }; + } + + eventProcessingOrderShipped(event: EventProcessingOrderShipped, current: EventProcessingOrderStatus | undefined, context: EventContext): EventProcessingOrderStatus { + return { state: 'Shipped', lastUpdated: context.occurred }; + } + + eventProcessingOrderDelivered(event: EventProcessingOrderDelivered, current: EventProcessingOrderStatus | undefined, context: EventContext): EventProcessingOrderStatus { + return { state: 'Delivered', lastUpdated: context.occurred }; + } + + eventProcessingOrderCancelled(event: EventProcessingOrderCancelled, current: EventProcessingOrderStatus | undefined, context: EventContext): EventProcessingOrderStatus { + return { state: 'Cancelled', lastUpdated: context.occurred }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/time-based-aggregation-pattern.md b/Documentation/client-snippets/reducers/event-processing/time-based-aggregation-pattern.md new file mode 100644 index 0000000..bc32672 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/time-based-aggregation-pattern.md @@ -0,0 +1,28 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingHourlyMetricRecorded { + constructor(readonly value: number) {} +} + +class EventProcessingHourlyMetrics { + metricsByHour: Record = {}; +} + +@reducer('', undefined, EventProcessingHourlyMetrics) +class EventProcessingHourlyMetricsReducer { + eventProcessingHourlyMetricRecorded( + event: EventProcessingHourlyMetricRecorded, + current: EventProcessingHourlyMetrics | undefined, + context: EventContext + ): EventProcessingHourlyMetrics { + const metricsByHour = { ...(current?.metricsByHour ?? {}) }; + const hour = context.occurred.getHours(); + + metricsByHour[hour] = (metricsByHour[hour] ?? 0) + event.value; + + return { metricsByHour }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-processing/using-event-context.md b/Documentation/client-snippets/reducers/event-processing/using-event-context.md new file mode 100644 index 0000000..da62506 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-processing/using-event-context.md @@ -0,0 +1,31 @@ +```typescript +import { EventContext, eventType, Guid, reducer } from '@cratis/chronicle'; + +@eventType() +class EventProcessingContextOrderPlaced { + constructor(readonly orderId: Guid, readonly amount: number) {} +} + +class EventProcessingOrderSummaryWithContext { + orderId: Guid = Guid.empty; + total = 0; + placedAt = new Date(); + correlationId = ''; +} + +@reducer('', undefined, EventProcessingOrderSummaryWithContext) +class EventProcessingOrderSummaryWithContextReducer { + eventProcessingContextOrderPlaced( + event: EventProcessingContextOrderPlaced, + current: EventProcessingOrderSummaryWithContext | undefined, + context: EventContext + ): EventProcessingOrderSummaryWithContext { + return { + orderId: event.orderId, + total: event.amount, + placedAt: context.occurred, + correlationId: context.correlationId + }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-sequence/event-log-attribute.md b/Documentation/client-snippets/reducers/event-sequence/event-log-attribute.md new file mode 100644 index 0000000..8ef85b5 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-sequence/event-log-attribute.md @@ -0,0 +1,24 @@ +```typescript +import { EventContext, eventType, Guid, reducer } from '@cratis/chronicle'; + +@eventType() +class EventSequenceLogOrderPlaced { + constructor(readonly orderId: Guid) {} +} + +class EventSequenceLogOrderAudit { + orderId: Guid = Guid.empty; +} + +// No eventSequenceId given - observes the default event log +@reducer('', undefined, EventSequenceLogOrderAudit) +class EventSequenceLocalAuditReducer { + eventSequenceLogOrderPlaced( + event: EventSequenceLogOrderPlaced, + current: EventSequenceLogOrderAudit | undefined, + context: EventContext + ): EventSequenceLogOrderAudit { + return { orderId: event.orderId }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/event-sequence/event-sequence-attribute.md b/Documentation/client-snippets/reducers/event-sequence/event-sequence-attribute.md new file mode 100644 index 0000000..5fc2d59 --- /dev/null +++ b/Documentation/client-snippets/reducers/event-sequence/event-sequence-attribute.md @@ -0,0 +1,22 @@ +```typescript +import { eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class EventSequenceShipmentDispatched { + constructor(readonly trackingNumber: string) {} +} + +class EventSequenceShipmentStatus { + trackingNumber = ''; +} + +@reducer('', 'fulfillment-events', EventSequenceShipmentStatus) +class EventSequenceShipmentReducer { + eventSequenceShipmentDispatched( + event: EventSequenceShipmentDispatched, + current: EventSequenceShipmentStatus | undefined + ): EventSequenceShipmentStatus { + return { trackingNumber: event.trackingNumber }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/external-event-store-subscriptions/automatic-routing.md b/Documentation/client-snippets/reducers/external-event-store-subscriptions/automatic-routing.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reducers/external-event-store-subscriptions/automatic-routing.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reducers/external-event-store-subscriptions/observer-level-event-store.md b/Documentation/client-snippets/reducers/external-event-store-subscriptions/observer-level-event-store.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reducers/external-event-store-subscriptions/observer-level-event-store.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reducers/filtering/by-event-source-type.md b/Documentation/client-snippets/reducers/filtering/by-event-source-type.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reducers/filtering/by-event-source-type.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reducers/filtering/by-event-stream-type.md b/Documentation/client-snippets/reducers/filtering/by-event-stream-type.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reducers/filtering/by-event-stream-type.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reducers/filtering/by-tag.md b/Documentation/client-snippets/reducers/filtering/by-tag.md new file mode 100644 index 0000000..799479d --- /dev/null +++ b/Documentation/client-snippets/reducers/filtering/by-tag.md @@ -0,0 +1,38 @@ +```typescript +import { eventType, filterEventsByTag, IEventStore, reducer } from '@cratis/chronicle'; + +@eventType() +class ReducersFilteringByTagOrderPlaced { + constructor(readonly totalAmount: number) {} +} + +class ReducersFilteringPriorityOrderTotals { + count = 0; + total = 0; +} + +@reducer('', undefined, ReducersFilteringPriorityOrderTotals) +@filterEventsByTag('priority') +class ReducersFilteringPriorityOrderTotalsReducer { + reducersFilteringByTagOrderPlaced( + event: ReducersFilteringByTagOrderPlaced, + current: ReducersFilteringPriorityOrderTotals | undefined + ): ReducersFilteringPriorityOrderTotals { + return { + count: (current?.count ?? 0) + 1, + total: (current?.total ?? 0) + event.totalAmount + }; + } +} + +class ReducersFilteringByTagOrderService { + constructor(private readonly store: IEventStore) {} + + async placePriorityOrder(eventSourceId: string, totalAmount: number): Promise { + await this.store.eventLog.append( + eventSourceId, + new ReducersFilteringByTagOrderPlaced(totalAmount), + { tags: ['priority'] }); + } +} +``` diff --git a/Documentation/client-snippets/reducers/filtering/combine-filters.md b/Documentation/client-snippets/reducers/filtering/combine-filters.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reducers/filtering/combine-filters.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reducers/filtering/metadata-example.md b/Documentation/client-snippets/reducers/filtering/metadata-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reducers/filtering/metadata-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reducers/filtering/multiple-tags.md b/Documentation/client-snippets/reducers/filtering/multiple-tags.md new file mode 100644 index 0000000..2a28635 --- /dev/null +++ b/Documentation/client-snippets/reducers/filtering/multiple-tags.md @@ -0,0 +1,24 @@ +```typescript +import { eventType, filterEventsByTag, reducer } from '@cratis/chronicle'; + +@eventType() +class ReducersFilteringMultiTagOrderPlaced { + constructor(readonly totalAmount: number) {} +} + +class ReducersFilteringFastTrackOrderTotals { + count = 0; +} + +@reducer('', undefined, ReducersFilteringFastTrackOrderTotals) +@filterEventsByTag('priority') +@filterEventsByTag('express') +class ReducersFilteringFastTrackOrderTotalsReducer { + reducersFilteringMultiTagOrderPlaced( + _event: ReducersFilteringMultiTagOrderPlaced, + current: ReducersFilteringFastTrackOrderTotals | undefined + ): ReducersFilteringFastTrackOrderTotals { + return { count: (current?.count ?? 0) + 1 }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/filtering/tag-vs-filter.md b/Documentation/client-snippets/reducers/filtering/tag-vs-filter.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/reducers/filtering/tag-vs-filter.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/reducers/getting-started/async-signatures.md b/Documentation/client-snippets/reducers/getting-started/async-signatures.md new file mode 100644 index 0000000..bc99565 --- /dev/null +++ b/Documentation/client-snippets/reducers/getting-started/async-signatures.md @@ -0,0 +1,23 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class ReducersAsyncSignaturesOrderPlaced { + constructor(readonly orderId: string) {} +} + +class ReducersAsyncSignaturesOrderSummary { + orderId = ''; +} + +@reducer('', undefined, ReducersAsyncSignaturesOrderSummary) +class ReducersAsyncSignaturesOrderSummaryReducer { + // Async without context + async reducersAsyncSignaturesOrderPlaced( + event: ReducersAsyncSignaturesOrderPlaced, + current: ReducersAsyncSignaturesOrderSummary | undefined + ): Promise { + return { orderId: event.orderId }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/getting-started/sync-signatures.md b/Documentation/client-snippets/reducers/getting-started/sync-signatures.md new file mode 100644 index 0000000..f836b53 --- /dev/null +++ b/Documentation/client-snippets/reducers/getting-started/sync-signatures.md @@ -0,0 +1,25 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class ReducersSyncSignaturesOrderPlaced { + constructor(readonly orderId: string) {} +} + +class ReducersSyncSignaturesOrderSummary { + orderId = ''; + lastUpdated = new Date(); +} + +@reducer('', undefined, ReducersSyncSignaturesOrderSummary) +class ReducersSyncSignaturesOrderSummaryReducer { + // Synchronous, with context + reducersSyncSignaturesOrderPlaced( + event: ReducersSyncSignaturesOrderPlaced, + current: ReducersSyncSignaturesOrderSummary | undefined, + context: EventContext + ): ReducersSyncSignaturesOrderSummary { + return { orderId: event.orderId, lastUpdated: context.occurred }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/development-testing.md b/Documentation/client-snippets/reducers/passive-reducers/development-testing.md new file mode 100644 index 0000000..3269b55 --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/development-testing.md @@ -0,0 +1,12 @@ +```typescript +import { reducer } from '@cratis/chronicle'; + +class PassiveReducersExperimentalMetrics { + sampleCount = 0; +} + +// Kept passive while experimental - flip isActive to true once the metric is trusted +@reducer('', undefined, PassiveReducersExperimentalMetrics, false) +class PassiveReducersExperimentalMetricsReducer { +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/historical-snapshots.md b/Documentation/client-snippets/reducers/passive-reducers/historical-snapshots.md new file mode 100644 index 0000000..c0c98f4 --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/historical-snapshots.md @@ -0,0 +1,16 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class PassiveReducersAccountBalance { + balance = 0; +} + +class PassiveReducersHistoricalBalanceService { + constructor(private readonly store: IEventStore) {} + + // Passive reducer computes state on-demand from historical events + getBalanceAtDate(accountId: string): Promise { + return this.store.readModels.getInstanceById(PassiveReducersAccountBalance, accountId); + } +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/on-demand-reports.md b/Documentation/client-snippets/reducers/passive-reducers/on-demand-reports.md new file mode 100644 index 0000000..1cb895f --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/on-demand-reports.md @@ -0,0 +1,36 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class PassiveReducersPaymentReceived { + constructor(readonly category: string, readonly amount: number) {} +} + +class PassiveReducersMonthlyRevenueReport { + totalRevenue = 0; + revenueByCategory: Record = {}; + month = 0; + year = 0; +} + +@reducer('', undefined, PassiveReducersMonthlyRevenueReport, false) +class PassiveReducersMonthlyRevenueReportReducer { + passiveReducersPaymentReceived( + event: PassiveReducersPaymentReceived, + current: PassiveReducersMonthlyRevenueReport | undefined, + context: EventContext + ): PassiveReducersMonthlyRevenueReport { + const revenue = current?.totalRevenue ?? 0; + const byCategory = { ...(current?.revenueByCategory ?? {}) }; + + byCategory[event.category] = (byCategory[event.category] ?? 0) + event.amount; + + return { + totalRevenue: revenue + event.amount, + revenueByCategory: byCategory, + month: context.occurred.getMonth() + 1, + year: context.occurred.getFullYear() + }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/passive-attribute.md b/Documentation/client-snippets/reducers/passive-reducers/passive-attribute.md new file mode 100644 index 0000000..0184b61 --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/passive-attribute.md @@ -0,0 +1,28 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class PassiveReducersTransactionCompleted { + constructor(readonly amount: number) {} +} + +class PassiveReducersAdHocReport { + totalRevenue = 0; + transactionCount = 0; + generatedAt = new Date(); +} + +@reducer('', undefined, PassiveReducersAdHocReport, false) +class PassiveReducersAdHocReportReducer { + passiveReducersTransactionCompleted( + event: PassiveReducersTransactionCompleted, + current: PassiveReducersAdHocReport | undefined, + context: EventContext + ): PassiveReducersAdHocReport { + const revenue = current?.totalRevenue ?? 0; + const count = current?.transactionCount ?? 0; + + return { totalRevenue: revenue + event.amount, transactionCount: count + 1, generatedAt: context.occurred }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/reducer-attribute.md b/Documentation/client-snippets/reducers/passive-reducers/reducer-attribute.md new file mode 100644 index 0000000..4047b5a --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/reducer-attribute.md @@ -0,0 +1,29 @@ +```typescript +import { EventContext, eventType, reducer } from '@cratis/chronicle'; + +@eventType() +class PassiveReducersDataRecorded { + constructor(readonly value: number) {} +} + +class PassiveReducersAnalytics { + recordCount = 0; + totalValue = 0; + lastUpdated = new Date(); +} + +// isActive: false — registered with the Kernel but does not automatically observe events +@reducer('', undefined, PassiveReducersAnalytics, false) +class PassiveReducersTemporaryAnalyticsReducer { + passiveReducersDataRecorded( + event: PassiveReducersDataRecorded, + current: PassiveReducersAnalytics | undefined, + context: EventContext + ): PassiveReducersAnalytics { + const count = current?.recordCount ?? 0; + const sum = current?.totalValue ?? 0; + + return { recordCount: count + 1, totalValue: sum + event.value, lastUpdated: context.occurred }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/retrieving-state.md b/Documentation/client-snippets/reducers/passive-reducers/retrieving-state.md new file mode 100644 index 0000000..18e7dc8 --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/retrieving-state.md @@ -0,0 +1,12 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class PassiveReducersReportingService { + constructor(private readonly store: IEventStore) {} + + // This triggers the passive reducer to compute state from events + generateReport(reportId: string): Promise { + return this.store.readModels.getInstanceById(PassiveReducersMonthlyRevenueReport, reportId); + } +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/switching-active-passive.md b/Documentation/client-snippets/reducers/passive-reducers/switching-active-passive.md new file mode 100644 index 0000000..926e6ca --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/switching-active-passive.md @@ -0,0 +1,12 @@ +```typescript +import { reducer } from '@cratis/chronicle'; + +class PassiveReducersSwitchableReadModel { + value = 0; +} + +// Was active, now passive +@reducer('', undefined, PassiveReducersSwitchableReadModel, false) +class PassiveReducersSwitchableReducer { +} +``` diff --git a/Documentation/client-snippets/reducers/passive-reducers/temporary-analysis.md b/Documentation/client-snippets/reducers/passive-reducers/temporary-analysis.md new file mode 100644 index 0000000..356f186 --- /dev/null +++ b/Documentation/client-snippets/reducers/passive-reducers/temporary-analysis.md @@ -0,0 +1,13 @@ +```typescript +import { reducer } from '@cratis/chronicle'; + +class PassiveReducersCustomerBehaviorAnalysis { + uniqueCustomers = 0; + averageOrderValue = 0; + ordersByHour: Record = {}; +} + +@reducer('', undefined, PassiveReducersCustomerBehaviorAnalysis, false) +class PassiveReducersCustomerBehaviorAnalysisReducer { +} +``` diff --git a/Documentation/client-snippets/reducers/tagging-reducers/mixed-approach.md b/Documentation/client-snippets/reducers/tagging-reducers/mixed-approach.md new file mode 100644 index 0000000..d77e190 --- /dev/null +++ b/Documentation/client-snippets/reducers/tagging-reducers/mixed-approach.md @@ -0,0 +1,12 @@ +```typescript +import { reducer, tag } from '@cratis/chronicle'; + +class TaggingReducersExecutiveDashboard { + metricCount = 0; +} + +@reducer('', undefined, TaggingReducersExecutiveDashboard) +@tag('Analytics', 'Reporting') +@tag('Executive') +class TaggingReducersExecutiveDashboardReducer {} +``` diff --git a/Documentation/client-snippets/reducers/tagging-reducers/multiple-tags-multiple-attributes.md b/Documentation/client-snippets/reducers/tagging-reducers/multiple-tags-multiple-attributes.md new file mode 100644 index 0000000..c9b2d22 --- /dev/null +++ b/Documentation/client-snippets/reducers/tagging-reducers/multiple-tags-multiple-attributes.md @@ -0,0 +1,13 @@ +```typescript +import { reducer, tag } from '@cratis/chronicle'; + +class TaggingReducersComplianceReport { + status = ''; +} + +@reducer('', undefined, TaggingReducersComplianceReport) +@tag('Analytics') +@tag('Compliance') +@tag('Auditing') +class TaggingReducersComplianceReportReducer {} +``` diff --git a/Documentation/client-snippets/reducers/tagging-reducers/multiple-tags-single-attribute.md b/Documentation/client-snippets/reducers/tagging-reducers/multiple-tags-single-attribute.md new file mode 100644 index 0000000..07ba235 --- /dev/null +++ b/Documentation/client-snippets/reducers/tagging-reducers/multiple-tags-single-attribute.md @@ -0,0 +1,11 @@ +```typescript +import { reducer, tag } from '@cratis/chronicle'; + +class TaggingReducersSalesReport { + totalSales = 0; +} + +@reducer('', undefined, TaggingReducersSalesReport) +@tag('Analytics', 'Reporting', 'Dashboard') +class TaggingReducersSalesReportReducer {} +``` diff --git a/Documentation/client-snippets/reducers/tagging-reducers/single-tag.md b/Documentation/client-snippets/reducers/tagging-reducers/single-tag.md new file mode 100644 index 0000000..b7ab98d --- /dev/null +++ b/Documentation/client-snippets/reducers/tagging-reducers/single-tag.md @@ -0,0 +1,27 @@ +```typescript +import { eventType, reducer, tag } from '@cratis/chronicle'; + +@eventType() +class TaggingReducersOrderPlaced { + constructor(readonly totalAmount: number) {} +} + +class TaggingReducersOrderAnalytics { + orderCount = 0; + totalAmount = 0; +} + +@reducer('', undefined, TaggingReducersOrderAnalytics) +@tag('Analytics') +class TaggingReducersOrderAnalyticsReducer { + taggingReducersOrderPlaced( + event: TaggingReducersOrderPlaced, + current: TaggingReducersOrderAnalytics | undefined + ): TaggingReducersOrderAnalytics { + return { + orderCount: (current?.orderCount ?? 0) + 1, + totalAmount: (current?.totalAmount ?? 0) + event.totalAmount + }; + } +} +``` diff --git a/Documentation/client-snippets/reducers/tagging-reducers/tag-categories.md b/Documentation/client-snippets/reducers/tagging-reducers/tag-categories.md new file mode 100644 index 0000000..39f27cb --- /dev/null +++ b/Documentation/client-snippets/reducers/tagging-reducers/tag-categories.md @@ -0,0 +1,18 @@ +```typescript +import { reducer, tag } from '@cratis/chronicle'; + +class TaggingReducersCategoryExamples { + id = ''; +} + +@reducer('', undefined, TaggingReducersCategoryExamples) +// By domain +@tag('Sales', 'Inventory', 'Customer') +// By purpose +@tag('Analytics', 'Reporting', 'Dashboard', 'Auditing') +// By stakeholder +@tag('Executive', 'Operations', 'Finance') +// By data type +@tag('Aggregates', 'Summaries', 'Metrics') +class TaggingReducersCategoryExamplesReducer {} +``` diff --git a/Documentation/client-snippets/scenarios/real-time-query/get-all.md b/Documentation/client-snippets/scenarios/real-time-query/get-all.md new file mode 100644 index 0000000..540c6c3 --- /dev/null +++ b/Documentation/client-snippets/scenarios/real-time-query/get-all.md @@ -0,0 +1,12 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ScenariosQueryOnLoanBooks { + constructor(private readonly store: IEventStore) {} + + async getOnLoan(): Promise { + const books = await this.store.readModels.getInstances(ScenariosQueryBook); + return books.filter(book => book.onLoan); + } +} +``` diff --git a/Documentation/client-snippets/scenarios/real-time-query/materialized-page.md b/Documentation/client-snippets/scenarios/real-time-query/materialized-page.md new file mode 100644 index 0000000..02b4b63 --- /dev/null +++ b/Documentation/client-snippets/scenarios/real-time-query/materialized-page.md @@ -0,0 +1,11 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ScenariosQueryBookPageService { + constructor(private readonly store: IEventStore) {} + + getPage(): Promise { + return this.store.readModels.materialized.getInstances(ScenariosQueryBook, 0, 20); + } +} +``` diff --git a/Documentation/client-snippets/scenarios/real-time-query/observe-page.md b/Documentation/client-snippets/scenarios/real-time-query/observe-page.md new file mode 100644 index 0000000..703fa40 --- /dev/null +++ b/Documentation/client-snippets/scenarios/real-time-query/observe-page.md @@ -0,0 +1,13 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ScenariosQueryLiveBookPage { + constructor(private readonly store: IEventStore) {} + + async subscribe(updateView: (books: ScenariosQueryBook[]) => void): Promise { + for await (const page of this.store.readModels.materialized.observeInstances(ScenariosQueryBook, 0, 50)) { + updateView(page); + } + } +} +``` diff --git a/Documentation/client-snippets/scenarios/real-time-query/watch-changes.md b/Documentation/client-snippets/scenarios/real-time-query/watch-changes.md new file mode 100644 index 0000000..e3faa9a --- /dev/null +++ b/Documentation/client-snippets/scenarios/real-time-query/watch-changes.md @@ -0,0 +1,17 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class ScenariosQueryBookWatcher { + constructor(private readonly store: IEventStore) {} + + async watch(): Promise { + for await (const changeset of this.store.readModels.watch(ScenariosQueryBook)) { + if (changeset.removed) { + continue; + } + + console.log(`${changeset.key}: on loan = ${changeset.readModel.onLoan}`); + } + } +} +``` diff --git a/Documentation/client-snippets/scenarios/test-a-slice/append-spec.md b/Documentation/client-snippets/scenarios/test-a-slice/append-spec.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/scenarios/test-a-slice/append-spec.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/scenarios/test-a-slice/constraint-spec.md b/Documentation/client-snippets/scenarios/test-a-slice/constraint-spec.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/scenarios/test-a-slice/constraint-spec.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/scenarios/test-a-slice/read-model-spec.md b/Documentation/client-snippets/scenarios/test-a-slice/read-model-spec.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/scenarios/test-a-slice/read-model-spec.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/sinks/index/enable-sql-sink.md b/Documentation/client-snippets/sinks/index/enable-sql-sink.md new file mode 100644 index 0000000..0e65ac4 --- /dev/null +++ b/Documentation/client-snippets/sinks/index/enable-sql-sink.md @@ -0,0 +1,9 @@ +```typescript +import { ChronicleOptions, WellKnownSinks } from '@cratis/chronicle'; + +function createSinksSqlOptions(): ChronicleOptions { + return ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { + defaultSinkTypeId: WellKnownSinks.SQL + }); +} +``` diff --git a/Documentation/client-snippets/sinks/index/register-sql-storage.md b/Documentation/client-snippets/sinks/index/register-sql-storage.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/sinks/index/register-sql-storage.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/explicit-subscriptions/startup-registration.md b/Documentation/client-snippets/subscriptions/explicit-subscriptions/startup-registration.md new file mode 100644 index 0000000..946a5fd --- /dev/null +++ b/Documentation/client-snippets/subscriptions/explicit-subscriptions/startup-registration.md @@ -0,0 +1,29 @@ +```typescript +import { ChronicleClient, ChronicleOptions, eventType, IEventStore } from '@cratis/chronicle'; + +@eventType() +class SubscriptionsExplicitStartupShipmentDispatched { + constructor(readonly orderId: string, readonly trackingNumber: string) {} +} + +@eventType() +class SubscriptionsExplicitStartupStockAdjusted { + constructor(readonly sku: string, readonly delta: number) {} +} + +// Safe to call on every application startup +async function runSubscriptionsExplicitStartupRegistration(): Promise { + const client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000')); + const eventStore: IEventStore = await client.getEventStore('Quickstart'); + + await eventStore.subscriptions.subscribe( + 'orders-from-fulfillment', + 'fulfillment-service', + builder => builder.withEventType(SubscriptionsExplicitStartupShipmentDispatched)); + + await eventStore.subscriptions.subscribe( + 'inventory-from-warehouse', + 'warehouse-service', + builder => builder.withEventType(SubscriptionsExplicitStartupStockAdjusted)); +} +``` diff --git a/Documentation/client-snippets/subscriptions/explicit-subscriptions/typical-pattern.md b/Documentation/client-snippets/subscriptions/explicit-subscriptions/typical-pattern.md new file mode 100644 index 0000000..291d99c --- /dev/null +++ b/Documentation/client-snippets/subscriptions/explicit-subscriptions/typical-pattern.md @@ -0,0 +1,32 @@ +```typescript +import { eventType, IEventStore } from '@cratis/chronicle'; + +@eventType() +class SubscriptionsExplicitTypicalShipmentDispatched { + constructor(readonly orderId: string, readonly trackingNumber: string) {} +} + +@eventType() +class SubscriptionsExplicitTypicalStockAdjusted { + constructor(readonly sku: string, readonly delta: number) {} +} + +@eventType() +class SubscriptionsExplicitTypicalStockReserved { + constructor(readonly sku: string, readonly quantity: number) {} +} + +async function registerSubscriptionsExplicitTypicalPattern(eventStore: IEventStore): Promise { + await eventStore.subscriptions.subscribe( + 'orders-from-fulfillment', + 'fulfillment-service', + builder => builder.withEventType(SubscriptionsExplicitTypicalShipmentDispatched)); + + await eventStore.subscriptions.subscribe( + 'inventory-updates', + 'warehouse-service', + builder => builder + .withEventType(SubscriptionsExplicitTypicalStockAdjusted) + .withEventType(SubscriptionsExplicitTypicalStockReserved)); +} +``` diff --git a/Documentation/client-snippets/subscriptions/implicit-subscriptions/consumer-setup.md b/Documentation/client-snippets/subscriptions/implicit-subscriptions/consumer-setup.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/implicit-subscriptions/consumer-setup.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/implicit-subscriptions/event-store-attribute.md b/Documentation/client-snippets/subscriptions/implicit-subscriptions/event-store-attribute.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/implicit-subscriptions/event-store-attribute.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/implicit-subscriptions/event-types-with-assembly-attribute.md b/Documentation/client-snippets/subscriptions/implicit-subscriptions/event-types-with-assembly-attribute.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/implicit-subscriptions/event-types-with-assembly-attribute.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/implicit-subscriptions/mixing-not-allowed.md b/Documentation/client-snippets/subscriptions/implicit-subscriptions/mixing-not-allowed.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/implicit-subscriptions/mixing-not-allowed.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/implicit-subscriptions/nuget-package.md b/Documentation/client-snippets/subscriptions/implicit-subscriptions/nuget-package.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/implicit-subscriptions/nuget-package.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/implicit-subscriptions/projection.md b/Documentation/client-snippets/subscriptions/implicit-subscriptions/projection.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/implicit-subscriptions/projection.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/implicit-subscriptions/separate-reactors.md b/Documentation/client-snippets/subscriptions/implicit-subscriptions/separate-reactors.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/implicit-subscriptions/separate-reactors.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/subscriptions/outbox-inbox/inbox-id.md b/Documentation/client-snippets/subscriptions/outbox-inbox/inbox-id.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/subscriptions/outbox-inbox/inbox-id.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/multiple-events.md b/Documentation/client-snippets/testing/event-append-collection/multiple-events.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/multiple-events.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/scope-lifetime.md b/Documentation/client-snippets/testing/event-append-collection/scope-lifetime.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/scope-lifetime.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/shipment-events-and-reactor.md b/Documentation/client-snippets/testing/event-append-collection/shipment-events-and-reactor.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/shipment-events-and-reactor.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/shipment-given-context.md b/Documentation/client-snippets/testing/event-append-collection/shipment-given-context.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/shipment-given-context.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/shipment-spec.md b/Documentation/client-snippets/testing/event-append-collection/shipment-spec.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/shipment-spec.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/single-event.md b/Documentation/client-snippets/testing/event-append-collection/single-event.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/single-event.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/violation-events-and-reactor.md b/Documentation/client-snippets/testing/event-append-collection/violation-events-and-reactor.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/violation-events-and-reactor.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/violation-given-context.md b/Documentation/client-snippets/testing/event-append-collection/violation-given-context.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/violation-given-context.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/event-append-collection/violation-spec.md b/Documentation/client-snippets/testing/event-append-collection/violation-spec.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/event-append-collection/violation-spec.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/assertions/concurrency.md b/Documentation/client-snippets/testing/events/assertions/concurrency.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/assertions/concurrency.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/assertions/constraint-e2e.md b/Documentation/client-snippets/testing/events/assertions/constraint-e2e.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/assertions/constraint-e2e.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/assertions/constraint-violation.md b/Documentation/client-snippets/testing/events/assertions/constraint-violation.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/assertions/constraint-violation.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/assertions/errors.md b/Documentation/client-snippets/testing/events/assertions/errors.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/assertions/errors.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/assertions/failure.md b/Documentation/client-snippets/testing/events/assertions/failure.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/assertions/failure.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/assertions/happy-path.md b/Documentation/client-snippets/testing/events/assertions/happy-path.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/assertions/happy-path.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/assertions/no-constraint-violation.md b/Documentation/client-snippets/testing/events/assertions/no-constraint-violation.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/assertions/no-constraint-violation.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-at-position.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-at-position.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-at-position.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-by-type.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-by-type.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-by-type.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-with-result.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-with-result.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/appended-event-with-result.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/by-event-source.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/by-event-source.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/by-event-source.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/full-example.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/full-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/full-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/predicate.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/predicate.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/predicate.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/tail-sequence-number.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/tail-sequence-number.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/tail-sequence-number.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/validator-no-sequence.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/validator-no-sequence.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/validator-no-sequence.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/event-sequence-assertions/validator.md b/Documentation/client-snippets/testing/events/event-sequence-assertions/validator.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/event-sequence-assertions/validator.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/scenario/append-many.md b/Documentation/client-snippets/testing/events/scenario/append-many.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/scenario/append-many.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/scenario/basic.md b/Documentation/client-snippets/testing/events/scenario/basic.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/scenario/basic.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/scenario/full-example.md b/Documentation/client-snippets/testing/events/scenario/full-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/scenario/full-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/scenario/given-multiple-sources.md b/Documentation/client-snippets/testing/events/scenario/given-multiple-sources.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/scenario/given-multiple-sources.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/scenario/given.md b/Documentation/client-snippets/testing/events/scenario/given.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/scenario/given.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/events/scenario/when-builder.md b/Documentation/client-snippets/testing/events/scenario/when-builder.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/events/scenario/when-builder.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/index/example-spec.md b/Documentation/client-snippets/testing/index/example-spec.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/index/example-spec.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/pure-function.md b/Documentation/client-snippets/testing/reactors/pure-function.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/pure-function.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/scenario/basic.md b/Documentation/client-snippets/testing/reactors/scenario/basic.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/scenario/basic.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/scenario/email-example.md b/Documentation/client-snippets/testing/reactors/scenario/email-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/scenario/email-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/scenario/injecting-dependencies.md b/Documentation/client-snippets/testing/reactors/scenario/injecting-dependencies.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/scenario/injecting-dependencies.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/scenario/multiple-sources.md b/Documentation/client-snippets/testing/reactors/scenario/multiple-sources.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/scenario/multiple-sources.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/scenario/produced-side-effects.md b/Documentation/client-snippets/testing/reactors/scenario/produced-side-effects.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/scenario/produced-side-effects.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/scenario/read-model.md b/Documentation/client-snippets/testing/reactors/scenario/read-model.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/scenario/read-model.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/reactors/scenario/services.md b/Documentation/client-snippets/testing/reactors/scenario/services.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/reactors/scenario/services.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/basic.md b/Documentation/client-snippets/testing/read-models/scenario/basic.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/basic.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/children.md b/Documentation/client-snippets/testing/read-models/scenario/children.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/children.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/fluent-projection-example.md b/Documentation/client-snippets/testing/read-models/scenario/fluent-projection-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/fluent-projection-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/initial-state.md b/Documentation/client-snippets/testing/read-models/scenario/initial-state.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/initial-state.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/injecting-dependencies.md b/Documentation/client-snippets/testing/read-models/scenario/injecting-dependencies.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/injecting-dependencies.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/model-bound-example.md b/Documentation/client-snippets/testing/read-models/scenario/model-bound-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/model-bound-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/multiple-instances.md b/Documentation/client-snippets/testing/read-models/scenario/multiple-instances.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/multiple-instances.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/preseeding-instances.md b/Documentation/client-snippets/testing/read-models/scenario/preseeding-instances.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/preseeding-instances.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/reducer-example.md b/Documentation/client-snippets/testing/read-models/scenario/reducer-example.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/reducer-example.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/registered-artifacts.md b/Documentation/client-snippets/testing/read-models/scenario/registered-artifacts.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/registered-artifacts.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/strict-event-subscription.md b/Documentation/client-snippets/testing/read-models/scenario/strict-event-subscription.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/strict-event-subscription.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/strict-fidelity.md b/Documentation/client-snippets/testing/read-models/scenario/strict-fidelity.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/strict-fidelity.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/testing/read-models/scenario/substitutions.md b/Documentation/client-snippets/testing/read-models/scenario/substitutions.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/testing/read-models/scenario/substitutions.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/tutorial/read-model/borrowed-books-query.md b/Documentation/client-snippets/tutorial/read-model/borrowed-books-query.md new file mode 100644 index 0000000..c3ec17a --- /dev/null +++ b/Documentation/client-snippets/tutorial/read-model/borrowed-books-query.md @@ -0,0 +1,11 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class BorrowedBooks { + constructor(private readonly store: IEventStore) {} + + all(): Promise { + return this.store.readModels.getInstances(BorrowedBook); + } +} +``` diff --git a/Documentation/client-snippets/tutorial/read-model/query.md b/Documentation/client-snippets/tutorial/read-model/query.md new file mode 100644 index 0000000..c3ce4d0 --- /dev/null +++ b/Documentation/client-snippets/tutorial/read-model/query.md @@ -0,0 +1,12 @@ +```typescript +import { IEventStore } from '@cratis/chronicle'; + +class Books { + constructor(private readonly store: IEventStore) {} + + async onLoan(): Promise { + const books = await this.store.readModels.getInstances(Book); + return books.filter(book => book.onLoan); + } +} +``` diff --git a/Documentation/client-snippets/understanding-constraints/event-type-attribute.md b/Documentation/client-snippets/understanding-constraints/event-type-attribute.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/understanding-constraints/event-type-attribute.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/understanding-constraints/property-attribute.md b/Documentation/client-snippets/understanding-constraints/property-attribute.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/understanding-constraints/property-attribute.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/client-snippets/understanding-constraints/remove-constraint.md b/Documentation/client-snippets/understanding-constraints/remove-constraint.md new file mode 100644 index 0000000..19b3207 --- /dev/null +++ b/Documentation/client-snippets/understanding-constraints/remove-constraint.md @@ -0,0 +1,3 @@ +```text +TypeScript does not support this workflow yet. +``` diff --git a/Documentation/validate-client-snippets.py b/Documentation/validate-client-snippets.py index ad05c6e..447a2d1 100644 --- a/Documentation/validate-client-snippets.py +++ b/Documentation/validate-client-snippets.py @@ -50,6 +50,11 @@ "read-models/watching-read-models/filtering": """ const threshold = 1000; """, + "projections/projection-declaration-language/adhoc-querying/basic": "", + "projections/projection-declaration-language/adhoc-querying/inferred-vs-explicit": "", + "projections/projection-declaration-language/adhoc-querying/type-mismatch": "", + "projections/projection-declaration-language/adhoc-querying/custom-sequence": "", + "projections/projection-declaration-language/adhoc-querying/error-handling": "", "contributing/clients/typescript-grpc-package/event-stores-definition": "", "contributing/clients/typescript-grpc-package/namespaces-definition": "", "contributing/clients/typescript-grpc-package/request-messages": "", diff --git a/Source/EventStore.ts b/Source/EventStore.ts index 6731c4f..0bacf4a 100644 --- a/Source/EventStore.ts +++ b/Source/EventStore.ts @@ -90,7 +90,7 @@ export class EventStore implements IEventStore { const artifacts = DefaultClientArtifactsProvider.default; this.eventTypes = new EventTypes(name.value, _connection, artifacts); this.constraints = new Constraints(name.value, _connection, artifacts); - this.projections = new Projections(name.value, _connection, artifacts, defaultSinkTypeId); + this.projections = new Projections(name.value, namespace.value, _connection, artifacts, defaultSinkTypeId); this.reactors = new Reactors(artifacts, _connection, name.value, namespace.value, lifecycle, this.eventLog); this.reducers = new Reducers(artifacts, _connection, name.value, namespace.value, lifecycle, defaultSinkTypeId); this.readModels = new ReadModels(name.value, namespace.value, _connection, artifacts, defaultSinkTypeId); diff --git a/Source/compliance/ComplianceContracts.ts b/Source/compliance/ComplianceContracts.ts index 5faa4c5..0795d13 100644 --- a/Source/compliance/ComplianceContracts.ts +++ b/Source/compliance/ComplianceContracts.ts @@ -33,6 +33,16 @@ export interface DeleteEncryptionKeyRequest { Identifier: string; } +/** + * Request to authorize a new encryption key for a PII encryption key identifier whose key was + * previously erased, so a later lawful lifecycle can protect their data again. + */ +export interface AllowNewEncryptionKeyRequest { + EventStore: string; + Namespace: string; + Identifier: string; +} + /** * An empty protobuf message. */ @@ -49,6 +59,7 @@ export type DeepPartial = T extends Builtin ? T : T extends globalThis.Array< export interface ComplianceClient { release(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; deleteEncryptionKey(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; + allowNewEncryptionKey(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; } export const ReleaseRequest = { @@ -181,6 +192,46 @@ export const DeleteEncryptionKeyRequest = { } }; +export const AllowNewEncryptionKeyRequest = { + encode(message: AllowNewEncryptionKeyRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.EventStore !== '') { + writer.uint32(10).string(message.EventStore); + } + if (message.Namespace !== '') { + writer.uint32(18).string(message.Namespace); + } + if (message.Identifier !== '') { + writer.uint32(26).string(message.Identifier); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AllowNewEncryptionKeyRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message: AllowNewEncryptionKeyRequest = { EventStore: '', Namespace: '', Identifier: '' }; + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: message.EventStore = reader.string(); continue; + case 2: message.Namespace = reader.string(); continue; + case 3: message.Identifier = reader.string(); continue; + } + if ((tag & 7) === 4 || tag === 0) break; + reader.skip(tag & 7); + } + return message; + }, + + fromPartial(object: DeepPartial): AllowNewEncryptionKeyRequest { + return { + EventStore: object.EventStore ?? '', + Namespace: object.Namespace ?? '', + Identifier: object.Identifier ?? '' + }; + } +}; + export const Empty = { encode(_: Empty, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { return writer; @@ -224,6 +275,14 @@ export const ComplianceDefinition = { responseType: Empty, responseStream: false as const, options: {} + }, + allowNewEncryptionKey: { + name: 'AllowNewEncryptionKey', + requestType: AllowNewEncryptionKeyRequest, + requestStream: false as const, + responseType: Empty, + responseStream: false as const, + options: {} } } } as const; diff --git a/Source/compliance/IPIIManager.ts b/Source/compliance/IPIIManager.ts index ce3f8a3..8a76a44 100644 --- a/Source/compliance/IPIIManager.ts +++ b/Source/compliance/IPIIManager.ts @@ -14,4 +14,16 @@ export interface IPIIManager { * this operation cannot be undone. */ deleteEncryptionKey(identifier: string): Promise; + + /** + * Authorizes a new encryption key for a PII encryption key identifier whose key was previously + * erased, so a later lawful lifecycle can protect their data again. + * @param identifier - The identifier of the encryption key to authorize a new key for. + * @remarks + * Erasing an identifier removes the key that exists now; it does not ban the identifier + * forever. This creates no key - it lets the next PII value written for the identifier + * provision a fresh, independent one, which can decrypt nothing written before the erasure. + * The erased key itself never comes back, whatever else happens. + */ + allowNewEncryptionKeyFor(identifier: string): Promise; } diff --git a/Source/compliance/PIIManager.spec.ts b/Source/compliance/PIIManager.spec.ts new file mode 100644 index 0000000..5c08427 --- /dev/null +++ b/Source/compliance/PIIManager.spec.ts @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { describe, expect, it, vi } from 'vitest'; +import { ChronicleConnection } from '../connection'; +import { PIIManager } from './PIIManager'; + +function createPIIManager() { + const deleteEncryptionKeyMock = vi.fn().mockResolvedValue(undefined); + const allowNewEncryptionKeyMock = vi.fn().mockResolvedValue(undefined); + const connection = { + compliance: { + deleteEncryptionKey: deleteEncryptionKeyMock, + allowNewEncryptionKey: allowNewEncryptionKeyMock + } + } as unknown as ChronicleConnection; + + const manager = new PIIManager('test-store', 'test-namespace', connection); + return { manager, deleteEncryptionKeyMock, allowNewEncryptionKeyMock }; +} + +describe('PIIManager', () => { + describe('when deleting an encryption key', () => { + it('should call the compliance service with the event store, namespace, and identifier', async () => { + const { manager, deleteEncryptionKeyMock } = createPIIManager(); + + await manager.deleteEncryptionKey('some-subject'); + + expect(deleteEncryptionKeyMock).toHaveBeenCalledWith({ + EventStore: 'test-store', + Namespace: 'test-namespace', + Identifier: 'some-subject' + }); + }); + }); + + describe('when allowing a new encryption key', () => { + it('should call the compliance service with the event store, namespace, and identifier', async () => { + const { manager, allowNewEncryptionKeyMock } = createPIIManager(); + + await manager.allowNewEncryptionKeyFor('some-subject'); + + expect(allowNewEncryptionKeyMock).toHaveBeenCalledWith({ + EventStore: 'test-store', + Namespace: 'test-namespace', + Identifier: 'some-subject' + }); + }); + }); +}); diff --git a/Source/compliance/PIIManager.ts b/Source/compliance/PIIManager.ts index 3d5f7bd..7164f94 100644 --- a/Source/compliance/PIIManager.ts +++ b/Source/compliance/PIIManager.ts @@ -28,4 +28,13 @@ export class PIIManager implements IPIIManager { Identifier: identifier }); } + + /** @inheritdoc */ + async allowNewEncryptionKeyFor(identifier: string): Promise { + await this._connection.compliance.allowNewEncryptionKey({ + EventStore: this._eventStore, + Namespace: this._namespace, + Identifier: identifier + }); + } } diff --git a/Source/compliance/PIINotSupportedOnEventSourceId.ts b/Source/compliance/PIINotSupportedOnEventSourceId.ts new file mode 100644 index 0000000..9b04e01 --- /dev/null +++ b/Source/compliance/PIINotSupportedOnEventSourceId.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Error thrown when the {@link pii} decorator is applied to the `eventSourceId` property. + */ +export class PIINotSupportedOnEventSourceId extends Error { + /** + * Initializes a new instance of the {@link PIINotSupportedOnEventSourceId} class. + * @param typeName - The name of the type the decorator was applied to. + */ + constructor(typeName: string) { + super( + `The @pii() decorator cannot be applied to 'eventSourceId' on '${typeName}' because it is the event ` + + 'source identifier. Event source identifiers cannot be encrypted as they are required for event ' + + 'correlation. If the identifier itself is sensitive, use a non-sensitive surrogate value as the event ' + + 'source identifier and store the sensitive value in a separate property marked with @pii().' + ); + this.name = 'PIINotSupportedOnEventSourceId'; + } +} diff --git a/Source/compliance/index.ts b/Source/compliance/index.ts index afd6f48..b53ece5 100644 --- a/Source/compliance/index.ts +++ b/Source/compliance/index.ts @@ -4,6 +4,8 @@ export { ComplianceMetadataType } from './ComplianceMetadataType'; export type { ComplianceMetadata } from './ComplianceMetadata'; export { pii, getPIIMetadata, hasPIIMetadata, getTypePIIMetadata, isPII } from './pii'; +export { subject, hasSubjectMetadata, getSubjectPropertyName } from './subject'; +export { PIINotSupportedOnEventSourceId } from './PIINotSupportedOnEventSourceId'; export { ComplianceMetadataResolver } from './ComplianceMetadataResolver'; export type { IPIIManager } from './IPIIManager'; export { PIIManager } from './PIIManager'; diff --git a/Source/compliance/pii.spec.ts b/Source/compliance/pii.spec.ts new file mode 100644 index 0000000..f4eeaa5 --- /dev/null +++ b/Source/compliance/pii.spec.ts @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { describe, expect, it } from 'vitest'; +import { pii } from './pii'; +import { PIINotSupportedOnEventSourceId } from './PIINotSupportedOnEventSourceId'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support. + +describe('pii', () => { + describe('when applied to the eventSourceId property', () => { + class SomeEvent { + eventSourceId = ''; + } + + it('should throw PIINotSupportedOnEventSourceId', () => { + expect(() => pii()(SomeEvent.prototype, 'eventSourceId')).toThrow(PIINotSupportedOnEventSourceId); + }); + + it('should describe why the property cannot be encrypted', () => { + expect(() => pii()(SomeEvent.prototype, 'eventSourceId')).toThrow(/event source identifier/); + }); + }); + + describe('when applied to any other property', () => { + class SomeEvent { + eventSourceId = ''; + name = ''; + } + + it('should not throw', () => { + expect(() => pii()(SomeEvent.prototype, 'name')).not.toThrow(); + }); + }); +}); diff --git a/Source/compliance/pii.ts b/Source/compliance/pii.ts index 635542d..bcd7d86 100644 --- a/Source/compliance/pii.ts +++ b/Source/compliance/pii.ts @@ -4,8 +4,12 @@ import 'reflect-metadata'; import type { ComplianceMetadata } from './ComplianceMetadata'; import { ComplianceMetadataType } from './ComplianceMetadataType'; +import { PIINotSupportedOnEventSourceId } from './PIINotSupportedOnEventSourceId'; import { TypeIntrospector } from '../types'; +/** The property name this client uses everywhere for the event source identifier. */ +const EVENT_SOURCE_ID_PROPERTY = 'eventSourceId'; + /** Metadata key for PII decorator on properties. */ const PII_PROPERTY_METADATA_KEY = 'chronicle:compliance:pii:property'; @@ -66,7 +70,18 @@ export function pii(details?: string): PropertyDecorator & ClassDecorator { // Property decorator usage if (propertyKey !== undefined) { const key = propertyKey.toString(); - TypeIntrospector.trackProperty((target as { constructor: Function }).constructor, key); + const declaringType = (target as { constructor: Function }).constructor; + + // Encrypting the event source identifier would make its own decryption key + // unfindable - the identifier is required, in the clear, to correlate events and + // look up the key that protects everything else. Mirrors C#'s + // PIINotSupportedOnEventSourceId guard, which throws for the same reason when + // [PII] is applied to an EventSourceId/EventSourceId type. + if (key === EVENT_SOURCE_ID_PROPERTY) { + throw new PIINotSupportedOnEventSourceId(declaringType.name); + } + + TypeIntrospector.trackProperty(declaringType, key); const metadata: ComplianceMetadata = { metadataType: ComplianceMetadataType.PII, details: details ?? '' diff --git a/Source/compliance/subject.spec.ts b/Source/compliance/subject.spec.ts new file mode 100644 index 0000000..1116499 --- /dev/null +++ b/Source/compliance/subject.spec.ts @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { describe, expect, it } from 'vitest'; +import { getSubjectPropertyName, hasSubjectMetadata, subject } from './subject'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support. + +describe('subject', () => { + describe('when applied to a property', () => { + class SomeReadModel { + personId = ''; + name = ''; + } + subject()(SomeReadModel.prototype, 'personId'); + + it('should mark the decorated property with subject metadata', () => { + expect(hasSubjectMetadata(SomeReadModel.prototype, 'personId')).toBe(true); + }); + + it('should leave other properties without subject metadata', () => { + expect(hasSubjectMetadata(SomeReadModel.prototype, 'name')).toBe(false); + }); + + it('should record the decorated property name on the declaring type', () => { + expect(getSubjectPropertyName(SomeReadModel)).toBe('personId'); + }); + }); + + describe('when no property is decorated', () => { + class PlainReadModel { + id = ''; + } + + it('should not record a subject property on the declaring type', () => { + expect(getSubjectPropertyName(PlainReadModel)).toBeUndefined(); + }); + + it('should report no subject metadata for its properties', () => { + expect(hasSubjectMetadata(PlainReadModel.prototype, 'id')).toBe(false); + }); + }); +}); diff --git a/Source/compliance/subject.ts b/Source/compliance/subject.ts new file mode 100644 index 0000000..4be24c7 --- /dev/null +++ b/Source/compliance/subject.ts @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { TypeIntrospector } from '../types'; + +/** Metadata key for the subject decorator on a property. */ +const SUBJECT_PROPERTY_METADATA_KEY = 'chronicle:compliance:subject:property'; + +/** Metadata key for the name of the subject property recorded on the declaring type. */ +const SUBJECT_TYPE_METADATA_KEY = 'chronicle:compliance:subject:type'; + +/** + * Decorator that marks a property as the compliance subject - the natural person whose Personal + * Identifiable Information (PII) a read model or event carries. The subject selects which + * encryption key protects that PII, and which key a manual release operation must use. + * + * Mirrors the .NET client's `SubjectAttribute`. When no property is decorated, resolvers fall + * back to the `id` property by convention, so read models that predate this decorator keep + * working unchanged. + * + * @returns A property decorator. + * + * @example + * ```typescript + * @readModel() + * class Employee { + * @subject() + * personId: string = ''; + * + * @pii('Employee social security number') + * ssn: string = ''; + * } + * ``` + */ +export function subject(): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const key = propertyKey.toString(); + const declaringType = (target as { constructor: Function }).constructor; + + TypeIntrospector.trackProperty(declaringType, key); + Reflect.defineMetadata(SUBJECT_PROPERTY_METADATA_KEY, true, target, key); + Reflect.defineMetadata(SUBJECT_TYPE_METADATA_KEY, key, declaringType); + }; +} + +/** + * Checks whether a property has been decorated with @subject. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns True if the property has the @subject decorator; false otherwise. + */ +export function hasSubjectMetadata(target: object, propertyKey: string): boolean { + return Reflect.hasMetadata(SUBJECT_PROPERTY_METADATA_KEY, target, propertyKey); +} + +/** + * Gets the name of the property decorated with @subject on a type, if any. + * @param type - The type constructor to inspect. + * @returns The decorated property name, or undefined when no property is decorated. + */ +export function getSubjectPropertyName(type: Function): string | undefined { + return Reflect.getMetadata(SUBJECT_TYPE_METADATA_KEY, type); +} diff --git a/Source/eventSequences/AppendOptions.ts b/Source/eventSequences/AppendOptions.ts index a6ab848..bf0d782 100644 --- a/Source/eventSequences/AppendOptions.ts +++ b/Source/eventSequences/AppendOptions.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { Guid } from '@cratis/fundamentals'; +import type { Tag } from '../events/Tag'; import type { ConcurrencyScope } from './ConcurrencyScope'; /** @@ -17,6 +18,14 @@ export interface AppendOptions { /** Optional concurrency scope to use for append operations. */ concurrencyScope?: ConcurrencyScope; + /** + * Optional tags to associate with the event(s) being appended. These are combined with any + * static tags declared on the event type(s) via `@tag()`/`@tags()`, and - for the + * `appendMany(events: EventForEventSourceId[], options?)` overload - with any tags carried + * by the individual {@link EventForEventSourceId} entries. + */ + tags?: ReadonlyArray; + /** * Optional per-event-source-id concurrency scopes, keyed by event source id. * Only meaningful for the `appendMany(events: EventForEventSourceId[], options?)` overload, which diff --git a/Source/eventSequences/EventForEventSourceId.ts b/Source/eventSequences/EventForEventSourceId.ts index d885531..728d0c5 100644 --- a/Source/eventSequences/EventForEventSourceId.ts +++ b/Source/eventSequences/EventForEventSourceId.ts @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import type { Tag } from '../events/Tag'; + /** * Represents an event paired with the event source identifier it belongs to. */ @@ -22,4 +24,11 @@ export interface EventForEventSourceId { /** Optional subject identifying the target the event is about. Defaults to the event source id. */ readonly subject?: string; + + /** + * Optional tags to associate with the event. These are combined with any static tags + * declared on the event type via `@tag()`/`@tags()`, and with any tags supplied at + * append time. + */ + readonly tags?: ReadonlyArray; } diff --git a/Source/eventSequences/EventSequence.ts b/Source/eventSequences/EventSequence.ts index 2652c5d..a01f134 100644 --- a/Source/eventSequences/EventSequence.ts +++ b/Source/eventSequences/EventSequence.ts @@ -10,6 +10,9 @@ import type { AppendedEvent } from '../events/AppendedEvent'; import { EventType } from '../events/EventType'; import { EventTypeId } from '../events/EventTypeId'; import { EventTypeGeneration } from '../events/EventTypeGeneration'; +import { Tag } from '../events/Tag'; +import { getTagsFor } from '../events/tagDecorator'; +import { mergeTags } from '../events/mergeTags'; import { DecoratorType } from '../types/DecoratorType'; import { TypeDiscoverer } from '../types/TypeDiscoverer'; import { toClientFailedPartition } from '../observation/toClientFailedPartition'; @@ -66,6 +69,9 @@ export class EventSequence implements IEventSequence { : Guid.as(options.correlationId); const content = JsonSerializer.serialize(event); + // Merge static tags declared on the event type with tags supplied at append time. + const tags = mergeTags(getTagsFor(event.constructor as Function), options?.tags); + causationManager.add(CausationType.appendEvent, { eventType: eventType.id.value }); const causationChain = causationManager.getCurrentChain(); const identity = identityProvider.getCurrent(); @@ -108,7 +114,7 @@ export class EventSequence implements IEventSequence { })), CausedBy: toContractsCausedBy(identity), ConcurrencyScope: this.toContractConcurrencyScope(options?.concurrencyScope), - Tags: [], + Tags: tags, Occurred: undefined, Subject: eventSourceId }); @@ -149,7 +155,8 @@ export class EventSequence implements IEventSequence { eventType, occurred: new Date(), correlationId: correlationId.toString(), - causation: causationChain.map(c => ({ type: c.type.name, properties: { ...c.properties } })) + causation: causationChain.map(c => ({ type: c.type.name, properties: { ...c.properties } })), + tags: tags.map(value => new Tag(value)) }, eventType, content: event as Record @@ -223,8 +230,13 @@ export class EventSequence implements IEventSequence { const resolveConcurrencyScope = (eventSourceId: string) => this.toContractConcurrencyScope(concurrencyScopesByEventSourceId?.[eventSourceId] ?? defaultConcurrencyScope); - const eventsToAppend = eventsForEventSourceIds.map(({ eventSourceId, event, eventStreamType, eventStreamId, eventSourceType, subject }) => { + const eventsToAppend = eventsForEventSourceIds.map(({ eventSourceId, event, eventStreamType, eventStreamId, eventSourceType, subject, tags: instanceTags }) => { const eventType = getEventTypeFor(event.constructor as Function); + + // Merge static tags declared on the event type, tags carried by this specific + // EventForEventSourceId entry, and tags supplied at call time for the whole batch. + const tags = mergeTags(getTagsFor(event.constructor as Function), instanceTags, appendOptions?.tags); + return { EventSourceType: eventSourceType ?? 'Default', EventSourceId: eventSourceId, @@ -242,7 +254,7 @@ export class EventSequence implements IEventSequence { Properties: { ...c.properties } })), CausedBy: toContractsCausedBy(identity), - Tags: [], + Tags: tags, Occurred: undefined, Subject: subject ?? eventSourceId }; @@ -334,7 +346,8 @@ export class EventSequence implements IEventSequence { eventType, occurred: occurredAt, correlationId: correlationId.toString(), - causation: causationEntries + causation: causationEntries, + tags: eventsToAppend[index].Tags.map(value => new Tag(value)) }, eventType, content: event as Record @@ -687,7 +700,8 @@ export class EventSequence implements IEventSequence { causation: (context.Causation ?? []).map(c => ({ type: c.Type, properties: { ...c.Properties } - })) + })), + tags: (context.Tags ?? []).map(value => new Tag(value)) }, eventType, content: JSON.parse(wireEvent.Content) as Record diff --git a/Source/events/EventContext.ts b/Source/events/EventContext.ts index cc06489..9b74d45 100644 --- a/Source/events/EventContext.ts +++ b/Source/events/EventContext.ts @@ -3,6 +3,7 @@ import { EventType } from './EventType'; import { CausationEntry } from './CausationEntry'; +import { Tag } from './Tag'; /** * Represents contextual information about an appended event. @@ -25,4 +26,7 @@ export interface EventContext { /** The causation chain for the event. */ readonly causation: ReadonlyArray; + + /** The tags the event carries. */ + readonly tags: ReadonlyArray; } diff --git a/Source/events/Tag.ts b/Source/events/Tag.ts new file mode 100644 index 0000000..98e35fc --- /dev/null +++ b/Source/events/Tag.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents a tag associated with an event, observer, or read model. + */ +export class Tag { + constructor(readonly value: string) {} + + /** @inheritdoc */ + toString(): string { + return this.value; + } +} diff --git a/Source/events/filterEventsByTagDecorator.ts b/Source/events/filterEventsByTagDecorator.ts new file mode 100644 index 0000000..1c7838d --- /dev/null +++ b/Source/events/filterEventsByTagDecorator.ts @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { Tag } from './Tag'; +import { mergeTags } from './mergeTags'; + +/** Metadata key used to store the tags a reactor or reducer filters events by. */ +const FILTER_TAGS_METADATA_KEY = 'chronicle:filterEventsByTag'; + +/** + * TypeScript decorator that restricts a reactor or reducer so that it only handles events + * that carry a specific tag. This is the TypeScript equivalent of the C# `[FilterEventsByTag]` + * attribute. + * + * Apply this decorator to a `@reactor()` or `@reducer()`-decorated class to filter the observed + * event stream to events tagged with the given value. Use {@link tag}/{@link tags} when the + * intent is to *label* an observer for discoverability; use this decorator when the intent is + * to *filter* which events reach it. Applying the decorator more than once accumulates filter + * tags - a class handles events carrying any of the applied tags. + * + * @param value - The tag value that an event must carry in order to be dispatched to the observer. + * @returns A class decorator. + * + * @example + * ```typescript + * @reactor() + * @filterEventsByTag('vip') + * class VipWelcomeReactor { + * async customerRegistered(event: CustomerRegistered): Promise { + * console.log(`Welcome VIP customer ${event.emailAddress}`); + * } + * } + * ``` + */ +export function filterEventsByTag(value: string): ClassDecorator { + return (target: object) => { + const existing = (Reflect.getMetadata(FILTER_TAGS_METADATA_KEY, target) as Tag[] | undefined) ?? []; + const merged = mergeTags(existing, [value]).map(tagValue => new Tag(tagValue)); + Reflect.defineMetadata(FILTER_TAGS_METADATA_KEY, merged, target); + }; +} + +/** + * Gets all filter tags applied to a class via {@link filterEventsByTag}. + * @param target - The class constructor to retrieve filter tags for. + * @returns The filter tags applied to the class, or an empty array if none are. + */ +export function getFilterTagsFor(target: Function): Tag[] { + return (Reflect.getMetadata(FILTER_TAGS_METADATA_KEY, target) as Tag[] | undefined) ?? []; +} diff --git a/Source/events/index.ts b/Source/events/index.ts index 6d462d2..a102210 100644 --- a/Source/events/index.ts +++ b/Source/events/index.ts @@ -11,5 +11,9 @@ export type { CausationEntry } from './CausationEntry'; export type { AppendedEvent } from './AppendedEvent'; export type { IEventTypes } from './IEventTypes'; export { EventTypes } from './EventTypes'; +export { Tag } from './Tag'; +export { tag, tags, getTagsFor } from './tagDecorator'; +export { filterEventsByTag, getFilterTagsFor } from './filterEventsByTagDecorator'; +export { mergeTags } from './mergeTags'; export * from './constraints'; export * from './migrations'; diff --git a/Source/events/mergeTags.spec.ts b/Source/events/mergeTags.spec.ts new file mode 100644 index 0000000..5622204 --- /dev/null +++ b/Source/events/mergeTags.spec.ts @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { describe, expect, it } from 'vitest'; +import { Tag } from './Tag'; +import { mergeTags } from './mergeTags'; + +describe('mergeTags', () => { + describe('when merging a single source of strings', () => { + it('should return the values unchanged', () => { + expect(mergeTags(['a', 'b'])).toEqual(['a', 'b']); + }); + }); + + describe('when merging several sources', () => { + it('should combine them in order', () => { + expect(mergeTags(['a'], ['b'], ['c'])).toEqual(['a', 'b', 'c']); + }); + }); + + describe('when the same value appears in more than one source', () => { + it('should keep only the first occurrence', () => { + expect(mergeTags(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c']); + }); + }); + + describe('when a source holds Tag instances', () => { + it('should unwrap them to their values', () => { + expect(mergeTags([new Tag('a')], ['b'])).toEqual(['a', 'b']); + }); + }); + + describe('when a source is undefined', () => { + it('should skip it', () => { + expect(mergeTags(['a'], undefined, ['b'])).toEqual(['a', 'b']); + }); + }); + + describe('when a value is blank', () => { + it('should drop it, so an empty tag never reaches the wire', () => { + expect(mergeTags(['a', '', ' '])).toEqual(['a']); + }); + }); + + describe('when there are no sources at all', () => { + it('should return an empty list', () => { + expect(mergeTags()).toEqual([]); + }); + }); +}); diff --git a/Source/events/mergeTags.ts b/Source/events/mergeTags.ts new file mode 100644 index 0000000..55cb451 --- /dev/null +++ b/Source/events/mergeTags.ts @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Tag } from './Tag'; + +/** + * Merges one or more sources of tags - static tags declared on a type, tags carried by an + * individual event instance, and tags supplied at call time - into a single, distinct list of + * tag values. Mirrors the .NET client, which merges static, instance, and dynamic tags the + * same way before sending an append request. + * @param sources - The tag sources to merge. Each may be a mix of strings and {@link Tag} instances, + * or undefined when that source contributed no tags. + * @returns The distinct, merged tag values. + */ +export function mergeTags(...sources: ReadonlyArray | undefined>): string[] { + const values = new Set(); + for (const source of sources) { + if (!source) continue; + for (const entry of source) { + const value = typeof entry === 'string' ? entry : entry.value; + if (value.trim().length > 0) { + values.add(value); + } + } + } + return [...values]; +} diff --git a/Source/events/tagDecorator.spec.ts b/Source/events/tagDecorator.spec.ts new file mode 100644 index 0000000..bf4ba31 --- /dev/null +++ b/Source/events/tagDecorator.spec.ts @@ -0,0 +1,115 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { describe, expect, it } from 'vitest'; +import { getTagsFor, tag, tags } from './tagDecorator'; +import { filterEventsByTag, getFilterTagsFor } from './filterEventsByTagDecorator'; + +describe('tag', () => { + describe('when a class carries no tags', () => { + class Untagged {} + + it('should report no tags', () => { + expect(getTagsFor(Untagged)).toEqual([]); + }); + }); + + describe('when decorating a class with a single tag', () => { + class SingleTagged {} + tag('analytics')(SingleTagged); + + it('should carry that tag', () => { + expect(getTagsFor(SingleTagged).map(_ => _.value)).toEqual(['analytics']); + }); + }); + + describe('when decorating a class with several tags at once', () => { + class MultiTagged {} + tag('analytics', 'user-action')(MultiTagged); + + it('should carry every tag', () => { + expect(getTagsFor(MultiTagged).map(_ => _.value)).toEqual(['analytics', 'user-action']); + }); + }); + + describe('when applying the decorator more than once', () => { + class Accumulated {} + tag('first')(Accumulated); + tag('second')(Accumulated); + + it('should accumulate rather than replace', () => { + expect(getTagsFor(Accumulated).map(_ => _.value)).toEqual(['first', 'second']); + }); + }); + + describe('when the same tag is applied twice', () => { + class Duplicated {} + tag('same')(Duplicated); + tag('same')(Duplicated); + + it('should keep it once', () => { + expect(getTagsFor(Duplicated).map(_ => _.value)).toEqual(['same']); + }); + }); + + describe('when using the plural tags decorator', () => { + class PluralTagged {} + tags('a', 'b')(PluralTagged); + + it('should behave identically to tag', () => { + expect(getTagsFor(PluralTagged).map(_ => _.value)).toEqual(['a', 'b']); + }); + }); + + describe('when two classes are tagged separately', () => { + class FirstTagged {} + class SecondTagged {} + tag('one')(FirstTagged); + tag('two')(SecondTagged); + + it('should not leak tags between them', () => { + expect(getTagsFor(FirstTagged).map(_ => _.value)).toEqual(['one']); + expect(getTagsFor(SecondTagged).map(_ => _.value)).toEqual(['two']); + }); + }); +}); + +describe('filterEventsByTag', () => { + describe('when a class carries no filter', () => { + class Unfiltered {} + + it('should report no filter tags', () => { + expect(getFilterTagsFor(Unfiltered)).toEqual([]); + }); + }); + + describe('when decorating a class with a filter tag', () => { + class Filtered {} + filterEventsByTag('vip')(Filtered); + + it('should carry that filter tag', () => { + expect(getFilterTagsFor(Filtered).map(_ => _.value)).toEqual(['vip']); + }); + }); + + describe('when applying the decorator more than once', () => { + class MultiFiltered {} + filterEventsByTag('vip')(MultiFiltered); + filterEventsByTag('premium')(MultiFiltered); + + it('should accumulate, so the observer handles events carrying any of them', () => { + expect(getFilterTagsFor(MultiFiltered).map(_ => _.value)).toEqual(['vip', 'premium']); + }); + }); + + describe('when a class is both labeled and filtered', () => { + class LabeledAndFiltered {} + tag('reporting')(LabeledAndFiltered); + filterEventsByTag('vip')(LabeledAndFiltered); + + it('should keep labeling and filtering separate', () => { + expect(getTagsFor(LabeledAndFiltered).map(_ => _.value)).toEqual(['reporting']); + expect(getFilterTagsFor(LabeledAndFiltered).map(_ => _.value)).toEqual(['vip']); + }); + }); +}); diff --git a/Source/events/tagDecorator.ts b/Source/events/tagDecorator.ts new file mode 100644 index 0000000..56f2aeb --- /dev/null +++ b/Source/events/tagDecorator.ts @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { Tag } from './Tag'; +import { mergeTags } from './mergeTags'; + +/** Metadata key used to store the tags labeling a class. */ +const TAGS_METADATA_KEY = 'chronicle:tags'; + +/** + * TypeScript decorator that labels an event type, reactor, or reducer with one or more tags. + * This is the TypeScript equivalent of the C# `[Tag]` attribute. + * + * Applying the decorator more than once accumulates tags rather than replacing them, so + * `@tag('a')` followed by `@tag('b')` on the same class results in both `'a'` and `'b'`. + * + * @param values - The tags to apply. + * @returns A class decorator. + * + * @example + * ```typescript + * @eventType() + * @tag('analytics', 'user-action') + * class UserLoggedIn { + * constructor(readonly userId: string) {} + * } + * ``` + */ +export function tag(...values: string[]): ClassDecorator { + return (target: object) => addTags(target, values); +} + +/** + * TypeScript decorator that labels an event type, reactor, or reducer with one or more tags. + * This is the TypeScript equivalent of the C# `[Tags]` attribute, and behaves identically to + * {@link tag} - use whichever reads more naturally at the call site. + * + * @param values - The tags to apply. + * @returns A class decorator. + */ +export function tags(...values: string[]): ClassDecorator { + return (target: object) => addTags(target, values); +} + +function addTags(target: object, values: string[]): void { + const existing = (Reflect.getMetadata(TAGS_METADATA_KEY, target) as Tag[] | undefined) ?? []; + const merged = mergeTags(existing, values).map(value => new Tag(value)); + Reflect.defineMetadata(TAGS_METADATA_KEY, merged, target); +} + +/** + * Gets all tags applied to a class via {@link tag} or {@link tags}. + * @param target - The class constructor to retrieve tags for. + * @returns The tags applied to the class, or an empty array if none are. + */ +export function getTagsFor(target: Function): Tag[] { + return (Reflect.getMetadata(TAGS_METADATA_KEY, target) as Tag[] | undefined) ?? []; +} diff --git a/Source/observation/ObserverRunningState.ts b/Source/observation/ObserverRunningState.ts index b75c481..42b97ec 100644 --- a/Source/observation/ObserverRunningState.ts +++ b/Source/observation/ObserverRunningState.ts @@ -8,27 +8,18 @@ export enum ObserverRunningState { /** The observer is in an unknown state. */ Unknown = 'Unknown', - /** The observer is subscribing to the event sequence. */ - Subscribing = 'Subscribing', - - /** The observer is replaying events from the beginning. */ - Replaying = 'Replaying', - - /** The observer is resuming after a pause. */ - Resuming = 'Resuming', - - /** The observer is actively processing events. */ + /** The observer is active and waiting for new events. */ Active = 'Active', - /** The observer is paused and not processing events. */ - Paused = 'Paused', + /** The observer is suspended. */ + Suspended = 'Suspended', - /** The observer has stopped processing events. */ - Stopped = 'Stopped', + /** The observer is replaying. */ + Replaying = 'Replaying', - /** The observer is in a failed state. */ - Failed = 'Failed', + /** The observer is disconnected. */ + Disconnected = 'Disconnected', - /** The observer has been disconnected. */ - Disconnected = 'Disconnected' + /** The observer is quarantined. */ + Quarantined = 'Quarantined' } diff --git a/Source/observation/index.ts b/Source/observation/index.ts index 4522b8b..942d631 100644 --- a/Source/observation/index.ts +++ b/Source/observation/index.ts @@ -7,5 +7,6 @@ export type { FailedPartition } from './FailedPartition'; export type { FailedPartitionAttempt } from './FailedPartitionAttempt'; export type { IFailedPartitions } from './IFailedPartitions'; export { FailedPartitions } from './FailedPartitions'; +export { toObserverRunningState } from './toObserverRunningState'; export type { ICanBeNotifiedWhenReplay } from './ICanBeNotifiedWhenReplay'; export type { ICanBeNotifiedWhenPartitionReplayed } from './ICanBeNotifiedWhenPartitionReplayed'; diff --git a/Source/observation/toObserverRunningState.ts b/Source/observation/toObserverRunningState.ts new file mode 100644 index 0000000..845eb27 --- /dev/null +++ b/Source/observation/toObserverRunningState.ts @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ObserverRunningState as ContractObserverRunningState } from '@cratis/chronicle.contracts'; +import { ObserverRunningState } from './ObserverRunningState'; + +/** + * Converts a wire {@link ContractObserverRunningState} into the client {@link ObserverRunningState}. + * @param state - The wire running state to convert. + * @returns The converted client running state. + */ +export function toObserverRunningState(state: ContractObserverRunningState): ObserverRunningState { + switch (state) { + case ContractObserverRunningState.Active: + return ObserverRunningState.Active; + case ContractObserverRunningState.Suspended: + return ObserverRunningState.Suspended; + case ContractObserverRunningState.Replaying: + return ObserverRunningState.Replaying; + case ContractObserverRunningState.Disconnected: + return ObserverRunningState.Disconnected; + case ContractObserverRunningState.Quarantined: + return ObserverRunningState.Quarantined; + default: + return ObserverRunningState.Unknown; + } +} diff --git a/Source/projections/IProjections.ts b/Source/projections/IProjections.ts index 72fd212..cb02f7a 100644 --- a/Source/projections/IProjections.ts +++ b/Source/projections/IProjections.ts @@ -1,8 +1,16 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { Constructor } from '@cratis/fundamentals'; +import { FailedPartition } from '../observation/FailedPartition'; +import { JobId } from '../jobs/JobId'; +import { ProjectionId } from './ProjectionId'; +import { ProjectionQueryResult } from './ProjectionQueryResult'; +import { ProjectionState } from './ProjectionState'; + /** - * Defines a system to work with projections, including discovery and registration with the Kernel. + * Defines a system to work with projections, including discovery, registration and operating + * on projections that are known to the Chronicle Kernel. */ export interface IProjections { /** @@ -16,4 +24,76 @@ export interface IProjections { * @returns A promise that resolves when registration is complete. */ register(): Promise; + + /** + * Check if there is a definition for a specific projection identifier. + * @param projectionId - Identifier of the projection. + * @returns True if it exists, false if not. + */ + hasFor(projectionId: ProjectionId | string): boolean; + + /** + * Check if there is a definition for the projection that maintains a specific read model. + * @param readModelType - Type of read model to check for. + * @returns True if it exists, false if not. + */ + hasForModel(readModelType: Constructor): boolean; + + /** + * Get the {@link ProjectionId} for the projection that maintains a specific read model. + * @param readModelType - Type of read model to get for. + * @returns The {@link ProjectionId} for the read model. + * @remarks A model-bound projection has no projection type of its own - its read model type is the + * only handle it has. A declarative projection is only resolvable here when it declares its + * read model type explicitly (the second argument to the `projection()` decorator); one whose read + * model is inferred at registration time cannot be resolved before `register()` has run. + */ + getProjectionIdFor(readModelType: Constructor): ProjectionId; + + /** + * Get the state of a specific projection. + * @param projectionId - Identifier of the projection to get the state for. + * @returns The {@link ProjectionState}. + */ + getStateFor(projectionId: ProjectionId | string): Promise; + + /** + * Get the state of the projection that maintains a specific read model. + * @param readModelType - Type of read model to get the state for. + * @returns The {@link ProjectionState}. + */ + getStateForModel(readModelType: Constructor): Promise; + + /** + * Get any failed partitions for the projection that maintains a specific read model. + * @param readModelType - Type of read model to get for. + * @returns Collection of {@link FailedPartition}, if any. + */ + getFailedPartitionsForModel(readModelType: Constructor): Promise; + + /** + * Replay a specific projection by its identifier. + * @param projectionId - Identifier of the projection to replay. + * @returns The {@link JobId} of the replay job that was started or resumed. + */ + replay(projectionId: ProjectionId | string): Promise; + + /** + * Replay the projection that maintains a specific read model. + * @param readModelType - Type of read model to replay the projection for. + * @returns The {@link JobId} of the replay job that was started or resumed. + */ + replayForModel(readModelType: Constructor): Promise; + + /** + * Query a projection declaration against the event log without registering it. + * @param declaration - The Projection Declaration Language string to query. + * @param eventSequenceId - Optional event sequence identifier to query. Defaults to `"event-log"`. + * @returns A {@link ProjectionQueryResult} containing the resulting read model entries. + * @remarks The declaration may omit the `=> ReadModelType` target - in that case the read model + * schema is inferred from the events used in the projection. An inferred read model can never be + * registered as a permanent projection; query-only declarations are exclusively for ad-hoc + * exploration. + */ + query(declaration: string, eventSequenceId?: string): Promise; } diff --git a/Source/projections/ProjectionQueryResult.ts b/Source/projections/ProjectionQueryResult.ts new file mode 100644 index 0000000..3086c76 --- /dev/null +++ b/Source/projections/ProjectionQueryResult.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents the result of querying a projection against the event log. + */ +export interface ProjectionQueryResult { + /** Collection of JSON representations of the resulting read model entries. */ + readonly readModelEntries: ReadonlyArray; +} diff --git a/Source/projections/ProjectionState.ts b/Source/projections/ProjectionState.ts new file mode 100644 index 0000000..559b282 --- /dev/null +++ b/Source/projections/ProjectionState.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ObserverRunningState } from '../observation/ObserverRunningState'; +import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber'; + +/** + * Represents the state of a projection. + */ +export interface ProjectionState { + /** The current running state of the projection. */ + readonly runningState: ObserverRunningState; + + /** Indicates whether the projection is subscribed to its handler. */ + readonly isSubscribed: boolean; + + /** The next event sequence number the projection will process. */ + readonly nextEventSequenceNumber: EventSequenceNumber; + + /** The last event sequence number the projection handled. */ + readonly lastHandledEventSequenceNumber: EventSequenceNumber; + + /** The current tail event sequence number of the event sequence the projection observes. */ + readonly tailEventSequenceNumber: EventSequenceNumber; +} diff --git a/Source/projections/Projections.childrenAndNested.spec.ts b/Source/projections/Projections.childrenAndNested.spec.ts index aaa252a..7bdf7d1 100644 --- a/Source/projections/Projections.childrenAndNested.spec.ts +++ b/Source/projections/Projections.childrenAndNested.spec.ts @@ -124,7 +124,7 @@ function createProjections(readModels: (new (...args: unknown[]) => unknown)[]) eventTypeMigrations: [] }; - const projections = new Projections('test-store', connection, clientArtifacts, 'test-sink'); + const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink'); return { projections, registerMock }; } diff --git a/Source/projections/Projections.modelBoundCompleteness.spec.ts b/Source/projections/Projections.modelBoundCompleteness.spec.ts new file mode 100644 index 0000000..836fe6a --- /dev/null +++ b/Source/projections/Projections.modelBoundCompleteness.spec.ts @@ -0,0 +1,224 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { AutoMap } from '@cratis/chronicle.contracts'; +import { describe, expect, it, vi } from 'vitest'; +import { IClientArtifactsProvider } from '../artifacts'; +import { ChronicleConnection } from '../connection'; +import { eventType } from '../events/eventTypeDecorator'; +import { readModel } from '../readModels/readModel'; +import { clearWith } from './modelBound/clearWith'; +import { eventLog, eventSequence } from './modelBound/eventSequence'; +import { fromAll } from './modelBound/fromAll'; +import { fromEvent } from './modelBound/fromEvent'; +import { noAutoMap } from './modelBound/noAutoMap'; +import { setFrom } from './modelBound/setFrom'; +import { Projections } from './Projections'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support. + +class MbWorkArrangementSet { + location!: string; + workMode!: number; +} +eventType()(MbWorkArrangementSet); + +class MbCandidateSubmitted { + name!: string; + location!: string; +} +eventType()(MbCandidateSubmitted); + +class MbAssignmentSummary { + id!: string; + location!: string; + candidateName!: string; +} +setFrom(MbWorkArrangementSet, 'location')(MbAssignmentSummary.prototype, 'location'); +noAutoMap(MbAssignmentSummary.prototype, 'location'); +setFrom(MbCandidateSubmitted, 'name')(MbAssignmentSummary.prototype, 'candidateName'); +fromEvent(MbWorkArrangementSet)(MbAssignmentSummary); +readModel()(MbAssignmentSummary); + +class MbFullyExcluded { + id!: string; + location!: string; +} +setFrom(MbWorkArrangementSet, 'location')(MbFullyExcluded.prototype, 'location'); +fromEvent(MbWorkArrangementSet)(MbFullyExcluded); +noAutoMap(MbFullyExcluded); +readModel()(MbFullyExcluded); + +class MbProductRenamed { + name!: string; + version!: number; +} +eventType()(MbProductRenamed); + +class MbProductPriceChanged { + price!: number; + version!: number; +} +eventType()(MbProductPriceChanged); + +class MbProductVersion { + id!: string; + name!: string; + price!: number; + version!: number; +} +fromAll('version')(MbProductVersion.prototype, 'version'); +fromEvent(MbProductRenamed)(MbProductVersion); +fromEvent(MbProductPriceChanged)(MbProductVersion); +readModel()(MbProductVersion); + +class MbOrderPlaced { + amount!: number; +} +eventType()(MbOrderPlaced); + +class MbOrderSummaryWithCustomSequence { + id!: string; + totalAmount!: number; +} +setFrom(MbOrderPlaced, 'amount')(MbOrderSummaryWithCustomSequence.prototype, 'totalAmount'); +fromEvent(MbOrderPlaced)(MbOrderSummaryWithCustomSequence); +eventSequence('custom-sequence')(MbOrderSummaryWithCustomSequence); +readModel()(MbOrderSummaryWithCustomSequence); + +class MbLocalEvent { + data!: string; +} +eventType()(MbLocalEvent); + +class MbLocalSnapshot { + id!: string; + data!: string; +} +setFrom(MbLocalEvent, 'data')(MbLocalSnapshot.prototype, 'data'); +fromEvent(MbLocalEvent)(MbLocalSnapshot); +eventLog(MbLocalSnapshot); +readModel()(MbLocalSnapshot); + +class MbProjectNoted { + note!: string; +} +eventType()(MbProjectNoted); + +class MbProjectNoteCleared {} +eventType()(MbProjectNoteCleared); + +class MbProjectNotes { + id!: string; + note!: string | undefined; +} +setFrom(MbProjectNoted, 'note')(MbProjectNotes.prototype, 'note'); +clearWith(MbProjectNoteCleared)(MbProjectNotes.prototype, 'note'); +fromEvent(MbProjectNoted)(MbProjectNotes); +readModel()(MbProjectNotes); + +interface BuiltFromEntry { + Key: { Id: string }; + Value: { Properties: Record; Key: string; ParentKey: string }; +} + +interface BuiltDefinition { + EventSequenceId: string; + AutoMap: AutoMap; + NoAutoMapProperties: string[]; + All: { Properties: Record }; + From: BuiltFromEntry[]; +} + +function createProjections(readModels: (new (...args: unknown[]) => unknown)[]) { + const registerMock = vi.fn().mockResolvedValue(undefined); + const registerManyMock = vi.fn().mockResolvedValue(undefined); + const connection = { + readModels: { registerMany: registerManyMock }, + projections: { register: registerMock } + } as unknown as ChronicleConnection; + + const clientArtifacts: IClientArtifactsProvider = { + eventTypes: [], + readModels: readModels as unknown as IClientArtifactsProvider['readModels'], + reactors: [], + reducers: [], + seeders: [], + constraints: [], + projections: [], + webhooks: [], + eventTypeMigrations: [] + }; + + const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink'); + return { projections, registerMock }; +} + +async function registerAndGetDefinition(readModelType: new (...args: unknown[]) => unknown): Promise { + const { projections, registerMock } = createProjections([readModelType]); + await projections.register(); + return registerMock.mock.calls[0][0].Projections[0] as BuiltDefinition; +} + +function findFromEntry(definition: BuiltDefinition, eventTypeId: string): BuiltFromEntry { + const entry = definition.From.find(candidate => candidate.Key.Id === eventTypeId); + if (!entry) { + throw new Error(`No From entry found for event type '${eventTypeId}'.`); + } + return entry; +} + +describe('Projections model-bound completeness', () => { + describe('when a property is excluded from AutoMap with noAutoMap', () => { + it('should keep AutoMap enabled at the root and list only the excluded property', async () => { + const definition = await registerAndGetDefinition(MbAssignmentSummary); + + expect(definition.AutoMap).toBe(AutoMap.Enabled); + expect(definition.NoAutoMapProperties).toEqual(['location']); + + const workArrangementSet = findFromEntry(definition, 'MbWorkArrangementSet'); + expect(workArrangementSet.Value.Properties.location).toBe('location'); + + const candidateSubmitted = findFromEntry(definition, 'MbCandidateSubmitted'); + expect(candidateSubmitted.Value.Properties.candidateName).toBe('name'); + }); + }); + + describe('when a whole model-bound projection is excluded from AutoMap with class-level noAutoMap', () => { + it('should disable AutoMap for the whole definition', async () => { + const definition = await registerAndGetDefinition(MbFullyExcluded); + expect(definition.AutoMap).toBe(AutoMap.Disabled); + }); + }); + + describe('when a property uses fromAll to read from every event type', () => { + it('should include it in the All properties map', async () => { + const definition = await registerAndGetDefinition(MbProductVersion); + expect(definition.All.Properties.version).toBe('version'); + }); + }); + + describe('when a model-bound projection declares a custom event sequence', () => { + it('should use the declared event sequence instead of the event log', async () => { + const definition = await registerAndGetDefinition(MbOrderSummaryWithCustomSequence); + expect(definition.EventSequenceId).toBe('custom-sequence'); + }); + }); + + describe('when a model-bound projection is explicitly pinned to the event log', () => { + it('should use the event log sequence', async () => { + const definition = await registerAndGetDefinition(MbLocalSnapshot); + expect(definition.EventSequenceId).toBe('event-log'); + }); + }); + + describe('when a root scalar property is cleared with an event', () => { + it('should add a $null mapping for that property on the clearing event', async () => { + const definition = await registerAndGetDefinition(MbProjectNotes); + const clearedBy = findFromEntry(definition, 'MbProjectNoteCleared'); + expect(clearedBy.Value.Properties.note).toBe('$null'); + }); + }); +}); diff --git a/Source/projections/Projections.operationalSurface.spec.ts b/Source/projections/Projections.operationalSurface.spec.ts new file mode 100644 index 0000000..b6f5ead --- /dev/null +++ b/Source/projections/Projections.operationalSurface.spec.ts @@ -0,0 +1,257 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { describe, expect, it, vi } from 'vitest'; +import { IClientArtifactsProvider } from '../artifacts'; +import { ChronicleConnection } from '../connection'; +import { ObserverRunningState } from '../observation/ObserverRunningState'; +import { eventType } from '../events/eventTypeDecorator'; +import { readModel } from '../readModels/readModel'; +import { eventSequence } from './modelBound/eventSequence'; +import { fromEvent } from './modelBound/fromEvent'; +import { projection } from './declarative/projection'; +import type { IProjectionBuilderFor } from './declarative/IProjectionBuilderFor'; +import type { IProjectionFor } from './declarative/IProjectionFor'; +import { Projections } from './Projections'; +import { UnableToQueryProjection } from './UnableToQueryProjection'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support. + +class OpStateChanged { + value!: string; +} +eventType()(OpStateChanged); + +class OpSummary { + id!: string; + value!: string; +} +fromEvent(OpStateChanged)(OpSummary); +eventSequence('custom-op-sequence')(OpSummary); +readModel()(OpSummary); + +class UndiscoveredSummary { + id!: string; +} +readModel()(UndiscoveredSummary); + +class DeclarativeReadModel { + id!: string; +} +readModel()(DeclarativeReadModel); + +class DeclarativeSummaryProjection implements IProjectionFor { + // discover() never calls define() - only register() does - so a no-op body is sufficient here. + define(_builder: IProjectionBuilderFor): void {} +} +projection('DeclarativeSummary', DeclarativeReadModel)(DeclarativeSummaryProjection); + +function wireObserverInformation() { + return { + RunningState: 1, // Active + IsSubscribed: true, + NextEventSequenceNumber: 5n, + LastHandledEventSequenceNumber: 4n, + TailEventSequenceNumber: 4n + }; +} + +async function createDiscoveredProjections() { + const getObserverInformation = vi.fn().mockResolvedValue(wireObserverInformation()); + const replay = vi.fn().mockResolvedValue({ JobId: '11111111-1111-1111-1111-111111111111' }); + const getFailedPartitions = vi.fn().mockResolvedValue({ items: [] }); + const preview = vi.fn().mockResolvedValue({ Value0: { ReadModelEntries: ['{"id":"1"}'] } }); + const registerMock = vi.fn().mockResolvedValue(undefined); + const registerManyMock = vi.fn().mockResolvedValue(undefined); + + const connection = { + observers: { getObserverInformation, replay }, + failedPartitions: { getFailedPartitions }, + projections: { register: registerMock, preview }, + readModels: { registerMany: registerManyMock } + } as unknown as ChronicleConnection; + + const clientArtifacts: IClientArtifactsProvider = { + eventTypes: [], + readModels: [OpSummary, UndiscoveredSummary, DeclarativeReadModel] as unknown as IClientArtifactsProvider['readModels'], + reactors: [], + reducers: [], + seeders: [], + constraints: [], + projections: [DeclarativeSummaryProjection] as unknown as IClientArtifactsProvider['projections'], + webhooks: [], + eventTypeMigrations: [] + }; + + const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink'); + await projections.discover(); + + return { projections, getObserverInformation, replay, getFailedPartitions, preview }; +} + +describe('Projections operational surface', () => { + describe('when checking hasFor with a discovered projection identifier', () => { + it('should return true', async () => { + const { projections } = await createDiscoveredProjections(); + expect(projections.hasFor('OpSummary')).toBe(true); + }); + }); + + describe('when checking hasFor with an unknown projection identifier', () => { + it('should return false', async () => { + const { projections } = await createDiscoveredProjections(); + expect(projections.hasFor('unknown-projection')).toBe(false); + }); + }); + + describe('when checking hasForModel for a discovered model-bound read model', () => { + it('should return true', async () => { + const { projections } = await createDiscoveredProjections(); + expect(projections.hasForModel(OpSummary)).toBe(true); + }); + }); + + describe('when checking hasForModel for an undiscovered read model', () => { + it('should return false', async () => { + const { projections } = await createDiscoveredProjections(); + expect(projections.hasForModel(UndiscoveredSummary)).toBe(false); + }); + }); + + describe('when getting the projection identifier for a model-bound read model', () => { + it('should resolve the read model identifier', async () => { + const { projections } = await createDiscoveredProjections(); + expect(projections.getProjectionIdFor(OpSummary).value).toBe('OpSummary'); + }); + }); + + describe('when getting the projection identifier for a declarative projection with an explicit read model type', () => { + it('should resolve the declared projection identifier', async () => { + const { projections } = await createDiscoveredProjections(); + expect(projections.getProjectionIdFor(DeclarativeReadModel).value).toBe('DeclarativeSummary'); + }); + }); + + describe('when getting the projection identifier for an undiscovered read model', () => { + it('should throw', async () => { + const { projections } = await createDiscoveredProjections(); + expect(() => projections.getProjectionIdFor(UndiscoveredSummary)).toThrow(); + }); + }); + + describe('when getting state for a projection by identifier', () => { + it('should call GetObserverInformation and map the response', async () => { + const { projections, getObserverInformation } = await createDiscoveredProjections(); + + const state = await projections.getStateFor('OpSummary'); + + expect(getObserverInformation).toHaveBeenCalledTimes(1); + const request = getObserverInformation.mock.calls[0][0]; + expect(request.EventStore).toEqual('test-store'); + expect(request.Namespace).toEqual('test-namespace'); + expect(request.ObserverId).toEqual('OpSummary'); + + expect(state.runningState).toBe(ObserverRunningState.Active); + expect(state.isSubscribed).toBe(true); + expect(state.nextEventSequenceNumber.value).toEqual(5n); + expect(state.lastHandledEventSequenceNumber.value).toEqual(4n); + expect(state.tailEventSequenceNumber.value).toEqual(4n); + }); + }); + + describe('when getting state for a model-bound read model with a custom event sequence', () => { + it('should resolve the identifier and pass the model-declared event sequence', async () => { + const { projections, getObserverInformation } = await createDiscoveredProjections(); + + await projections.getStateForModel(OpSummary); + + const request = getObserverInformation.mock.calls[0][0]; + expect(request.ObserverId).toEqual('OpSummary'); + expect(request.EventSequenceId).toEqual('custom-op-sequence'); + }); + }); + + describe('when getting failed partitions for a model-bound read model', () => { + it('should call GetFailedPartitions with the resolved projection identifier', async () => { + const { projections, getFailedPartitions } = await createDiscoveredProjections(); + + await projections.getFailedPartitionsForModel(OpSummary); + + expect(getFailedPartitions).toHaveBeenCalledTimes(1); + const request = getFailedPartitions.mock.calls[0][0]; + expect(request.ObserverId).toEqual('OpSummary'); + }); + }); + + describe('when replaying a projection by identifier', () => { + it('should call Replay and parse the returned job id', async () => { + const { projections, replay } = await createDiscoveredProjections(); + + const jobId = await projections.replay('OpSummary'); + + expect(replay).toHaveBeenCalledTimes(1); + const request = replay.mock.calls[0][0]; + expect(request.ObserverId).toEqual('OpSummary'); + expect(jobId.toString()).toEqual('11111111-1111-1111-1111-111111111111'); + }); + }); + + describe('when replaying a projection that is not replayable', () => { + it('should resolve to a not-set job id', async () => { + const { projections, replay } = await createDiscoveredProjections(); + replay.mockResolvedValueOnce({ JobId: '' }); + + const jobId = await projections.replay('OpSummary'); + + expect(jobId.value.toString()).toEqual('00000000-0000-0000-0000-000000000000'); + }); + }); + + describe('when replaying a model-bound read model', () => { + it('should resolve the identifier and replay it', async () => { + const { projections, replay } = await createDiscoveredProjections(); + + await projections.replayForModel(OpSummary); + + const request = replay.mock.calls[0][0]; + expect(request.ObserverId).toEqual('OpSummary'); + }); + }); + + describe('when querying a projection declaration', () => { + it('should call Preview and return the read model entries', async () => { + const { projections, preview } = await createDiscoveredProjections(); + + const result = await projections.query('projection Orders\n from OpStateChanged'); + + expect(preview).toHaveBeenCalledTimes(1); + const request = preview.mock.calls[0][0]; + expect(request.EventSequenceId).toEqual('event-log'); + expect(result.readModelEntries).toEqual(['{"id":"1"}']); + }); + }); + + describe('when querying a projection declaration with a custom event sequence', () => { + it('should pass the given event sequence identifier', async () => { + const { projections, preview } = await createDiscoveredProjections(); + + await projections.query('projection Orders\n from OpStateChanged', 'inbox'); + + const request = preview.mock.calls[0][0]; + expect(request.EventSequenceId).toEqual('inbox'); + }); + }); + + describe('when querying a projection declaration that fails to parse', () => { + it('should throw UnableToQueryProjection with the syntax errors', async () => { + const { projections, preview } = await createDiscoveredProjections(); + preview.mockResolvedValueOnce({ + Value1: { Errors: [{ Message: 'Unexpected token', Line: 2, Column: 3 }] } + }); + + await expect(projections.query('projection Bad')).rejects.toThrow(UnableToQueryProjection); + }); + }); +}); diff --git a/Source/projections/Projections.spec.ts b/Source/projections/Projections.spec.ts index f39920b..f35960b 100644 --- a/Source/projections/Projections.spec.ts +++ b/Source/projections/Projections.spec.ts @@ -85,7 +85,7 @@ function createProjections(readModels: (new (...args: unknown[]) => unknown)[]) eventTypeMigrations: [] }; - const projections = new Projections('test-store', connection, clientArtifacts, 'test-sink'); + const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink'); return { projections, registerMock }; } diff --git a/Source/projections/Projections.ts b/Source/projections/Projections.ts index eb33cfa..781c9ef 100644 --- a/Source/projections/Projections.ts +++ b/Source/projections/Projections.ts @@ -12,6 +12,11 @@ import { ChronicleConnection } from '../connection'; import { toContractsGuid } from '../connection/Guid'; import { WellKnownSinks } from '../sinks'; import { EventSequenceId } from '../eventSequences/EventSequenceId'; +import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber'; +import { JobId } from '../jobs/JobId'; +import { FailedPartition } from '../observation/FailedPartition'; +import { FailedPartitions } from '../observation/FailedPartitions'; +import { toObserverRunningState } from '../observation/toObserverRunningState'; import { getReadModelMetadata } from '../readModels'; import { TypeIntrospector } from '../types'; import { IProjections } from './IProjections'; @@ -24,20 +29,28 @@ import { buildNestedEntry, ChildrenDefinitionLike, ContractEventType, + ensureFromEntry, FromRecord, getEventTypeMapKey, toContractEventType } from './modelBound/childrenAndNestedBuilder'; import { getChildrenFromMetadata } from './modelBound/childrenFrom'; +import { getClearWithPropertyMetadata } from './modelBound/clearWith'; +import { getEventSequenceMetadata } from './modelBound/eventSequence'; +import { getFromAllMetadata } from './modelBound/fromAll'; import { getFromEveryMetadata } from './modelBound/fromEvery'; import { getFromEventMetadata, hasFromEventMetadata } from './modelBound/fromEvent'; import { getJoinMetadata } from './modelBound/join'; +import { isNoAutoMap, isPropertyNoAutoMap } from './modelBound/noAutoMap'; import { ProjectionId } from './ProjectionId'; +import { ProjectionQueryResult } from './ProjectionQueryResult'; +import { ProjectionState } from './ProjectionState'; import { isNested } from './modelBound/nested'; import { isNotRewindable } from './modelBound/notRewindable'; import { isPassive } from './modelBound/passive'; import { getRemovedWithClassMetadata, getRemovedWithPropertyMetadata } from './modelBound/removedWith'; import { getRemovedWithJoinClassMetadata, getRemovedWithJoinPropertyMetadata } from './modelBound/removedWithJoin'; +import { UnableToQueryProjection } from './UnableToQueryProjection'; interface ResolvedModelBoundMetadata { id: ProjectionId; @@ -52,19 +65,27 @@ interface ResolvedModelBoundMetadata { export class Projections implements IProjections { private readonly _declarative = new Map(); private readonly _modelBound = new Map(); + private readonly _failedPartitions: FailedPartitions; private readonly _logger = diag.createComponentLogger({ namespace: '@cratis/chronicle/projections' }); /** * Creates a new {@link Projections} instance. + * @param _eventStore - The event store name. + * @param _namespace - The event store namespace. + * @param _connection - Chronicle connection. * @param _clientArtifacts - Provider for discovered client artifact types. + * @param _defaultSinkTypeId - The identifier of the default read model sink. */ constructor( private readonly _eventStore: string, + private readonly _namespace: string, private readonly _connection: ChronicleConnection, private readonly _clientArtifacts: IClientArtifactsProvider, private readonly _defaultSinkTypeId: string - ) {} + ) { + this._failedPartitions = new FailedPartitions(_eventStore, _namespace, _connection); + } /** @inheritdoc */ async discover(): Promise { @@ -137,6 +158,148 @@ export class Projections implements IProjections { } } + /** @inheritdoc */ + hasFor(projectionId: ProjectionId | string): boolean { + const id = this.toProjectionIdValue(projectionId); + return this._declarative.has(id) || this._modelBound.has(id); + } + + /** @inheritdoc */ + hasForModel(readModelType: Constructor): boolean { + try { + this.resolveProjectionIdForModel(readModelType); + return true; + } catch { + return false; + } + } + + /** @inheritdoc */ + getProjectionIdFor(readModelType: Constructor): ProjectionId { + return this.resolveProjectionIdForModel(readModelType); + } + + /** @inheritdoc */ + async getStateFor(projectionId: ProjectionId | string): Promise { + const id = this.toProjectionIdValue(projectionId); + const eventSequenceId = this.resolveEventSequenceIdFor(id); + + const response = await this._connection.observers.getObserverInformation({ + EventStore: this._eventStore, + Namespace: this._namespace, + ObserverId: id, + EventSequenceId: eventSequenceId + }); + + return { + runningState: toObserverRunningState(response.RunningState), + isSubscribed: response.IsSubscribed, + nextEventSequenceNumber: new EventSequenceNumber(response.NextEventSequenceNumber), + lastHandledEventSequenceNumber: new EventSequenceNumber(response.LastHandledEventSequenceNumber), + tailEventSequenceNumber: new EventSequenceNumber(response.TailEventSequenceNumber) + }; + } + + /** @inheritdoc */ + getStateForModel(readModelType: Constructor): Promise { + return this.getStateFor(this.resolveProjectionIdForModel(readModelType)); + } + + /** @inheritdoc */ + getFailedPartitionsForModel(readModelType: Constructor): Promise { + const projectionId = this.resolveProjectionIdForModel(readModelType); + return this._failedPartitions.getFailedPartitionsFor(projectionId.value); + } + + /** @inheritdoc */ + async replay(projectionId: ProjectionId | string): Promise { + const id = this.toProjectionIdValue(projectionId); + + const response = await this._connection.observers.replay({ + EventStore: this._eventStore, + Namespace: this._namespace, + ObserverId: id, + + // The kernel resolves the observer's own event sequence from its identifier - it does not + // need to be told which one to replay. + EventSequenceId: '' + }); + + return response.JobId ? JobId.from(response.JobId) : JobId.from(Guid.empty); + } + + /** @inheritdoc */ + replayForModel(readModelType: Constructor): Promise { + return this.replay(this.resolveProjectionIdForModel(readModelType)); + } + + /** @inheritdoc */ + async query(declaration: string, eventSequenceId: string = EventSequenceId.eventLog.value): Promise { + const result = await this._connection.projections.preview({ + EventStore: this._eventStore, + Namespace: this._namespace, + EventSequenceId: eventSequenceId, + Declaration: declaration + }); + + if (result.Value1) { + throw new UnableToQueryProjection(result.Value1.Errors.map(error => error.Message)); + } + + return { + readModelEntries: result.Value0?.ReadModelEntries ?? [] + }; + } + + private toProjectionIdValue(projectionId: ProjectionId | string): string { + return typeof projectionId === 'string' ? projectionId : projectionId.value; + } + + /** + * Resolves the {@link ProjectionId} of the projection that maintains a specific read model type, + * from what has already been discovered. + * @param readModelType - The read model type to resolve for. + * @returns The resolved {@link ProjectionId}. + */ + private resolveProjectionIdForModel(readModelType: Constructor): ProjectionId { + for (const [id, type] of this._modelBound) { + if (type === readModelType) { + return new ProjectionId(id); + } + } + + for (const [id, type] of this._declarative) { + const metadata = getProjectionMetadata(type); + if (metadata?.readModelType === readModelType) { + return new ProjectionId(id); + } + } + + throw new Error( + `No projection found for read model '${readModelType.name}'. Make sure discover() has run and the ` + + 'read model is model-bound, or its declarative projection() decorator specifies an explicit readModelType.'); + } + + /** + * Resolves the event sequence identifier a discovered projection reads from, without requiring + * its full definition to have been built. + * @param projectionId - The projection identifier to resolve for. + * @returns The resolved event sequence identifier. + */ + private resolveEventSequenceIdFor(projectionId: string): string { + const modelBoundType = this._modelBound.get(projectionId); + if (modelBoundType) { + return getEventSequenceMetadata(modelBoundType) ?? EventSequenceId.eventLog.value; + } + + const declarativeType = this._declarative.get(projectionId); + if (declarativeType) { + return getProjectionMetadata(declarativeType)?.eventSequenceId ?? EventSequenceId.eventLog.value; + } + + return EventSequenceId.eventLog.value; + } + private async registerWithRetry(projection: unknown, identifier: string, maxAttempts = 5): Promise { let delay = 2000; for (let attempt = 1; attempt <= maxAttempts; attempt++) { @@ -344,14 +507,25 @@ export class Projections implements IProjections { childrenByProperty[property] = buildChildrenEntry(type, property, childrenFromList); } - if (isNested(prototype, property)) { + const propertyIsNested = isNested(prototype, property); + if (propertyIsNested) { nestedByProperty[property] = buildNestedEntry(type, property); } + + // A scalar (non-nested, non-children-collection) root property clears back to no value + // every time the given event is observed. A nested single-object property's clearWith is + // handled by buildNestedEntry instead, which clears the whole nested object. + if (childrenFromList.length === 0 && !propertyIsNested) { + for (const clearWith of getClearWithPropertyMetadata(prototype, property)) { + const entry = ensureFromEntry(fromByEventType, clearWith.eventType); + entry.Value.Properties[property] = '$null'; + } + } } const allProperties: Record = {}; for (const property of properties) { - const fromEvery = getFromEveryMetadata(prototype, property); + const fromEvery = getFromEveryMetadata(prototype, property) ?? getFromAllMetadata(prototype, property); if (fromEvery) { allProperties[property] = fromEvery.contextProperty ? fromEvery.contextProperty @@ -379,7 +553,8 @@ export class Projections implements IProjections { RemovedWithJoin: Array.from(removedWithJoinByEventType.values()), LastUpdated: { Value: '' }, Tags: [], - AutoMap: AutoMap.Enabled, + AutoMap: isNoAutoMap(type) ? AutoMap.Disabled : AutoMap.Enabled, + NoAutoMapProperties: properties.filter(property => isPropertyNoAutoMap(prototype, property)), Nested: nestedByProperty }; definition.LastUpdated = { Value: this.computeStableLastUpdated(definition) }; @@ -392,7 +567,7 @@ export class Projections implements IProjections { if (readModelMetadata && hasFromEventMetadata(type)) { return { id: new ProjectionId(readModelMetadata.id.value), - eventSequenceId: undefined, + eventSequenceId: getEventSequenceMetadata(type), readModelIdentifier: readModelMetadata.id.value }; } diff --git a/Source/projections/UnableToQueryProjection.ts b/Source/projections/UnableToQueryProjection.ts new file mode 100644 index 0000000..79a60fa --- /dev/null +++ b/Source/projections/UnableToQueryProjection.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Error thrown when a projection query fails because the Projection Declaration Language + * declaration contains errors. + */ +export class UnableToQueryProjection extends Error { + /** + * Creates a new {@link UnableToQueryProjection}. + * @param errors - The collection of error messages describing why the query failed. + */ + constructor(errors: ReadonlyArray) { + super(`Unable to query projection. Errors:\n${errors.join('\n')}`); + this.name = 'UnableToQueryProjection'; + } +} diff --git a/Source/projections/index.ts b/Source/projections/index.ts index 22355fc..4e6e73b 100644 --- a/Source/projections/index.ts +++ b/Source/projections/index.ts @@ -4,5 +4,8 @@ export { ProjectionId } from './ProjectionId'; export type { IProjections } from './IProjections'; export { Projections } from './Projections'; +export type { ProjectionState } from './ProjectionState'; +export type { ProjectionQueryResult } from './ProjectionQueryResult'; +export { UnableToQueryProjection } from './UnableToQueryProjection'; export * from './declarative'; export * from './modelBound'; diff --git a/Source/projections/modelBound/eventSequence.ts b/Source/projections/modelBound/eventSequence.ts new file mode 100644 index 0000000..d66f5ed --- /dev/null +++ b/Source/projections/modelBound/eventSequence.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { EventSequenceId } from '../../eventSequences/EventSequenceId'; + +const METADATA_KEY = 'chronicle:projection:eventSequence'; + +/** + * Class decorator that overrides the event sequence a model-bound projection reads from. + * When applied, auto-inbox routing is suppressed - the explicit value is always honored. + * @param sequence - The event sequence identifier to read from. + * @returns A class decorator. + */ +export function eventSequence(sequence: string): ClassDecorator { + return (target: object) => { + Reflect.defineMetadata(METADATA_KEY, sequence, target); + }; +} + +/** + * Convenience class decorator that pins a model-bound projection to the default event log + * sequence. Equivalent to `eventSequence(EventSequenceId.eventLog.value)`, but more expressive + * about the intent to read from the local event log rather than an inbox or another sequence. + * @param target - The class constructor. + */ +export function eventLog(target: Function): void { + Reflect.defineMetadata(METADATA_KEY, EventSequenceId.eventLog.value, target); +} + +/** + * Retrieves the explicit event sequence identifier stored on a class, if any. + * @param target - The class constructor. + * @returns The event sequence identifier, or undefined when the class has no explicit override. + */ +export function getEventSequenceMetadata(target: Function): string | undefined { + return Reflect.getMetadata(METADATA_KEY, target); +} diff --git a/Source/projections/modelBound/fromAll.ts b/Source/projections/modelBound/fromAll.ts new file mode 100644 index 0000000..fb5e129 --- /dev/null +++ b/Source/projections/modelBound/fromAll.ts @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { TypeIntrospector } from '../../types'; + +/** Metadata stored by the fromAll property decorator. */ +export interface FromAllMetadata { + /** The event property name to read the value from. */ + readonly property?: string; + /** The event context property name to read the value from. */ + readonly contextProperty?: string; +} + +const METADATA_KEY = 'chronicle:projection:fromAll'; + +/** + * Property decorator that sets the decorated read model property from a property present on every + * event type the projection is built from - the convention-based counterpart to declaring the same + * mapping on every individual `fromEvent`. Equivalent to {@link fromEvery} for model-bound + * projections; use whichever name reads better for the property being mapped. + * @param property - Optional event property name. If not specified, uses the model property name. + * @param contextProperty - Optional event context property name. + * @returns A property decorator. + */ +export function fromAll(property?: string, contextProperty?: string): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + TypeIntrospector.trackProperty((target as { constructor: Function }).constructor, propertyKey.toString()); + const metadata: FromAllMetadata = { property, contextProperty }; + Reflect.defineMetadata(METADATA_KEY, metadata, target, propertyKey.toString()); + }; +} + +/** + * Retrieves fromAll metadata stored on the given property. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns The fromAll metadata, or undefined if not decorated. + */ +export function getFromAllMetadata(target: object, propertyKey: string): FromAllMetadata | undefined { + return Reflect.getMetadata(METADATA_KEY, target, propertyKey); +} diff --git a/Source/projections/modelBound/index.ts b/Source/projections/modelBound/index.ts index 30b9270..3a9fa00 100644 --- a/Source/projections/modelBound/index.ts +++ b/Source/projections/modelBound/index.ts @@ -35,3 +35,7 @@ export { setValue, getSetValueMetadata } from './setValue'; export type { SetValueMetadata } from './setValue'; export { fromEvery, getFromEveryMetadata } from './fromEvery'; export type { FromEveryMetadata } from './fromEvery'; +export { fromAll, getFromAllMetadata } from './fromAll'; +export type { FromAllMetadata } from './fromAll'; +export { noAutoMap, isNoAutoMap, isPropertyNoAutoMap } from './noAutoMap'; +export { eventSequence, eventLog, getEventSequenceMetadata } from './eventSequence'; diff --git a/Source/projections/modelBound/noAutoMap.ts b/Source/projections/modelBound/noAutoMap.ts new file mode 100644 index 0000000..bfd43ef --- /dev/null +++ b/Source/projections/modelBound/noAutoMap.ts @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { TypeIntrospector } from '../../types'; + +const CLASS_METADATA_KEY = 'chronicle:projection:noAutoMap:class'; +const PROPERTY_METADATA_KEY = 'chronicle:projection:noAutoMap:property'; + +/** + * Class or property decorator that disables AutoMap for a model-bound projection. + * Applied to a class, it prevents AutoMap from mapping any property automatically. + * Applied to a property, it excludes only that single property from AutoMap while every other + * property keeps mapping - use it to stop an unrelated event that carries an identically named + * property from silently overwriting a property whose value is set explicitly (for example via + * `setFrom`). + * @param target - The class constructor, or the class prototype when used on a property. + * @param propertyKey - The property name, when used as a property decorator. + */ +export function noAutoMap(target: object, propertyKey?: string | symbol): void { + if (propertyKey !== undefined) { + const key = propertyKey.toString(); + TypeIntrospector.trackProperty((target as { constructor: Function }).constructor, key); + Reflect.defineMetadata(PROPERTY_METADATA_KEY, true, target, key); + } else { + Reflect.defineMetadata(CLASS_METADATA_KEY, true, target as Function); + } +} + +/** + * Checks whether the given class has AutoMap disabled entirely. + * @param target - The class constructor. + * @returns True if the class is marked with {@link noAutoMap}; false otherwise. + */ +export function isNoAutoMap(target: Function): boolean { + return Reflect.hasMetadata(CLASS_METADATA_KEY, target); +} + +/** + * Checks whether a specific property is excluded from AutoMap. + * @param target - The class prototype. + * @param propertyKey - The property name. + * @returns True if the property is marked with {@link noAutoMap}; false otherwise. + */ +export function isPropertyNoAutoMap(target: object, propertyKey: string): boolean { + return Reflect.hasMetadata(PROPERTY_METADATA_KEY, target, propertyKey); +} diff --git a/Source/reactors/Reactors.ts b/Source/reactors/Reactors.ts index 00d000d..601002d 100644 --- a/Source/reactors/Reactors.ts +++ b/Source/reactors/Reactors.ts @@ -12,6 +12,9 @@ import { getEventTypeMetadata } from '../events/eventTypeDecorator'; import { EventContext } from '../events/EventContext'; import { EventTypeId } from '../events/EventTypeId'; import { EventTypeGeneration } from '../events/EventTypeGeneration'; +import { Tag } from '../events/Tag'; +import { getTagsFor } from '../events/tagDecorator'; +import { getFilterTagsFor } from '../events/filterEventsByTagDecorator'; import { EventSequenceId } from '../eventSequences/EventSequenceId'; import type { IEventLog } from '../eventSequences/IEventLog'; import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle'; @@ -239,9 +242,9 @@ export class Reactors implements IReactors { Key: EVENT_SOURCE_ID_KEY })), IsReplayable: false, - Tags: [], + Tags: getTagsFor(reactorType).map(t => t.value), Filters: { - FilterTags: [], + FilterTags: getFilterTagsFor(reactorType).map(t => t.value), EventSourceType: '', EventStreamType: 'All' } @@ -303,7 +306,8 @@ export class Reactors implements IReactors { }, occurred: new Date(event.Context!.Occurred?.Value ?? ''), correlationId: event.Context?.CorrelationId ? `${event.Context.CorrelationId.lo}-${event.Context.CorrelationId.hi}` : '', - causation: [] + causation: [], + tags: (event.Context!.Tags ?? []).map(value => new Tag(value)) }; this._logger.info('Invoking reactor handler', { diff --git a/Source/readModels/MaterializedReadModels.spec.ts b/Source/readModels/MaterializedReadModels.spec.ts new file mode 100644 index 0000000..30e0667 --- /dev/null +++ b/Source/readModels/MaterializedReadModels.spec.ts @@ -0,0 +1,98 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { field } from '@cratis/fundamentals'; +import { describe, expect, it, vi } from 'vitest'; +import type { ChronicleConnection } from '../connection'; +import { pii } from '../compliance/pii'; +import { subject } from '../compliance/subject'; +import { readModel } from './readModel'; +import { MaterializedReadModels } from './MaterializedReadModels'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support. + +function createMaterializedReadModels(instancesJson: string[], releaseResponse: Record = { HasError: false, Payload: '{}' }) { + const release = vi.fn().mockResolvedValue(releaseResponse); + const getInstances = vi.fn().mockResolvedValue({ Instances: instancesJson }); + const connection = { + compliance: { release }, + materializedReadModels: { getInstances } + } as unknown as ChronicleConnection; + + const readModels = new MaterializedReadModels('test-store', 'test-namespace', connection); + return { readModels, release }; +} + +describe('MaterializedReadModels', () => { + describe('when a read model has a property decorated with @subject()', () => { + class Employee { + id = ''; + personId = ''; + ssn = ''; + } + field(String)(Employee.prototype, 'id'); + field(String)(Employee.prototype, 'personId'); + field(String)(Employee.prototype, 'ssn'); + pii()(Employee.prototype, 'ssn'); + subject()(Employee.prototype, 'personId'); + readModel('EmployeeWithSubjectMaterialized')(Employee); + + it('should release using the decorated property as the subject', async () => { + const json = JSON.stringify({ id: 'employee-1', personId: 'person-42', ssn: '123-45-6789' }); + const { readModels, release } = createMaterializedReadModels([json]); + + await readModels.getInstances(Employee); + + expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'person-42' })); + }); + }); + + describe('when a read model has no property decorated with @subject() but has an id property', () => { + class Customer { + id = ''; + ssn = ''; + } + field(String)(Customer.prototype, 'id'); + field(String)(Customer.prototype, 'ssn'); + pii()(Customer.prototype, 'ssn'); + readModel('CustomerWithIdOnlyMaterialized')(Customer); + + it('should fall back to the id property as the subject, unchanged from today', async () => { + const json = JSON.stringify({ id: 'customer-7', ssn: '987-65-4321' }); + const { readModels, release } = createMaterializedReadModels([json]); + + await readModels.getInstances(Customer); + + expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-7' })); + }); + }); + + describe('when a read model has neither a decorated property nor an id property', () => { + class Anonymous { + ssn = ''; + } + field(String)(Anonymous.prototype, 'ssn'); + pii()(Anonymous.prototype, 'ssn'); + readModel('AnonymousMaterialized')(Anonymous); + + it('should not call release, unchanged from today', async () => { + const json = JSON.stringify({ ssn: '000-00-0000' }); + const { readModels, release } = createMaterializedReadModels([json]); + + await readModels.getInstances(Anonymous); + + expect(release).not.toHaveBeenCalled(); + }); + + it('should return the instance unreleased', async () => { + const json = JSON.stringify({ ssn: '000-00-0000' }); + const { readModels } = createMaterializedReadModels([json]); + + const [instance] = await readModels.getInstances(Anonymous); + + expect(instance.ssn).toBe('000-00-0000'); + }); + }); +}); diff --git a/Source/readModels/MaterializedReadModels.ts b/Source/readModels/MaterializedReadModels.ts index 1096b0e..9238e71 100644 --- a/Source/readModels/MaterializedReadModels.ts +++ b/Source/readModels/MaterializedReadModels.ts @@ -6,6 +6,7 @@ import { JsonSerializer } from '@cratis/fundamentals'; import { ChronicleConnection } from '../connection'; import { JsonSchemaGenerator } from '../schemas'; import { getReadModelMetadata } from './readModel'; +import { ReadModelSubjectResolver } from './ReadModelSubjectResolver'; import type { IMaterializedReadModels } from './IMaterializedReadModels'; const defaultTake = 50; @@ -97,7 +98,7 @@ export class MaterializedReadModels implements IMaterializedReadModels { } private async releaseInstance(readModelType: Constructor, instance: TReadModel, schema: string): Promise { - const subject = this.extractSubject(instance); + const subject = ReadModelSubjectResolver.resolveFrom(readModelType, instance); if (!subject) { return instance; } @@ -141,11 +142,4 @@ export class MaterializedReadModels implements IMaterializedReadModels { } } - private extractSubject(instance: TReadModel): string | undefined { - const anyInstance = instance as Record; - if (anyInstance.id !== undefined && anyInstance.id !== null) { - return String(anyInstance.id); - } - return undefined; - } } diff --git a/Source/readModels/ReadModelSubjectResolver.spec.ts b/Source/readModels/ReadModelSubjectResolver.spec.ts new file mode 100644 index 0000000..d220551 --- /dev/null +++ b/Source/readModels/ReadModelSubjectResolver.spec.ts @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { describe, expect, it } from 'vitest'; +import { subject } from '../compliance/subject'; +import { ReadModelSubjectResolver } from './ReadModelSubjectResolver'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support. + +describe('ReadModelSubjectResolver', () => { + describe('when the read model has a property decorated with @subject()', () => { + class Employee { + id = ''; + personId = ''; + } + subject()(Employee.prototype, 'personId'); + + it('should resolve to the decorated property value', () => { + const instance = new Employee(); + instance.id = 'employee-1'; + instance.personId = 'person-42'; + + expect(ReadModelSubjectResolver.resolveFrom(Employee, instance)).toBe('person-42'); + }); + + it('should take precedence over the id property', () => { + const instance = new Employee(); + instance.id = 'employee-1'; + instance.personId = 'person-42'; + + expect(ReadModelSubjectResolver.resolveFrom(Employee, instance)).not.toBe('employee-1'); + }); + + it('should fall back to id when the decorated property has no value', () => { + const instance = new Employee(); + instance.id = 'employee-1'; + instance.personId = ''; + + expect(ReadModelSubjectResolver.resolveFrom(Employee, instance)).toBe('employee-1'); + }); + }); + + describe('when the read model has no property decorated with @subject()', () => { + class Customer { + id = ''; + name = ''; + } + + it('should fall back to the id property', () => { + const instance = new Customer(); + instance.id = 'customer-7'; + + expect(ReadModelSubjectResolver.resolveFrom(Customer, instance)).toBe('customer-7'); + }); + }); + + describe('when the read model has neither a decorated property nor an id property', () => { + class Anonymous { + name = ''; + } + + it('should not resolve a subject', () => { + const instance = new Anonymous(); + + expect(ReadModelSubjectResolver.resolveFrom(Anonymous, instance)).toBeUndefined(); + }); + }); + + describe('when the instance does not exist', () => { + class Customer { + id = ''; + } + + it('should not resolve a subject for null', () => { + expect(ReadModelSubjectResolver.resolveFrom(Customer, null as unknown as Customer)).toBeUndefined(); + }); + + it('should not resolve a subject for undefined', () => { + expect(ReadModelSubjectResolver.resolveFrom(Customer, undefined as unknown as Customer)).toBeUndefined(); + }); + }); +}); diff --git a/Source/readModels/ReadModelSubjectResolver.ts b/Source/readModels/ReadModelSubjectResolver.ts new file mode 100644 index 0000000..03493a6 --- /dev/null +++ b/Source/readModels/ReadModelSubjectResolver.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { Constructor } from '@cratis/fundamentals'; +import { getSubjectPropertyName } from '../compliance/subject'; + +/** + * Resolves the compliance subject - the natural person a read model's Personal Identifiable + * Information (PII) belongs to - from a read model instance. + */ +export class ReadModelSubjectResolver { + /** + * Attempts to derive the compliance subject from a read model instance. + * + * Resolution order: + * 1. The property decorated with `@subject()` on {@link readModelType}, when it has a value. + * 2. The `id` property, by convention - kept so read models that predate `@subject()` + * continue to resolve exactly as before. + * @param readModelType - The read model type to resolve subject metadata for. + * @param instance - The read model instance to inspect, or undefined/null for a read model + * that does not exist. + * @returns The resolved subject, or undefined when neither source yields a value. + */ + static resolveFrom(readModelType: Constructor, instance: TReadModel): string | undefined { + if (instance === undefined || instance === null) { + return undefined; + } + + const anyInstance = instance as Record; + const subjectProperty = getSubjectPropertyName(readModelType); + const explicitSubject = subjectProperty ? ReadModelSubjectResolver.toSubject(anyInstance[subjectProperty]) : undefined; + + return explicitSubject ?? ReadModelSubjectResolver.toSubject(anyInstance.id); + } + + private static toSubject(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + const stringValue = String(value); + return stringValue.length > 0 ? stringValue : undefined; + } +} diff --git a/Source/readModels/ReadModels.spec.ts b/Source/readModels/ReadModels.spec.ts new file mode 100644 index 0000000..74aa24d --- /dev/null +++ b/Source/readModels/ReadModels.spec.ts @@ -0,0 +1,118 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import type { Constructor } from '@cratis/fundamentals'; +import { describe, expect, it, vi } from 'vitest'; +import type { IClientArtifactsProvider } from '../artifacts'; +import type { ChronicleConnection } from '../connection'; +import { subject } from '../compliance/subject'; +import { fromEvent } from '../projections/modelBound/fromEvent'; +import { readModel } from './readModel'; +import { ReadModels } from './ReadModels'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support. + +class SomeEvent { + value = ''; +} + +function createReadModels(readModelType: Constructor, releaseResponse: Record = { HasError: false, Payload: '{}' }) { + const release = vi.fn().mockResolvedValue(releaseResponse); + const connection = { + compliance: { release } + } as unknown as ChronicleConnection; + + const clientArtifacts = { + eventTypes: [], + readModels: [readModelType], + reactors: [], + reducers: [], + seeders: [], + constraints: [], + projections: [], + webhooks: [], + eventTypeMigrations: [] + } as IClientArtifactsProvider; + + const readModels = new ReadModels('test-store', 'test-namespace', connection, clientArtifacts, 'default-sink'); + return { readModels, release }; +} + +describe('ReadModels', () => { + describe('when releasing a read model with a property decorated with @subject()', () => { + class Employee { + id = ''; + personId = ''; + } + subject()(Employee.prototype, 'personId'); + fromEvent(SomeEvent)(Employee); + readModel('EmployeeWithSubject')(Employee); + + it('should release using the decorated property as the subject', async () => { + const { readModels, release } = createReadModels(Employee); + const instance = new Employee(); + instance.id = 'employee-1'; + instance.personId = 'person-42'; + + await readModels.release(Employee, instance); + + expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'person-42' })); + }); + }); + + describe('when releasing a read model without @subject() but with an id property', () => { + class Customer { + id = ''; + } + fromEvent(SomeEvent)(Customer); + readModel('CustomerWithIdOnly')(Customer); + + it('should fall back to the id property as the subject, unchanged from today', async () => { + const { readModels, release } = createReadModels(Customer); + const instance = new Customer(); + instance.id = 'customer-7'; + + await readModels.release(Customer, instance); + + expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-7' })); + }); + }); + + describe('when releasing a read model with neither @subject() nor an id property', () => { + class Anonymous { + name = ''; + } + fromEvent(SomeEvent)(Anonymous); + readModel('AnonymousReadModel')(Anonymous); + + it('should throw, same as today', async () => { + const { readModels } = createReadModels(Anonymous); + const instance = new Anonymous(); + + await expect(readModels.release(Anonymous, instance)).rejects.toThrow(/subject/); + }); + }); + + describe('when releasing many read model instances', () => { + class Customer { + id = ''; + } + fromEvent(SomeEvent)(Customer); + readModel('CustomerForReleaseMany')(Customer); + + it('should release each instance using its own resolved subject', async () => { + const { readModels, release } = createReadModels(Customer); + const first = new Customer(); + first.id = 'customer-1'; + const second = new Customer(); + second.id = 'customer-2'; + + await readModels.releaseMany(Customer, [first, second]); + + expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-1' })); + expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-2' })); + }); + }); +}); diff --git a/Source/readModels/ReadModels.ts b/Source/readModels/ReadModels.ts index 2b7e4ab..c116327 100644 --- a/Source/readModels/ReadModels.ts +++ b/Source/readModels/ReadModels.ts @@ -23,6 +23,7 @@ import { WellKnownSinks } from '../sinks'; import { getReadModelMetadata } from './readModel'; import type { IMaterializedReadModels } from './IMaterializedReadModels'; import { MaterializedReadModels } from './MaterializedReadModels'; +import { ReadModelSubjectResolver } from './ReadModelSubjectResolver'; import type { IReadModels } from './IReadModels'; import type { ReadModelChangeset } from './ReadModelChangeset'; import type { ReadModelSnapshot } from './ReadModelSnapshot'; @@ -173,7 +174,7 @@ export class ReadModels implements IReadModels { const readModel = this.resolveReadModel(readModelType); const schema = this.getReadModelSchema(readModelType, readModel.identifier); const payload = JsonSerializer.serialize(instance); - const subject = this.extractSubject(instance); + const subject = this.extractSubject(readModelType, instance); const response = await this._connection.compliance.release({ EventStore: this._eventStore, @@ -345,12 +346,11 @@ export class ReadModels implements IReadModels { } } - private extractSubject(instance: TReadModel): string { - // By convention, use the 'id' property as the subject - const anyInstance = instance as any; - if (anyInstance.id !== undefined && anyInstance.id !== null) { - return String(anyInstance.id); + private extractSubject(readModelType: Constructor, instance: TReadModel): string { + const subject = ReadModelSubjectResolver.resolveFrom(readModelType, instance); + if (subject !== undefined) { + return subject; } - throw new Error('Read model instance must have an "id" property to serve as the subject for PII release'); + throw new Error('Read model instance must have a property decorated with @subject() or an "id" property to serve as the subject for PII release'); } } diff --git a/Source/readModels/index.ts b/Source/readModels/index.ts index 368249a..70cf548 100644 --- a/Source/readModels/index.ts +++ b/Source/readModels/index.ts @@ -6,6 +6,7 @@ export type { IReadModels } from './IReadModels'; export type { IMaterializedReadModels } from './IMaterializedReadModels'; export { ReadModels } from './ReadModels'; export { MaterializedReadModels } from './MaterializedReadModels'; +export { ReadModelSubjectResolver } from './ReadModelSubjectResolver'; export type { ReadModelChangeset } from './ReadModelChangeset'; export type { ReadModelSnapshot } from './ReadModelSnapshot'; export { readModel, getReadModelMetadata, isReadModel } from './readModel'; diff --git a/Source/reducers/Reducers.spec.ts b/Source/reducers/Reducers.spec.ts index 42fc734..2a1876f 100644 --- a/Source/reducers/Reducers.spec.ts +++ b/Source/reducers/Reducers.spec.ts @@ -6,11 +6,48 @@ import type { Constructor } from '@cratis/fundamentals'; import type { IClientArtifactsProvider } from '../artifacts'; import type { ChronicleConnection } from '../connection'; import { ConnectionLifecycle } from '../connection/ConnectionLifecycle'; +import { eventType, getEventTypeFor } from '../events/eventTypeDecorator'; +import type { EventContext } from '../events/EventContext'; +import { filterEventsByTag } from '../events/filterEventsByTagDecorator'; +import { tag } from '../events/tagDecorator'; import { reducer } from './reducer'; import { Reducers } from './Reducers'; const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +class ReducersSomeEventHappened { + constructor(readonly value: string = '') {} +} +eventType('d2b2b2b2-2222-4a3c-9d3f-6f2f4a3c9d3f')(ReducersSomeEventHappened); + +class SomeTaggedReducerState { + count = 0; +} + +class SomeTaggedReducer { + reducersSomeEventHappened(): SomeTaggedReducerState { + return { count: 1 }; + } +} +reducer('some-tagged-reducer', undefined, SomeTaggedReducerState)(SomeTaggedReducer); +tag('Analytics', 'Reporting')(SomeTaggedReducer); +filterEventsByTag('vip')(SomeTaggedReducer); +filterEventsByTag('priority')(SomeTaggedReducer); + +const receivedContexts: EventContext[] = []; + +class CapturingReducerState { + count = 0; +} + +class CapturingReducer { + reducersSomeEventHappened(_event: ReducersSomeEventHappened, current: CapturingReducerState | undefined, context: EventContext): CapturingReducerState { + receivedContexts.push(context); + return { count: (current?.count ?? 0) + 1 }; + } +} +reducer('capturing-reducer', undefined, CapturingReducerState)(CapturingReducer); + /** * A minimal client artifacts provider exposing only the reducer types under test — * Reducers.ts only reads .reducers and .eventTypes off this during discovery. @@ -84,4 +121,47 @@ describe('Reducers', () => { expect(message.Content.Value0.Reducer.IsActive).toBe(false); }); }); + + describe('when registering a reducer that is tagged and filters by tag', () => { + type TaggedRegistration = { + Content: { Value0: { Reducer: { Tags: string[]; Filters: { FilterTags: string[] } } } }; + }; + + const register = async () => { + const { connection, registrationMessages } = createConnection(); + const reducers = new Reducers(createArtifacts([SomeTaggedReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink'); + + await reducers.register(); + await flush(); + + return (registrationMessages[0] as TaggedRegistration).Content.Value0.Reducer; + }; + + it('should carry the tags the reducer is labeled with', async () => { + expect((await register()).Tags).toEqual(['Analytics', 'Reporting']); + }); + + it('should carry every tag it filters events by', async () => { + expect((await register()).Filters.FilterTags).toEqual(['vip', 'priority']); + }); + }); + + describe('when registering a reducer that is neither tagged nor filtered', () => { + class SomeUntaggedReducer {} + reducer('some-untagged-reducer')(SomeUntaggedReducer); + + it('should send empty tag lists rather than omitting them', async () => { + const { connection, registrationMessages } = createConnection(); + const reducers = new Reducers(createArtifacts([SomeUntaggedReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink'); + + await reducers.register(); + await flush(); + + const message = registrationMessages[0] as { + Content: { Value0: { Reducer: { Tags: string[]; Filters: { FilterTags: string[] } } } }; + }; + expect(message.Content.Value0.Reducer.Tags).toEqual([]); + expect(message.Content.Value0.Reducer.Filters.FilterTags).toEqual([]); + }); + }); }); diff --git a/Source/reducers/Reducers.ts b/Source/reducers/Reducers.ts index 6571f3b..23462fb 100644 --- a/Source/reducers/Reducers.ts +++ b/Source/reducers/Reducers.ts @@ -10,6 +10,12 @@ import { ChronicleConnection } from '../connection'; import { toContractsGuid } from '../connection/Guid'; import { ConnectionLifecycle } from '../connection/ConnectionLifecycle'; import { getEventTypeMetadata } from '../events/eventTypeDecorator'; +import { EventContext } from '../events/EventContext'; +import { EventTypeId } from '../events/EventTypeId'; +import { EventTypeGeneration } from '../events/EventTypeGeneration'; +import { Tag } from '../events/Tag'; +import { getTagsFor } from '../events/tagDecorator'; +import { getFilterTagsFor } from '../events/filterEventsByTagDecorator'; import { EventSequenceId } from '../eventSequences/EventSequenceId'; import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle'; import { IReducers } from './IReducers'; @@ -310,9 +316,9 @@ export class Reducers implements IReducers { })), ReadModel: readModelName, IsActive: isActive, - Tags: [], + Tags: getTagsFor(reducerType).map(t => t.value), Filters: { - FilterTags: [], + FilterTags: getFilterTagsFor(reducerType).map(t => t.value), EventSourceType: '', EventStreamType: 'All' } @@ -369,6 +375,19 @@ export class Reducers implements IReducers { } const content = JSON.parse(event.Content) as Record; + const context: EventContext = { + sequenceNumber: event.Context!.SequenceNumber, + eventSourceId: event.Context!.EventSourceId, + eventType: { + id: new EventTypeId(event.Context!.EventType!.Id), + generation: new EventTypeGeneration(event.Context!.EventType!.Generation), + tombstone: event.Context!.EventType!.Tombstone + }, + occurred: new Date(event.Context!.Occurred?.Value ?? ''), + correlationId: event.Context?.CorrelationId ? `${event.Context.CorrelationId.lo}-${event.Context.CorrelationId.hi}` : '', + causation: [], + tags: (event.Context!.Tags ?? []).map(value => new Tag(value)) + }; this._logger.info('Invoking reducer handler', { reducerId: id, @@ -378,7 +397,7 @@ export class Reducers implements IReducers { hasState: currentState !== undefined }); - currentState = await reducerInstance[entry.methodName](content, currentState); + currentState = await reducerInstance[entry.methodName](content, currentState, context); lastSuccessfullyObservedEvent = event.Context!.SequenceNumber; } catch (err) { this._logger.error('Error handling event in reducer', { reducerId: id, error: String(err) }); diff --git a/Source/schemas/JsonSchema.ts b/Source/schemas/JsonSchema.ts index 3e958fb..5d591b1 100644 --- a/Source/schemas/JsonSchema.ts +++ b/Source/schemas/JsonSchema.ts @@ -1,6 +1,21 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +/** + * Represents a single compliance classification recorded on a schema node. + */ +export interface ComplianceSchemaMetadata { + /** + * The type of compliance metadata (e.g. 'PII'). + */ + metadataType: string; + + /** + * Any additional details - can be empty. + */ + details: string; +} + /** * Represents a JSON Schema object. */ @@ -15,4 +30,5 @@ export type JsonSchema = { items?: JsonSchema; additionalProperties?: boolean | JsonSchema; enum?: Array; + compliance?: ComplianceSchemaMetadata[]; }; diff --git a/Source/schemas/JsonSchemaGenerator.spec.ts b/Source/schemas/JsonSchemaGenerator.spec.ts new file mode 100644 index 0000000..55dc09a --- /dev/null +++ b/Source/schemas/JsonSchemaGenerator.spec.ts @@ -0,0 +1,195 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { ConceptAs, field } from '@cratis/fundamentals'; +import { describe, expect, it } from 'vitest'; +import { pii } from '../compliance/pii'; +import { eventType, getEventTypeJsonSchemaFor } from '../events/eventTypeDecorator'; +import { getReadModelMetadata, readModel } from '../readModels/readModel'; +import { JsonSchema } from './JsonSchema'; + +// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these +// fixtures don't depend on the test runner's decorator-syntax support - they exercise exactly +// the same decorator functions and metadata storage that `@decorator` syntax would invoke. +// +// Order matters here in a way it would not for a real `@decorator` stack: class decorators +// apply bottom-to-top, so `pii()` must run - and therefore be called - before `readModel()`/ +// `eventType()` so the compliance metadata already exists when the schema is generated. + +function schemaFor(target: Function): JsonSchema { + return getReadModelMetadata(target)!.schema; +} + +describe('JsonSchemaGenerator', () => { + describe('when a class is marked @pii() at the class level', () => { + class PersonProfile { + name = ''; + email = ''; + } + pii()(PersonProfile); + readModel()(PersonProfile); + + const schema = schemaFor(PersonProfile); + + it('should mark every one of its own properties as PII', () => { + expect(schema.properties!.name.compliance).toEqual([{ metadataType: 'PII', details: '' }]); + expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: '' }]); + }); + }); + + describe('when only a single property is marked @pii()', () => { + class Contact { + name = ''; + ssn = ''; + } + pii('Social security number')(Contact.prototype, 'ssn'); + readModel()(Contact); + + const schema = schemaFor(Contact); + + it('should mark the decorated property as PII', () => { + expect(schema.properties!.ssn.compliance).toEqual([{ metadataType: 'PII', details: 'Social security number' }]); + }); + + it('should leave the other property without compliance metadata', () => { + expect(schema.properties!.name.compliance).toBeUndefined(); + }); + }); + + describe('when a property is typed as a ConceptAs marked @pii()', () => { + class EmailAddress extends ConceptAs { + constructor(value: string) { + super(value); + } + } + pii('Email address')(EmailAddress); + + describe('on a read model', () => { + class Customer { + email: EmailAddress = new EmailAddress(''); + } + readModel()(Customer); + + const schema = schemaFor(Customer); + + it('should mark the concept-typed property as PII', () => { + expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: 'Email address' }]); + }); + + it('should describe the concept as its underlying primitive type', () => { + expect(schema.properties!.email.type).toBe('string'); + }); + }); + + describe('on an event', () => { + class CustomerRegistered { + email: EmailAddress = new EmailAddress(''); + } + eventType()(CustomerRegistered); + + const schema = getEventTypeJsonSchemaFor(CustomerRegistered); + + it('should mark the concept-typed property as PII', () => { + expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: 'Email address' }]); + }); + }); + }); + + describe('when a property-level @pii() combines with a class-level @pii()', () => { + class Employee { + name = ''; + email = ''; + } + pii()(Employee.prototype, 'name'); + pii('Every field on this record is personal')(Employee); + readModel()(Employee); + + const schema = schemaFor(Employee); + + it('should not duplicate the compliance entry on the property carrying both markers', () => { + expect(schema.properties!.name.compliance).toHaveLength(1); + }); + + it('should keep the property-level details when both sources apply', () => { + expect(schema.properties!.name.compliance).toEqual([{ metadataType: 'PII', details: '' }]); + }); + + it('should still mark the property that only carries the class-level marker', () => { + expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: 'Every field on this record is personal' }]); + }); + }); + + describe('when a property typed as a nested composite value object is marked @pii()', () => { + class ContactDetails { + phone = ''; + fax = ''; + } + class Vendor { + contact: ContactDetails = new ContactDetails(); + } + pii('Vendor contact information')(Vendor.prototype, 'contact'); + readModel()(Vendor); + + const schema = schemaFor(Vendor); + + it('should push the compliance metadata down onto every leaf property', () => { + expect(schema.properties!.contact.properties!.phone.compliance).toEqual([{ metadataType: 'PII', details: 'Vendor contact information' }]); + expect(schema.properties!.contact.properties!.fax.compliance).toEqual([{ metadataType: 'PII', details: 'Vendor contact information' }]); + }); + + it('should not leave compliance metadata on the container node itself', () => { + expect(schema.properties!.contact.compliance).toBeUndefined(); + }); + }); + + describe('when an array element is a ConceptAs marked @pii()', () => { + class RequirementCode extends ConceptAs { + constructor(value: string) { + super(value); + } + } + pii('Sensitive requirement code')(RequirementCode); + + class Contract { + codes: RequirementCode[] = []; + } + field(Array, { enumerable: true, genericArguments: [RequirementCode] })(Contract.prototype, 'codes'); + readModel()(Contract); + + const schema = schemaFor(Contract); + + it('should describe the property as an array', () => { + expect(schema.properties!.codes.type).toBe('array'); + }); + + it('should carry the element concept compliance metadata onto the item schema', () => { + expect(schema.properties!.codes.items!.compliance).toEqual([{ metadataType: 'PII', details: 'Sensitive requirement code' }]); + }); + + it('should describe the item as the concept underlying primitive type', () => { + expect(schema.properties!.codes.items!.type).toBe('string'); + }); + + it('should not leave coarse compliance metadata on the array container itself', () => { + expect(schema.properties!.codes.compliance).toBeUndefined(); + }); + }); + + describe('when an array element is not a ConceptAs', () => { + class PlainItem { + value = ''; + } + class Basket { + items: PlainItem[] = []; + } + field(Array, { enumerable: true, genericArguments: [PlainItem] })(Basket.prototype, 'items'); + readModel()(Basket); + + const schema = schemaFor(Basket); + + it('should fall back to an opaque object item schema', () => { + expect(schema.properties!.items.items).toEqual({ type: 'object' }); + }); + }); +}); diff --git a/Source/schemas/JsonSchemaGenerator.ts b/Source/schemas/JsonSchemaGenerator.ts index 2437339..47682d1 100644 --- a/Source/schemas/JsonSchemaGenerator.ts +++ b/Source/schemas/JsonSchemaGenerator.ts @@ -2,9 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import 'reflect-metadata'; -import { ConceptAs, Guid } from '@cratis/fundamentals'; -import { JsonSchema } from './JsonSchema'; +import { ConceptAs, Constructor, Fields, Guid } from '@cratis/fundamentals'; +import { ComplianceSchemaMetadata, JsonSchema } from './JsonSchema'; import { TypeIntrospector } from '../types'; +import { ComplianceMetadata } from '../compliance/ComplianceMetadata'; import { ComplianceMetadataResolver } from '../compliance/ComplianceMetadataResolver'; /** @@ -48,12 +49,17 @@ export class JsonSchemaGenerator { const prototype = target.prototype; for (const [memberName, memberType] of membersToUse.entries()) { - const propertySchema = this.mapRuntimeTypeToSchema(memberType); + const propertySchema = this.mapRuntimeTypeToSchema(memberType, target, memberName); // Only include properties whose type was resolved. An empty schema ({}) means // the runtime type was unavailable (e.g. esbuild/tsx omits design:paramtypes). if (Object.keys(propertySchema).length > 0) { - // Add compliance metadata to property schema if present (both property and type-level) - this.addComplianceMetadata(propertySchema, prototype, memberName, memberType); + // An array of concept elements resolves and applies its own item-level compliance + // inside mapRuntimeTypeToSchema - the general property/type/declaring-class walk + // below is skipped for it, mirroring the C# generator's enumerable-of-concept branch. + if (!this.isConceptArrayMember(target, memberName, memberType)) { + const metadata = this.collectComplianceMetadata(prototype, memberName, memberType); + this.addComplianceMetadataToSchema(propertySchema, metadata); + } schemaProperties[memberName] = propertySchema; } } @@ -72,7 +78,7 @@ export class JsonSchemaGenerator { }; } - private static mapRuntimeTypeToSchema(runtimeType: Function | undefined): JsonSchema { + private static mapRuntimeTypeToSchema(runtimeType: Function | undefined, declaringType?: Function, propertyName?: string): JsonSchema { const knownTypeFormat = this.getKnownTypeFormat(runtimeType); if (knownTypeFormat) { return knownTypeFormat; @@ -91,7 +97,7 @@ export class JsonSchemaGenerator { } if (runtimeType === Array) { - return { type: 'array', items: { type: 'object' } }; + return this.mapArrayTypeToSchema(declaringType, propertyName); } if (!runtimeType) { @@ -115,6 +121,63 @@ export class JsonSchemaGenerator { return { type: 'object' }; } + /** + * Maps an array-typed member to a schema, resolving the element type from a + * `@field(Array, { genericArguments: [ItemType] })` declaration when present. + * @param declaringType - The class constructor that declares the array property. + * @param propertyName - The array property name. + * @returns The array schema, with the element's own compliance metadata carried onto `items` when the element is a PII concept. + */ + private static mapArrayTypeToSchema(declaringType: Function | undefined, propertyName: string | undefined): JsonSchema { + const elementType = this.getArrayElementType(declaringType, propertyName); + + // An array whose element is a ConceptAs loses its classification the moment it is put in + // a list unless the element concept's own compliance metadata is carried onto the item schema - + // a value that would be encrypted as a scalar would otherwise be persisted in the clear as a + // list element. Mirrors the C# generator's explicit enumerable-of-concept branch. + if (elementType && this.isConceptAs(elementType)) { + const itemSchema = this.mapRuntimeTypeToSchema(elementType); + const metadata = ComplianceMetadataResolver.getMetadataForType(elementType); + this.addComplianceMetadataToSchema(itemSchema, metadata); + return { type: 'array', items: itemSchema }; + } + + return { type: 'array', items: { type: 'object' } }; + } + + /** + * Resolves the element type of an array property from its `@field(Array, { genericArguments: [...] })` + * declaration. TypeScript erases generic type arguments at runtime, so without an explicit + * `@field` declaration the element type cannot be recovered. + * @param declaringType - The class constructor that declares the array property. + * @param propertyName - The array property name. + * @returns The element type constructor, or undefined when it cannot be resolved. + */ + private static getArrayElementType(declaringType: Function | undefined, propertyName: string | undefined): Function | undefined { + if (!declaringType || !propertyName) { + return undefined; + } + + const field = Fields.getFieldsForType(declaringType as Constructor).find(candidate => candidate.name === propertyName); + return field?.genericArguments?.[0]; + } + + /** + * Checks whether a member is an array whose element type is a ConceptAs. + * @param declaringType - The class constructor that declares the property. + * @param propertyName - The property name. + * @param runtimeType - The member's reflected runtime type. + * @returns True when the member is an array of concept elements; false otherwise. + */ + private static isConceptArrayMember(declaringType: Function, propertyName: string, runtimeType: Function | undefined): boolean { + if (runtimeType !== Array) { + return false; + } + + const elementType = this.getArrayElementType(declaringType, propertyName); + return elementType !== undefined && this.isConceptAs(elementType); + } + private static isConceptAs(runtimeType: Function): boolean { let current: Function | null = runtimeType; while (current && current !== Function.prototype) { @@ -150,35 +213,90 @@ export class JsonSchemaGenerator { } /** - * Adds compliance metadata to a property schema if the property has compliance decorators. - * Also checks if the property's type itself is marked as PII (e.g., ConceptAs types). - * @param schema - The property schema to add compliance metadata to. - * @param target - The class prototype. + * Collects compliance metadata for a property from every source C# resolves PII from: the + * property itself, its declaring class, and its own type (the concept case). + * @param target - The class prototype the property is declared on. * @param propertyKey - The property name. + * @param propertyType - The property's runtime type, when resolved. + * @returns The collected compliance metadata, in property → declaring-class → type order. */ - private static addComplianceMetadata(schema: JsonSchema, target: object, propertyKey: string, propertyType?: Function): void { - const complianceArray: Array<{ metadataType: string; details: string }> = []; + private static collectComplianceMetadata(target: object, propertyKey: string, propertyType?: Function): ComplianceMetadata[] { + const metadata: ComplianceMetadata[] = []; - // Check for property-level compliance decorators + // Property-level compliance decorator. if (ComplianceMetadataResolver.hasMetadataFor(target, propertyKey)) { - const propertyMetadata = ComplianceMetadataResolver.getMetadataFor(target, propertyKey); - complianceArray.push(...propertyMetadata.map(metadata => ({ - metadataType: metadata.metadataType.value.toString(), - details: metadata.details - }))); + metadata.push(...ComplianceMetadataResolver.getMetadataFor(target, propertyKey)); + } + + // Declaring class-level compliance decorator - a class-level @pii() marks every one of + // its own properties, the same way C#'s PIIMetadataProvider checks property.DeclaringType. + const declaringClass = (target as { constructor?: Function }).constructor; + if (declaringClass) { + metadata.push(...ComplianceMetadataResolver.getMetadataForType(declaringClass)); } - // Check for type-level compliance decorators (e.g., @pii on ConceptAs) + // Type-level compliance decorator on the property's own type (e.g., @pii on a ConceptAs). if (propertyType) { - const typeMetadata = ComplianceMetadataResolver.getMetadataForType(propertyType); - complianceArray.push(...typeMetadata.map(metadata => ({ - metadataType: metadata.metadataType.value.toString(), - details: metadata.details - }))); + metadata.push(...ComplianceMetadataResolver.getMetadataForType(propertyType)); + } + + return metadata; + } + + /** + * Adds compliance metadata to a schema node, descending into an object's properties so that + * the metadata always lands on the leaves that actually hold a value. + * @param schema - The schema node to add to. + * @param metadata - The compliance metadata to add. + * @remarks + * A compliance marker can be declared on something that is not a single value: a `@pii()` on a + * composite value-object type, or on a property whose type is such an object. Compliance is + * applied per value, so leaving the marker on the container would make Chronicle hand the whole + * JSON object to the value handler and store one opaque ciphertext string where the schema still + * says "object". Releasing that gives back a string, not an object, and the read model then + * fails to materialize. Pushing the metadata down to every leaf keeps encryption symmetric with + * the release walk, keeps each value independently encrypted, and preserves the document shape. + * + * An array-typed node is deliberately left as a container: coarse compliance on a whole + * collection is an established, separately handled behavior (the collection is blob-encrypted + * and its shape restored on release). + */ + private static addComplianceMetadataToSchema(schema: JsonSchema, metadata: ComplianceMetadata[]): void { + if (metadata.length === 0) { + return; } - if (complianceArray.length > 0) { - (schema as Record).compliance = complianceArray; + if (schema.properties && Object.keys(schema.properties).length > 0) { + for (const propertySchema of Object.values(schema.properties)) { + this.addComplianceMetadataToSchema(propertySchema, metadata); + } + return; } + + const compliance = schema.compliance ?? []; + for (const item of metadata) { + const metadataType = item.metadataType.value.toString(); + if (!this.hasComplianceMetadataOfType(compliance, metadataType)) { + compliance.push({ metadataType, details: item.details }); + } + } + + if (compliance.length > 0) { + schema.compliance = compliance; + } + } + + /** + * Checks whether a compliance array already carries metadata of a given type. + * @param compliance - The compliance array to check. + * @param metadataType - The metadata type to look for. + * @returns True when the metadata type is already present, false if not. + * @remarks + * A leaf can be reached by more than one marker — for example a `@pii()` concept inside a value + * object whose type is itself marked `@pii()`. Recording the same metadata type twice adds + * nothing and makes the generated schema noisier to read and to diff. + */ + private static hasComplianceMetadataOfType(compliance: ComplianceSchemaMetadata[], metadataType: string): boolean { + return compliance.some(item => item.metadataType === metadataType); } } diff --git a/Source/schemas/index.ts b/Source/schemas/index.ts index 96d6fc0..88ba468 100644 --- a/Source/schemas/index.ts +++ b/Source/schemas/index.ts @@ -3,4 +3,4 @@ export { JsonSchemaGenerator } from './JsonSchemaGenerator'; export { jsonSchemaProperty, getTrackedJsonSchemaProperties } from './jsonSchemaProperty'; -export type { JsonSchema } from './JsonSchema'; +export type { ComplianceSchemaMetadata, JsonSchema } from './JsonSchema'; diff --git a/Source/seeding/EventSeeding.ts b/Source/seeding/EventSeeding.ts index 1d9afa9..e0a538c 100644 --- a/Source/seeding/EventSeeding.ts +++ b/Source/seeding/EventSeeding.ts @@ -6,6 +6,7 @@ import { IClientArtifactsProvider } from '../artifacts'; import { ChronicleConnection } from '../connection'; import { EventStoreNamespaceName } from '../EventStoreNamespaceName'; import { getEventTypeFor } from '../events/eventTypeDecorator'; +import { getTagsFor } from '../events/tagDecorator'; import { ICanSeedEvents } from './ICanSeedEvents'; import { IEventSeeding } from './IEventSeeding'; import { IEventSeedingBuilder } from './IEventSeedingBuilder'; @@ -104,11 +105,12 @@ export class EventSeeding implements IEventSeeding { private addEntries(eventSourceId: string, events: Iterable, isGlobal: boolean, targetNamespace: string): void { for (const event of events) { const eventType = getEventTypeFor(event.constructor as Function); + const tags = getTagsFor(event.constructor as Function).map(t => t.value); this._entries.push({ eventSourceId, eventTypeId: eventType.id.value, content: JSON.stringify(event), - tags: [], + tags, isGlobal, targetNamespace });