From d737fcc65a4e1b3a425d784bf04542f79f2b0683 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 22:29:15 +0200 Subject: [PATCH 1/9] Bump @cratis/chronicle to 6.29.0 --- Samples/Library/package.json | 2 +- Source/Chronicle/package.json | 2 +- Source/Cratis/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Samples/Library/package.json b/Samples/Library/package.json index 8a8f73ee..09813fdd 100644 --- a/Samples/Library/package.json +++ b/Samples/Library/package.json @@ -15,7 +15,7 @@ "@cratis/arc.core": "workspace:^", "@cratis/arc.express": "workspace:^", "@cratis/arc.testing": "workspace:^", - "@cratis/chronicle": "6.19.0", + "@cratis/chronicle": "6.29.0", "@cratis/fundamentals": "7.19.6", "express": "^5.1.0", "rxjs": "^7.8.2" diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index 283ddda9..75858726 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -49,7 +49,7 @@ "devDependencies": { "@cratis/arc.core": "workspace:^", "@cratis/arc.testing": "workspace:^", - "@cratis/chronicle": "6.19.0", + "@cratis/chronicle": "6.29.0", "@cratis/fundamentals": "7.19.6", "rxjs": "^7.8.2", "zod": "^4.1.0" diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index 9e2248ff..a87832d5 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -37,7 +37,7 @@ "zod": "^4.1.0" }, "devDependencies": { - "@cratis/chronicle": "6.19.0", + "@cratis/chronicle": "6.29.0", "@cratis/fundamentals": "7.19.6", "rxjs": "^7.8.2", "zod": "^4.1.0" diff --git a/yarn.lock b/yarn.lock index d860e747..c982379d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -105,7 +105,7 @@ __metadata: dependencies: "@cratis/arc.core": "workspace:^" "@cratis/arc.testing": "workspace:^" - "@cratis/chronicle": "npm:6.19.0" + "@cratis/chronicle": "npm:6.29.0" "@cratis/fundamentals": "npm:7.19.6" rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" @@ -344,7 +344,7 @@ __metadata: "@cratis/arc.express": "workspace:^" "@cratis/arc.react": "npm:22.19.1" "@cratis/arc.testing": "workspace:^" - "@cratis/chronicle": "npm:6.19.0" + "@cratis/chronicle": "npm:6.29.0" "@cratis/components": "npm:4.6.0" "@cratis/fundamentals": "npm:7.19.6" "@testing-library/dom": "npm:^10.4.2" @@ -393,9 +393,9 @@ __metadata: languageName: node linkType: hard -"@cratis/chronicle@npm:6.19.0": - version: 6.19.0 - resolution: "@cratis/chronicle@npm:6.19.0" +"@cratis/chronicle@npm:6.29.0": + version: 6.29.0 + resolution: "@cratis/chronicle@npm:6.29.0" dependencies: "@bufbuild/protobuf": "npm:^2.15.0" "@cratis/chronicle.contracts": "npm:19.6.1" @@ -408,7 +408,7 @@ __metadata: undici: "npm:^8.10.2" peerDependencies: "@cratis/fundamentals": ^7.19.2 - checksum: 10c0/6de0b9b55eb4a32aa0f0386f559a599f75120d4e65c4d05839b858597b83c7a12642bc5a328230d76c03bd44848edaa12abd399739c60bab4c4ccd4ce1b97f15 + checksum: 10c0/4dc269f68904be93f549826904f1bdfa7726d2b58bb1955c37686ddbb973dfff555e697c97b2acc80c2fee639033d919a76b86e166bb8a621eec20a268b50e81 languageName: node linkType: hard @@ -446,7 +446,7 @@ __metadata: "@cratis/arc.chronicle": "workspace:^" "@cratis/arc.core": "workspace:^" "@cratis/arc.testing": "workspace:^" - "@cratis/chronicle": "npm:6.19.0" + "@cratis/chronicle": "npm:6.29.0" "@cratis/fundamentals": "npm:7.19.6" rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" From ad7109e2e86a16464ac0a375550e6b1ec798b9ad Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 22:34:07 +0200 Subject: [PATCH 2/9] Share the MongoDB collection name rule with other Arc integrations --- Source/Core/fetch.ts | 2 ++ .../when_loading_the_public_barrels.ts | 3 ++ Source/Core/index.ts | 2 ++ .../readModels/ReadModelCollectionName.ts | 5 +++ .../readModelCollectionNameResolver.ts | 10 ++++++ .../with_a_collection_name_override.ts | 12 +++++++ .../when_resolving/with_a_naming_policy.ts | 12 +++++++ .../when_resolving/with_an_empty_name.ts | 13 +++++++ .../when_resolving/with_the_default_policy.ts | 11 ++++++ ...en_registering_the_collection_name_rule.ts | 35 +++++++++++++++++++ Source/MongoDB/index.ts | 1 + Source/MongoDB/resolveMongoCollectionName.ts | 17 +++++++++ Source/MongoDB/withMongoDB.ts | 9 ++--- 13 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 Source/Core/readModels/ReadModelCollectionName.ts create mode 100644 Source/Core/readModels/readModelCollectionNameResolver.ts create mode 100644 Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_collection_name_override.ts create mode 100644 Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_naming_policy.ts create mode 100644 Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_an_empty_name.ts create mode 100644 Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_the_default_policy.ts create mode 100644 Source/MongoDB/for_withMongoDB/when_registering_the_collection_name_rule.ts create mode 100644 Source/MongoDB/resolveMongoCollectionName.ts diff --git a/Source/Core/fetch.ts b/Source/Core/fetch.ts index c3ca5e14..0384464d 100644 --- a/Source/Core/fetch.ts +++ b/Source/Core/fetch.ts @@ -10,6 +10,8 @@ export interface ArcBuilderExtensions {} export type { ArcBuilderIntegrationOptions } from './ArcBuilderIntegrationOptions.js'; export { canonicalMetadataSignature } from './reflection/generatedMetadataSignature.js'; export { optionalService } from './dependencyInjection/optionalService.js'; +export { readModelCollectionNameResolver } from './readModels/readModelCollectionNameResolver.js'; +export type { ReadModelCollectionName } from './readModels/ReadModelCollectionName.js'; export type { GeneratedMetadata, GeneratedArtifactMetadata } from './reflection/GeneratedArtifactMetadata.js'; /** Convert model-bound values to the same JSON-ready shape as Arc's HTTP pipeline. */ export { encode as encodeWireValue } from './reflection/wireSchema.js'; diff --git a/Source/Core/for_exports/when_loading_the_public_barrels.ts b/Source/Core/for_exports/when_loading_the_public_barrels.ts index 22270273..0102bfe9 100644 --- a/Source/Core/for_exports/when_loading_the_public_barrels.ts +++ b/Source/Core/for_exports/when_loading_the_public_barrels.ts @@ -15,6 +15,9 @@ describe('when loading the public Arc barrels', () => { it('should expose failure tracking to hosting integrations', () => { hosting.recordFailure.should.be.a('function'); }); + it('should share the read model collection rule token between the Node and Fetch roots', () => { + node.readModelCollectionNameResolver.should.equal(fetch.readModelCollectionNameResolver); + }); it('should retain branded command outcomes on the root', () => { node.tuple.should.be.a('function'); node.rejected.should.be.a('function'); diff --git a/Source/Core/index.ts b/Source/Core/index.ts index a9ef4ccc..bf4a8d6c 100644 --- a/Source/Core/index.ts +++ b/Source/Core/index.ts @@ -9,6 +9,8 @@ export type { ArcBuilderOptions } from './configuration/ArcBuilderOptions.js'; export { discoveryFiles } from './reflection/discoveryFiles.js'; export { canonicalMetadataSignature } from './reflection/generatedMetadataSignature.js'; export { optionalService } from './dependencyInjection/optionalService.js'; +export { readModelCollectionNameResolver } from './readModels/readModelCollectionNameResolver.js'; +export type { ReadModelCollectionName } from './readModels/ReadModelCollectionName.js'; export type { GeneratedMetadata, GeneratedArtifactMetadata } from './reflection/GeneratedArtifactMetadata.js'; /** Convert model-bound values to the same JSON-ready shape as Arc's HTTP pipeline. */ export { encode as encodeWireValue } from './reflection/wireSchema.js'; diff --git a/Source/Core/readModels/ReadModelCollectionName.ts b/Source/Core/readModels/ReadModelCollectionName.ts new file mode 100644 index 00000000..91bf21ad --- /dev/null +++ b/Source/Core/readModels/ReadModelCollectionName.ts @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Names the collection an Arc storage integration reads a read model class from. */ +export type ReadModelCollectionName = (type: new () => object) => string; diff --git a/Source/Core/readModels/readModelCollectionNameResolver.ts b/Source/Core/readModels/readModelCollectionNameResolver.ts new file mode 100644 index 00000000..c195b871 --- /dev/null +++ b/Source/Core/readModels/readModelCollectionNameResolver.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. +import { serviceToken } from '../dependencyInjection/ServiceToken.js'; +import type { ReadModelCollectionName } from './ReadModelCollectionName.js'; + +/** + * Registered by a storage integration such as `withMongoDB` so other integrations, such as Chronicle, can store a + * read model in the collection the application reads it from. Absent when no such integration is configured. + */ +export const readModelCollectionNameResolver = serviceToken('ReadModelCollectionNameResolver'); diff --git a/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_collection_name_override.ts b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_collection_name_override.ts new file mode 100644 index 00000000..5c48f10a --- /dev/null +++ b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_collection_name_override.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { describe, it, should } from 'vitest'; +import { TaskRecord } from '../../for_MongoCollection/given/TaskRecord.js'; +import { camelCaseMongoNamingPolicy } from '../../MongoNamingPolicy.js'; +import { resolveMongoCollectionName } from '../../resolveMongoCollectionName.js'; + +should(); +describe('when resolving a collection name with an override and a naming policy', () => { + const name = resolveMongoCollectionName({ namingPolicy: camelCaseMongoNamingPolicy, collectionName: type => type.name }, TaskRecord); + it('should prefer the override', () => name.should.equal('TaskRecord')); +}); diff --git a/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_naming_policy.ts b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_naming_policy.ts new file mode 100644 index 00000000..0aed7f96 --- /dev/null +++ b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_a_naming_policy.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { describe, it, should } from 'vitest'; +import { TaskRecord } from '../../for_MongoCollection/given/TaskRecord.js'; +import { camelCaseMongoNamingPolicy } from '../../MongoNamingPolicy.js'; +import { resolveMongoCollectionName } from '../../resolveMongoCollectionName.js'; + +should(); +describe('when resolving a collection name with a naming policy', () => { + const name = resolveMongoCollectionName({ namingPolicy: camelCaseMongoNamingPolicy }, TaskRecord); + it('should use the policy collection name', () => name.should.equal('taskRecords')); +}); diff --git a/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_an_empty_name.ts b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_an_empty_name.ts new file mode 100644 index 00000000..3b67ba59 --- /dev/null +++ b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_an_empty_name.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { describe, it, should } from 'vitest'; +import { TaskRecord } from '../../for_MongoCollection/given/TaskRecord.js'; +import { resolveMongoCollectionName } from '../../resolveMongoCollectionName.js'; + +should(); +describe('when resolving a collection name and the override returns an empty name', () => { + let failure: Error | undefined; + try { resolveMongoCollectionName({ collectionName: () => '' }, TaskRecord); } + catch (error) { failure = error as Error; } + it('should fail instead of choosing a collection', () => failure!.message.should.equal('MongoDB collection name is required')); +}); diff --git a/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_the_default_policy.ts b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_the_default_policy.ts new file mode 100644 index 00000000..7515fb2b --- /dev/null +++ b/Source/MongoDB/for_resolveMongoCollectionName/when_resolving/with_the_default_policy.ts @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { describe, it, should } from 'vitest'; +import { TaskRecord } from '../../for_MongoCollection/given/TaskRecord.js'; +import { resolveMongoCollectionName } from '../../resolveMongoCollectionName.js'; + +should(); +describe('when resolving a collection name without options', () => { + const name = resolveMongoCollectionName({}, TaskRecord); + it('should pluralize the class name as the default policy does', () => name.should.equal('TaskRecords')); +}); diff --git a/Source/MongoDB/for_withMongoDB/when_registering_the_collection_name_rule.ts b/Source/MongoDB/for_withMongoDB/when_registering_the_collection_name_rule.ts new file mode 100644 index 00000000..94a3de3a --- /dev/null +++ b/Source/MongoDB/for_withMongoDB/when_registering_the_collection_name_rule.ts @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcApplication, readModelCollectionNameResolver, Severity } from '@cratis/arc.core'; +import type { ReadModelCollectionName } from '@cratis/arc.core'; +import type { MongoClient } from 'mongodb'; +import { TaskRecord } from '../for_MongoCollection/given/TaskRecord.js'; +import { camelCaseMongoNamingPolicy } from '../MongoNamingPolicy.js'; +import { resolveMongoCollectionName } from '../resolveMongoCollectionName.js'; +import '../withMongoDB.js'; + +should(); +describe('when registering MongoDB with a naming policy and a collection override', () => { + const overridden = (type: new () => object) => type === TaskRecord ? 'Tasks' : `${type.name}!`; + let rule: ReadModelCollectionName; + let ruleWithoutOverride: ReadModelCollectionName; + beforeEach(async () => { + const client = {} as MongoClient; + const resolve = async (options: { collectionName?: typeof overridden }) => { + const builder = ArcApplication.createBuilder(); + builder.withMongoDB({ client, database: 'Items', readModels: [TaskRecord], namingPolicy: camelCaseMongoNamingPolicy, ...options }); + const application = await builder.build(); + const scope = application.server.services.createScope({ tenantId: 'default', correlationId: crypto.randomUUID(), + principal: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Warning }); + try { return await scope.resolve(readModelCollectionNameResolver); } + finally { await scope.dispose(); await application.dispose(); } + }; + rule = await resolve({ collectionName: overridden }); + ruleWithoutOverride = await resolve({}); + }); + it('should expose the override to other integrations', () => rule(TaskRecord).should.equal('Tasks')); + it('should expose the naming policy name when there is no override', () => ruleWithoutOverride(TaskRecord).should.equal('taskRecords')); + it('should resolve the same name as Arc reads the collection from', () => + ruleWithoutOverride(TaskRecord).should.equal(resolveMongoCollectionName({ namingPolicy: camelCaseMongoNamingPolicy }, TaskRecord))); +}); diff --git a/Source/MongoDB/index.ts b/Source/MongoDB/index.ts index a9d319c0..3371091c 100644 --- a/Source/MongoDB/index.ts +++ b/Source/MongoDB/index.ts @@ -7,6 +7,7 @@ export { encodeGeometry, decodeGeometry } from './MongoGeoJSON.js'; export type { MongoGeometry } from './MongoGeoJSON.js'; export { defaultMongoNamingPolicy, camelCaseMongoNamingPolicy } from './MongoNamingPolicy.js'; export type { MongoNamingPolicy } from './MongoNamingPolicy.js'; +export { resolveMongoCollectionName } from './resolveMongoCollectionName.js'; export { MongoClientFactory } from './MongoClientFactory.js'; export { MongoCollection } from './MongoCollection.js'; export { MongoObservable } from './MongoObservable.js'; diff --git a/Source/MongoDB/resolveMongoCollectionName.ts b/Source/MongoDB/resolveMongoCollectionName.ts new file mode 100644 index 00000000..362d1dce --- /dev/null +++ b/Source/MongoDB/resolveMongoCollectionName.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. +import type { MongoDBOptions } from './MongoDBOptions.js'; +import { defaultMongoNamingPolicy } from './MongoNamingPolicy.js'; + +/** + * Resolve the collection a read model class is stored in: the `collectionName` override when set, otherwise the + * naming policy's name. Arc reads the collection through this rule, and the Chronicle client Arc creates writes + * projected read models through the same one. + */ +export function resolveMongoCollectionName(options: Pick, + type: new () => object): string { + const namingPolicy = options.namingPolicy ?? defaultMongoNamingPolicy; + const collectionName = options.collectionName?.(type) ?? namingPolicy.collectionName(type); + if (!collectionName) throw new Error('MongoDB collection name is required'); + return collectionName; +} diff --git a/Source/MongoDB/withMongoDB.ts b/Source/MongoDB/withMongoDB.ts index 73977ba9..f52c84a7 100644 --- a/Source/MongoDB/withMongoDB.ts +++ b/Source/MongoDB/withMongoDB.ts @@ -1,6 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { ArcApplicationBuilder, serviceToken } from '@cratis/arc.core'; +import { ArcApplicationBuilder, readModelCollectionNameResolver, serviceToken } from '@cratis/arc.core'; import type { ExecutionContext, ServiceScope } from '@cratis/arc.core'; import { ArcApplicationBuilder as FetchArcApplicationBuilder } from '@cratis/arc.core/fetch'; import { MongoClientFactory } from './MongoClientFactory.js'; @@ -10,6 +10,7 @@ import { MongoReadModelForCommandResolver } from './MongoReadModelForCommandReso import type { MongoDBOptions } from './MongoDBOptions.js'; import { mongoCollection } from './collectionToken.js'; import { defaultMongoNamingPolicy } from './MongoNamingPolicy.js'; +import { resolveMongoCollectionName } from './resolveMongoCollectionName.js'; /** Public token for applications needing to resolve clients explicitly. */ export const mongoClientFactory = serviceToken('MongoClientFactory'); @@ -23,6 +24,8 @@ export function withMongoDB(builder: ArcApplicationBuilder, configured: MongoDBO if (!options.database && !options.databaseNameResolver) throw new Error('MongoDB requires database or databaseNameResolver'); const factory = new MongoClientFactory(options); builder.services.addSingleton(mongoClientFactory, () => factory); + // Lets integrations that store read models elsewhere, such as Chronicle, use the collection Arc reads from. + builder.services.addSingleton(readModelCollectionNameResolver, () => (type: new () => object) => resolveMongoCollectionName(options, type)); builder.services.addScoped(MongoReadModelForCommandResolver, () => new MongoReadModelForCommandResolver(options)); builder.addReadModelForCommandResolver(MongoReadModelForCommandResolver); const resolveDatabase = async (scope: ServiceScope) => { @@ -44,9 +47,7 @@ export function withMongoDB(builder: ArcApplicationBuilder, configured: MongoDBO builder.services.addScoped(token, async scope => { const { context, database } = await resolveDatabase(scope); const namingPolicy = options.namingPolicy ?? defaultMongoNamingPolicy; - const collectionName = options.collectionName?.(type) ?? namingPolicy.collectionName(type); - if (!collectionName) throw new Error('MongoDB collection name is required'); - return new MongoCollection(database.collection(collectionName), database, type, context, { + return new MongoCollection(database.collection(resolveMongoCollectionName(options, type)), database, type, context, { ignoreConventions: options.ignoreConventions, maxObservableItems: options.maxObservableItems, maxPageSize: options.maxPageSize, namingPolicy }); From dde896c25cce2ea0ebf17e95f4462a571557eefb Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 22:34:07 +0200 Subject: [PATCH 3/9] Store Chronicle read models in the collection Arc reads them from --- Source/Chronicle/ChronicleOptions.ts | 11 +++++++- Source/Chronicle/ChronicleRuntime.ts | 13 +++++++--- .../given/a_read_model_class.ts | 5 ++++ ...ies_a_client_and_a_collection_name_rule.ts | 21 +++++++++++++++ ...e_supplies_a_client_and_a_naming_policy.ts | 22 ++++++++++++++++ .../with_a_collection_name_rule.ts | 26 +++++++++++++++++++ .../with_an_explicit_naming_policy.ts | 23 ++++++++++++++++ .../without_a_collection_name_rule.ts | 22 ++++++++++++++++ .../given/a_chronicle_builder.ts | 16 ++++++++---- Source/Chronicle/withChronicle.ts | 19 ++++++++++---- 10 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 Source/Chronicle/for_withChronicle/given/a_read_model_class.ts create mode 100644 Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_collection_name_rule.ts create mode 100644 Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_naming_policy.ts create mode 100644 Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_a_collection_name_rule.ts create mode 100644 Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_an_explicit_naming_policy.ts create mode 100644 Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/without_a_collection_name_rule.ts diff --git a/Source/Chronicle/ChronicleOptions.ts b/Source/Chronicle/ChronicleOptions.ts index b3b1f659..ce612219 100644 --- a/Source/Chronicle/ChronicleOptions.ts +++ b/Source/Chronicle/ChronicleOptions.ts @@ -1,6 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import type { IChronicleClient } from '@cratis/chronicle'; +import type { IChronicleClient, ReadModelNamingPolicy } from '@cratis/chronicle'; /** Choose either a caller-owned SDK client or an Arc-owned connection. */ export type ChronicleRegistration = { @@ -9,6 +9,8 @@ export type ChronicleRegistration = { readonly completionTimeoutMs?: number; readonly client: IChronicleClient; readonly connectionString?: never; + /** Not accepted with a caller-owned client: set `readModelNamingPolicy` in the `ChronicleOptions` you create it with. */ + readonly readModelNamingPolicy?: never; /** * Preview: construct reactors and reducers in an Arc service scope per delivery. The client must have been created with * `artifactActivator: chronicleArtifactActivator(...)` for this event store; registration verifies only that. Passing @@ -25,6 +27,13 @@ export type ChronicleRegistration = { * and correlation. */ readonly activateArtifactsInScopes?: boolean; + /** + * Names the container each read model is stored in, given its identifier and, when known, its class. By default, + * when the application uses `withMongoDB`, a read model class is stored in the collection Arc's MongoDB integration + * reads it from, and one known only by identifier keeps that identifier as its name. Without `withMongoDB` + * the SDK default applies: the identifier. Setting this replaces that default. + */ + readonly readModelNamingPolicy?: ReadModelNamingPolicy; readonly connectionString: string; readonly client?: never; }; diff --git a/Source/Chronicle/ChronicleRuntime.ts b/Source/Chronicle/ChronicleRuntime.ts index 44819f57..9ce6c092 100644 --- a/Source/Chronicle/ChronicleRuntime.ts +++ b/Source/Chronicle/ChronicleRuntime.ts @@ -1,8 +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 { ChronicleClient, ChronicleOptions, EventStoreNamespaceName } from '@cratis/chronicle'; -import type { IEventStore } from '@cratis/chronicle'; -import type { ArcServer, ExecutionContext } from '@cratis/arc.core'; +import type { IEventStore, ReadModelNamingPolicy } from '@cratis/chronicle'; +import type { ArcServer, ExecutionContext, ReadModelCollectionName } from '@cratis/arc.core'; import type { ClientArtifactsActivator } from '@cratis/chronicle/artifacts'; import { reactorCommandResultHandler } from './reactorCommands.js'; import type { ChronicleRegistration } from './ChronicleOptions.js'; @@ -14,12 +14,17 @@ export class ChronicleRuntime { readonly #owned; #disposed = false; constructor(readonly options: ChronicleRegistration, readonly artifacts: ChronicleArtifacts, server: () => ArcServer, - artifactActivator?: ClientArtifactsActivator) { + artifactActivator?: ClientArtifactsActivator, collectionName?: ReadModelCollectionName) { if (!options.eventStore) throw new Error('A Chronicle event store is required'); + // An explicit policy wins. Otherwise store a read model class where the application's storage integration reads + // it, and keep the identifier when the client knows no class, as the SDK does without a policy. + const readModelNamingPolicy: ReadModelNamingPolicy | undefined = options.readModelNamingPolicy ?? + (collectionName ? (identifier, readModelType) => readModelType ? collectionName(readModelType) : identifier : undefined); this.#owned = !options.client; this.#client = options.client ?? new ChronicleClient(ChronicleOptions.fromConnectionString(options.connectionString!, { clientArtifactsProvider: artifacts, discoveryPatterns: [], reactorResultHandler: reactorCommandResultHandler(server, options.eventStore), - ...artifactActivator ? { artifactActivator } : {} + ...artifactActivator ? { artifactActivator } : {}, + ...readModelNamingPolicy ? { readModelNamingPolicy } : {} })); } /** Resolves the selected event store in the namespace authorized by Arc. */ diff --git a/Source/Chronicle/for_withChronicle/given/a_read_model_class.ts b/Source/Chronicle/for_withChronicle/given/a_read_model_class.ts new file mode 100644 index 00000000..29164fdc --- /dev/null +++ b/Source/Chronicle/for_withChronicle/given/a_read_model_class.ts @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** A read model class the naming specs hand to a policy. */ +export class Author {} diff --git a/Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_collection_name_rule.ts b/Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_collection_name_rule.ts new file mode 100644 index 00000000..c2e65a9c --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_collection_name_rule.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. +import { ChronicleOptions } from '@cratis/chronicle'; +import sinon from 'sinon'; +import { given } from '../given.js'; +import { ChronicleRuntime } from '../ChronicleRuntime.js'; +import { a_chronicle_builder } from './when_registering_artifact_fallbacks/given/a_chronicle_builder.js'; + +describe('when code supplies a Chronicle client and a collection name rule is registered', given(a_chronicle_builder, context => { + let fromConnectionString: sinon.SinonSpy; + beforeEach(async () => { + fromConnectionString = sinon.spy(ChronicleOptions, 'fromConnectionString'); + context.start(); + context.withChronicle(); + context.withCollectionNameRule(type => type.name); + await context.build(); + await context.inScope(scope => scope.resolve(ChronicleRuntime)); + }); + afterEach(async () => { fromConnectionString.restore(); await context.dispose(); }); + it('should not create or configure a client', () => { fromConnectionString.called.should.equal(false); }); +})); diff --git a/Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_naming_policy.ts b/Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_naming_policy.ts new file mode 100644 index 00000000..50fba5e3 --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_code_supplies_a_client_and_a_naming_policy.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { should } from 'vitest'; +import { ArcApplicationBuilder } from '@cratis/arc.core'; +import type { ChronicleRegistration } from '../ChronicleOptions.js'; +import { withChronicle } from '../withChronicle.js'; + +should(); +describe('when code supplies a Chronicle client and a read model naming policy', () => { + let failure: Error | undefined; + beforeEach(() => { + try { + withChronicle(new ArcApplicationBuilder(), { + client: {} as ChronicleRegistration['client'], eventStore: 'Tasks', + readModelNamingPolicy: (identifier: string) => identifier + } as unknown as Partial); + } catch (error) { failure = error as Error; } + }); + it('should reject it because Arc does not change a caller-owned client', () => { + failure!.message.should.contain('applies only to an Arc-owned client'); + }); +}); diff --git a/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_a_collection_name_rule.ts b/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_a_collection_name_rule.ts new file mode 100644 index 00000000..a8a78007 --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_a_collection_name_rule.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ChronicleOptions } from '@cratis/chronicle'; +import type { ReadModelNamingPolicy } from '@cratis/chronicle'; +import sinon from 'sinon'; +import { given } from '../../given.js'; +import { ChronicleRuntime } from '../../ChronicleRuntime.js'; +import { a_chronicle_builder } from '../when_registering_artifact_fallbacks/given/a_chronicle_builder.js'; +import { Author } from '../given/a_read_model_class.js'; + +describe('when creating the Arc-owned client with a collection name rule registered after Chronicle', given(a_chronicle_builder, context => { + let fromConnectionString: sinon.SinonSpy; + let policy: ReadModelNamingPolicy; + beforeEach(async () => { + fromConnectionString = sinon.spy(ChronicleOptions, 'fromConnectionString'); + context.start(); + context.withArcOwnedConnection(false); + context.withCollectionNameRule(type => `${type.name}s`); + await context.build(); + await context.inScope(scope => scope.resolve(ChronicleRuntime)); + policy = fromConnectionString.lastCall.args[1].readModelNamingPolicy; + }); + afterEach(async () => { fromConnectionString.restore(); await context.dispose(); }); + it('should name a read model class after the rule', () => { policy('Author', Author).should.equal('Authors'); }); + it('should keep the identifier when the client knows no class', () => { policy('custom-container').should.equal('custom-container'); }); +})); diff --git a/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_an_explicit_naming_policy.ts b/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_an_explicit_naming_policy.ts new file mode 100644 index 00000000..a0b376dd --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/with_an_explicit_naming_policy.ts @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ChronicleOptions } from '@cratis/chronicle'; +import type { ReadModelNamingPolicy } from '@cratis/chronicle'; +import sinon from 'sinon'; +import { given } from '../../given.js'; +import { ChronicleRuntime } from '../../ChronicleRuntime.js'; +import { a_chronicle_builder } from '../when_registering_artifact_fallbacks/given/a_chronicle_builder.js'; + +describe('when creating the Arc-owned client with an explicit naming policy and a collection name rule', given(a_chronicle_builder, context => { + let fromConnectionString: sinon.SinonSpy; + const explicit: ReadModelNamingPolicy = identifier => `explicit-${identifier}`; + beforeEach(async () => { + fromConnectionString = sinon.spy(ChronicleOptions, 'fromConnectionString'); + context.start(); + context.withArcOwnedConnection(false, explicit); + context.withCollectionNameRule(type => type.name); + await context.build(); + await context.inScope(scope => scope.resolve(ChronicleRuntime)); + }); + afterEach(async () => { fromConnectionString.restore(); await context.dispose(); }); + it('should pass the explicit policy to the client', () => { fromConnectionString.lastCall.args[1].readModelNamingPolicy.should.equal(explicit); }); +})); diff --git a/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/without_a_collection_name_rule.ts b/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/without_a_collection_name_rule.ts new file mode 100644 index 00000000..e705a8ba --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_creating_the_arc_owned_client/without_a_collection_name_rule.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { ChronicleOptions } from '@cratis/chronicle'; +import sinon from 'sinon'; +import { given } from '../../given.js'; +import { ChronicleRuntime } from '../../ChronicleRuntime.js'; +import { a_chronicle_builder } from '../when_registering_artifact_fallbacks/given/a_chronicle_builder.js'; + +describe('when creating the Arc-owned client without a collection name rule or naming policy', given(a_chronicle_builder, context => { + let fromConnectionString: sinon.SinonSpy; + beforeEach(async () => { + fromConnectionString = sinon.spy(ChronicleOptions, 'fromConnectionString'); + context.start(); + context.withArcOwnedConnection(false); + await context.build(); + await context.inScope(scope => scope.resolve(ChronicleRuntime)); + }); + afterEach(async () => { fromConnectionString.restore(); await context.dispose(); }); + it('should not pass a naming policy so the SDK names containers by identifier', () => { + ('readModelNamingPolicy' in fromConnectionString.lastCall.args[1]).should.equal(false); + }); +})); diff --git a/Source/Chronicle/for_withChronicle/when_registering_artifact_fallbacks/given/a_chronicle_builder.ts b/Source/Chronicle/for_withChronicle/when_registering_artifact_fallbacks/given/a_chronicle_builder.ts index 9fe571bc..b04ddcdd 100644 --- a/Source/Chronicle/for_withChronicle/when_registering_artifact_fallbacks/given/a_chronicle_builder.ts +++ b/Source/Chronicle/for_withChronicle/when_registering_artifact_fallbacks/given/a_chronicle_builder.ts @@ -1,8 +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 { ArcApplicationBuilder, Severity } from '@cratis/arc.core'; -import type { ArcOptions, ServiceIdentifier, ServiceRegistration, ServiceScope } from '@cratis/arc.core'; -import type { IChronicleClient } from '@cratis/chronicle'; +import { ArcApplicationBuilder, readModelCollectionNameResolver, Severity } from '@cratis/arc.core'; +import type { ArcOptions, ReadModelCollectionName, ServiceIdentifier, ServiceRegistration, ServiceScope } from '@cratis/arc.core'; +import type { IChronicleClient, ReadModelNamingPolicy } from '@cratis/chronicle'; import { withChronicle } from '../../../withChronicle.js'; type BuiltApplication = Awaited>; @@ -19,8 +19,14 @@ export class a_chronicle_builder { /** Opt in to scoped activation over an Arc-owned connection; building never connects. */ withScopedActivation(): this { return this.withArcOwnedConnection(true); } /** Use an Arc-owned connection; building never connects. */ - withArcOwnedConnection(activateArtifactsInScopes: boolean): this { - withChronicle(this.builder, { connectionString: 'chronicle://localhost:35000', eventStore: 'Fallbacks', activateArtifactsInScopes }); + withArcOwnedConnection(activateArtifactsInScopes: boolean, readModelNamingPolicy?: ReadModelNamingPolicy): this { + withChronicle(this.builder, { connectionString: 'chronicle://localhost:35000', eventStore: 'Fallbacks', activateArtifactsInScopes, + ...readModelNamingPolicy ? { readModelNamingPolicy } : {} }); + return this; + } + /** Register the collection rule a storage integration such as withMongoDB provides. */ + withCollectionNameRule(rule: ReadModelCollectionName): this { + this.builder.services.addSingleton(readModelCollectionNameResolver, () => rule); return this; } async build(): Promise { this.application = await this.builder.build(); return this.application; } diff --git a/Source/Chronicle/withChronicle.ts b/Source/Chronicle/withChronicle.ts index 4cb6a905..85af630c 100644 --- a/Source/Chronicle/withChronicle.ts +++ b/Source/Chronicle/withChronicle.ts @@ -1,6 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { ArcApplicationBuilder, serviceToken } from '@cratis/arc.core'; +import { ArcApplicationBuilder, readModelCollectionNameResolver, serviceToken } from '@cratis/arc.core'; import type { ArcServer, ReadModelInterceptor } from '@cratis/arc.core'; import { ArcApplicationBuilder as FetchArcApplicationBuilder } from '@cratis/arc.core/fetch'; import type { Constructor } from '@cratis/fundamentals'; @@ -25,6 +25,9 @@ export function withChronicle(builder: ArcApplicationBuilder, options: Partial { server = built; }); @@ -63,10 +66,16 @@ export function withChronicle(builder: ArcApplicationBuilder, options: Partial new ChronicleRuntime(registration as ChronicleRegistration, artifacts, () => { - if (!server) throw new Error('Arc must be built before Chronicle reactor commands can run'); - return server; - }, registration.client ? undefined : activator)); + builder.services.addSingleton(ChronicleRuntime, async scope => { + // withMongoDB registers the collection rule after or before this call; resolving here, once Arc is built, sees both. + const collectionName = registration.client || registration.readModelNamingPolicy || + !builder.services.registrations.some(registered => registered.token === readModelCollectionNameResolver) ? undefined : + await scope.resolve(readModelCollectionNameResolver); + return new ChronicleRuntime(registration as ChronicleRegistration, artifacts, () => { + if (!server) throw new Error('Arc must be built before Chronicle reactor commands can run'); + return server; + }, registration.client ? undefined : activator, collectionName); + }); builder.services.addScoped(ChronicleScopedStore, async scope => new ChronicleScopedStore(await scope.resolve(ChronicleRuntime), scope)); builder.services.addScoped(ChronicleReadModels, async scope => new ChronicleReadModels(await scope.resolve(ChronicleScopedStore), scope.identity!)); From a805a39b4166d8f0c1a614ace838fe3595c52bf7 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 22:34:38 +0200 Subject: [PATCH 4/9] Require @cratis/chronicle 6.29.0 or later for the read model naming policy --- Documentation/chronicle/add-event-sourcing.md | 2 +- .../chronicle/reactors/scoped-activation.md | 2 +- Documentation/reference/capabilities.md | 4 ++-- Documentation/reference/packages.md | 2 +- Documentation/testing/chronicle.md | 2 +- Source/Chronicle/package.json | 2 +- Source/Cratis/package.json | 2 +- scripts/chronicle-peer-floor-exports.json | 13 +++++++++++-- yarn.lock | 4 ++-- 9 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Documentation/chronicle/add-event-sourcing.md b/Documentation/chronicle/add-event-sourcing.md index 1f4f5899..197497ff 100644 --- a/Documentation/chronicle/add-event-sourcing.md +++ b/Documentation/chronicle/add-event-sourcing.md @@ -57,7 +57,7 @@ cd ../my-arc-app Install it together with the Chronicle SDK and RxJS: ```bash -npm install ../arc-packages/arc.chronicle.tgz @cratis/chronicle@~6.19.0 rxjs@^7.8.2 +npm install ../arc-packages/arc.chronicle.tgz @cratis/chronicle@~6.29.0 rxjs@^7.8.2 ``` | Package | What it gives you | diff --git a/Documentation/chronicle/reactors/scoped-activation.md b/Documentation/chronicle/reactors/scoped-activation.md index 2e1bccfe..b8c06ee6 100644 --- a/Documentation/chronicle/reactors/scoped-activation.md +++ b/Documentation/chronicle/reactors/scoped-activation.md @@ -21,7 +21,7 @@ builder.withChronicle({ }); ``` -Scoped activation requires `@cratis/chronicle` 6.17.0 or later, the lowest version `@cratis/arc.chronicle` accepts as a peer. To use a Chronicle client you create yourself, see [Use a caller-owned client](#use-a-caller-owned-client). +Scoped activation requires `@cratis/chronicle` 6.29.0 or later, the lowest version `@cratis/arc.chronicle` accepts as a peer. To use a Chronicle client you create yourself, see [Use a caller-owned client](#use-a-caller-owned-client). With the option set, Chronicle-only artifacts that `discover(...)` found before `withChronicle` was called are registered with Chronicle too. Without it, registration is unchanged. diff --git a/Documentation/reference/capabilities.md b/Documentation/reference/capabilities.md index b4907a3c..53edfab7 100644 --- a/Documentation/reference/capabilities.md +++ b/Documentation/reference/capabilities.md @@ -126,7 +126,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_