diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index 32e71887..6755d51f 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.49.0", + "version": "0.50.0", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/chronicle/reactors/command-side-effects.md b/Documentation/chronicle/reactors/command-side-effects.md index 65251500..6849fc76 100644 --- a/Documentation/chronicle/reactors/command-side-effects.md +++ b/Documentation/chronicle/reactors/command-side-effects.md @@ -5,7 +5,7 @@ description: Return Arc commands from a Chronicle reactor so they run through va When a book is added to the catalog, the search index should follow. The indexing command already exists, with its validation and its role check. Instead of calling a service from the reactor and repeating those checks, return the command, and Arc runs it through the same pipeline an HTTP caller would use. -This relies on the SDK's reactor result hook, available in the Chronicle SDK 6.7.0 and later. +This relies on the SDK's reactor result hook. ## Return a command @@ -83,9 +83,9 @@ Do not mix commands with events or other values in one array. Arc rejects the mi A returned command that fails, whether rejected by authorization or validation or by throwing, fails the handler with a message naming the command, the event store, and the namespace. Chronicle marks the observer partition as failed rather than acknowledging a partial side effect. When Chronicle delivers the event again, the handler returns the commands again, including any that succeeded the first time. -On SDK 6.9.0 and later, reactors accept explicit and kernel-initiated replays by default. Mark the class with `@onceOnly()` when all its handlers cause non-replayable effects, or mark individual handlers if only some do. Chronicle skips them during replay; `@replay()` can supply an alternate replay handler. See [Chronicle once-only reactors](/chronicle/reactors/once-only/). Marking a reactor once-only does **not** prevent re-delivery when a failed partition is recovered. +Reactors accept explicit and kernel-initiated replays by default. Mark the class with `@onceOnly()` when all its handlers cause non-replayable effects: Chronicle then never replays the reactor at all, neither a full replay nor a partition replay, so a `@replay()` handler on that class never runs. If only some handlers cause such effects, mark those methods with `@onceOnly()` instead; Chronicle skips only them during a replay, and `@replay()` can supply an alternate replay handler. See [Chronicle once-only reactors](/chronicle/reactors/once-only/). Marking a reactor once-only does **not** prevent re-delivery when a failed partition is recovered. -Make the commands safe to repeat even with `@onceOnly()`. Key them by the triggering event source, check current state in [`provide()` or a read model](../read-models/injecting-into-commands.md), or rely on a Chronicle constraint to reject the duplicate. SDK 6.7.x and 6.8.x do not support replay exclusion. +Make the commands safe to repeat even with `@onceOnly()`. Key them by the triggering event source, check current state in [`provide()` or a read model](../read-models/injecting-into-commands.md), or rely on a Chronicle constraint to reject the duplicate. No transaction spans the triggering event and the commands. The triggering event is already committed when the reactor runs. diff --git a/Documentation/chronicle/reactors/index.md b/Documentation/chronicle/reactors/index.md index ded02124..b9613dfa 100644 --- a/Documentation/chronicle/reactors/index.md +++ b/Documentation/chronicle/reactors/index.md @@ -53,7 +53,7 @@ Returning commands and events together in one array fails the handler. Anything A handler that throws, or a returned side effect that fails, marks the observer partition for that event source as failed, with the error message. Chronicle's failed-partition handling decides when that event is delivered again. Nothing that already happened is undone, so write handlers that are safe to run twice for the same event. -Since SDK 6.9.0, reactors run on replay by default. For effects such as returned commands, use `@onceOnly()` on the class to skip all handlers during replay, or on individual methods to skip only those handlers. `@replay()` selects a separate handler for a replayed event. See [Chronicle once-only reactors](/chronicle/reactors/once-only/). These markers do not prevent ordinary re-delivery after a failed partition recovers. Keep the effects safe to repeat; SDK 6.7.x and 6.8.x cannot exclude replay. +Reactors run on replay by default. For effects such as returned commands, mark the class with `@onceOnly()` so Chronicle never replays the reactor at all, neither a full replay nor a partition replay; a `@replay()` handler on such a class therefore never runs. Mark individual methods with `@onceOnly()` instead to skip only those handlers during a replay, and use `@replay()` to select a separate handler for a replayed event. See [Chronicle once-only reactors](/chronicle/reactors/once-only/). These markers do not prevent ordinary re-delivery after a failed partition recovers, so keep the effects safe to repeat. ## Topics diff --git a/Documentation/chronicle/reactors/scoped-activation.md b/Documentation/chronicle/reactors/scoped-activation.md index bb00906d..2e1bccfe 100644 --- a/Documentation/chronicle/reactors/scoped-activation.md +++ b/Documentation/chronicle/reactors/scoped-activation.md @@ -21,9 +21,7 @@ builder.withChronicle({ }); ``` -Scoped activation requires `@cratis/chronicle` 6.17.0 or later. Older versions the peer range still allows either ignore the activator or cannot report per-event correlation and scope cleanup failures, so with the option set `withChronicle` fails with `activateArtifactsInScopes requires @cratis/chronicle 6.17.0 or later`. With the option off, older versions keep working. - -Registration rejects the option together with `client`, because caller-owned clients are not supported yet. +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). With the option set, Chronicle-only artifacts that `discover(...)` found before `withChronicle` was called are registered with Chronicle too. Without it, registration is unchanged. @@ -57,27 +55,31 @@ A failed delivery is retried. It is not rolled back: appended events, commands a ## Shutdown -When the application shuts down, Arc stops accepting new deliveries, aborts `currentContext().signal` for running handlers, and waits for them to finish and dispose their scopes before it disposes services. Cancellation is cooperative: a handler that ignores the signal delays shutdown until it settles. +When the application shuts down, Arc stops accepting new deliveries, aborts `currentContext().signal` for running handlers, and waits for them to finish and dispose their scopes. Only then does it dispose services, and an Arc-owned Chronicle client is closed last, so the connection stays open while admitted deliveries finish. Cancellation is cooperative: a handler that ignores the signal delays shutdown until it settles. A delivery that arrives after shutdown has started is rejected without touching services. -## Use the activator directly +## Use a caller-owned client -`chronicleArtifactActivator(server, eventStore)` from `@cratis/arc.chronicle` is the activator this option installs. It returns an SDK `ClientArtifactsActivator`, so you can pass it as `artifactActivator` when you create a Chronicle client yourself. This also requires `@cratis/chronicle` 6.17.0 or later. On 6.16 an event delivery fails instead of running with the wrong correlation; SDKs before 6.16 ignore `artifactActivator` and construct artifacts themselves, and Arc cannot detect that for a client you create. +`chronicleArtifactActivator(server, eventStore)` from `@cratis/arc.chronicle` is the activator the option installs on an Arc-owned connection. For a Chronicle client you create yourself, pass it as `artifactActivator`, together with `reactorCommandResultHandler` as `reactorResultHandler`, and set `activateArtifactsInScopes` with the client: ```typescript title="main.ts (excerpt)" import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle'; -import { chronicleArtifactActivator } from '@cratis/arc.chronicle'; +import { chronicleArtifactActivator, reactorCommandResultHandler } from '@cratis/arc.chronicle'; let application: Awaited> | undefined; -const artifactActivator = chronicleArtifactActivator(() => application!.server, 'MyArcApp'); -const client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { artifactActivator })); +const server = () => application!.server; +const client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { + artifactActivator: chronicleArtifactActivator(server, 'MyArcApp'), + reactorResultHandler: reactorCommandResultHandler(server, 'MyArcApp') +})); -application = await builder.withChronicle({ client, eventStore: 'MyArcApp' }).build(); +application = await builder.withChronicle({ client, eventStore: 'MyArcApp', activateArtifactsInScopes: true }).build(); // Start observing with the client only now: the activator needs the built application. ``` +Registration verifies only that the client was created with an activator from `chronicleArtifactActivator` for the same event store; passing `reactorCommandResultHandler(...)` as `reactorResultHandler` is your responsibility, and without it returned commands are not executed through Arc. Arc never changes the client's options and never disposes the client. With the client registered this way, Arc checks the reactors' and reducers' registrations when building, as it does for an Arc-owned connection. + The caller owns the ordering: - Build Arc before the client starts observing. A delivery that arrives earlier finds no application; the delivery fails and Chronicle retries it. -- You register the reactors, reducers and their dependencies yourself, and Arc does not check them when building. -- Returned commands receive the delivery's signal and event store only when you also pass `reactorCommandResultHandler(() => application!.server, 'MyArcApp')` as `reactorResultHandler`. -- The activator joins Arc's shutdown only after its first activation. Before disposing Arc, stop the client, or call `artifactActivator.stop()` and then `await artifactActivator.drain()`, so no delivery is still using services that `application.dispose()` releases. +- Returned commands receive the delivery's signal and event store only when the client also has `reactorCommandResultHandler` as its `reactorResultHandler`. +- Dispose Arc first, then the client: `await application.dispose()` stops the activator and waits for admitted deliveries while the connection is still open, then `client.dispose()` stops observing. Deliveries that arrive in between are rejected. diff --git a/Documentation/chronicle/registration-options.md b/Documentation/chronicle/registration-options.md index 70f8fc4e..9c586458 100644 --- a/Documentation/chronicle/registration-options.md +++ b/Documentation/chronicle/registration-options.md @@ -27,7 +27,7 @@ Each recorded reactor and reducer also gets a scoped service registration in Arc | `connectionString` | `string` | One of `connectionString` and `client` | Arc creates, connects, and disposes the SDK client | | `client` | `IChronicleClient` from `@cratis/chronicle` | One of `connectionString` and `client` | You own the client; see [Choose who owns the client](#choose-who-owns-the-client) | | `completionTimeoutMs` | positive integer, milliseconds | No; no wait by default | After each successful append, wait until Chronicle's observers have processed it before the command answers. See [Choose Chronicle read consistency](../queries/read-consistency.md) | -| `activateArtifactsInScopes` | `boolean` | No; off by default | Preview. Resolve reactors and reducers from Arc's container, one scope per delivery. Arc-owned connections and `@cratis/chronicle` 6.17.0 or later only. See [Scoped activation](reactors/scoped-activation.md) | +| `activateArtifactsInScopes` | `boolean` | No; off by default | Preview. Resolve reactors and reducers from Arc's container, one scope per delivery. With `client`, registration verifies only that the client was created with `chronicleArtifactActivator`; passing `reactorCommandResultHandler` as `reactorResultHandler` is up to you. See [Scoped activation](reactors/scoped-activation.md) | Registration throws `Chronicle requires eventStore and exactly one of connectionString or client` when the event store is missing, or when neither or both of a connection string and a client are set. @@ -67,7 +67,7 @@ Values follow this precedence: | `{ connectionString, eventStore }` | Arc creates the SDK client with an artifact catalog for this application, and closes it when the application is disposed | | `{ client, eventStore }` | You pass a caller-owned `IChronicleClient`. Arc never disposes it; your host calls `client.dispose()`. The client must already have an artifact provider that registers the event types, projections, reducers, and reactors you use | -An Arc-owned client is also wired so that [reactors can return Arc commands](reactors/command-side-effects.md). A caller-owned client needs that handler passed to the SDK before it connects; the reactor page shows how. +An Arc-owned client is also wired so that [reactors can return Arc commands](reactors/command-side-effects.md). A caller-owned client needs that handler passed to the SDK before it connects; the reactor page shows how. To use [scoped activation](reactors/scoped-activation.md#use-a-caller-owned-client) with a caller-owned client, also pass `chronicleArtifactActivator` as its `artifactActivator`. Dispose the Arc application before the client, so deliveries still running finish while the connection is open. ## Related diff --git a/Documentation/index.md b/Documentation/index.md index b9502b9e..0bd84f6a 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source. :::caution[Source preview, no full parity] -No package is published to npm; the manifests are at version 0.49.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. +No package is published to npm; the manifests are at version 0.50.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. ::: ## What it looks like diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md index b3d9ef44..299a94a7 100644 --- a/Documentation/reference/packages.md +++ b/Documentation/reference/packages.md @@ -3,7 +3,7 @@ title: Packages description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client. --- -Every package in this repository is at version 0.49.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: +Every package in this repository is at version 0.50.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: - **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository's Yarn workspace. - **In your own project.** Pack each package you need with `yarn workspace pack --out ` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers. @@ -21,7 +21,7 @@ Every package in this repository is at version 0.49.0, the version of the source | `@cratis/arc.testing` | `Source/Testing` | `CommandScenario`, `QueryScenario`, `ObservableQueryScenario`, `ArcScenario`, `given`, `shouldHaveRuleFailure` | | | `@cratis/arc.mongodb` | `Source/MongoDB` | `withMongoDB`, `mongoCollection`, `MongoCollection`, naming policies, `MongoReadModels` | `@cratis/arc.core`, `@cratis/fundamentals`, `mongodb` `^6.21.0` | | `@cratis/arc.drizzle` | `Source/Drizzle` | `withDrizzle`, `drizzleReadModel`, `drizzleDatabase`, `DrizzleReadModels`, column codecs | `@cratis/arc.core`, `@cratis/fundamentals`, `drizzle-orm` `^0.45.0` | -| `@cratis/arc.chronicle` | `Source/Chronicle` | Experimental: `withChronicle`, `commandAggregate`, `reactorCommandResultHandler`, `executeCommandsAsSystem`, `eventForEventSourceId`, `eventSourceIdResponse`, `eventsWithConcurrencyScopes`, routing decorators, `notAudited`, `ChronicleReadModels`; `@cratis/arc.chronicle/testing` for `ChronicleCommandScenario` and `ChronicleKernelScenario` | `@cratis/arc.core`, `@cratis/arc.testing`, `@cratis/chronicle` `^6.7.0` (tested with 6.10.0), `@cratis/fundamentals`, `zod` | +| `@cratis/arc.chronicle` | `Source/Chronicle` | Experimental: `withChronicle`, `commandAggregate`, `reactorCommandResultHandler`, `executeCommandsAsSystem`, `eventForEventSourceId`, `eventSourceIdResponse`, `eventsWithConcurrencyScopes`, routing decorators, `notAudited`, `ChronicleReadModels`; `@cratis/arc.chronicle/testing` for `ChronicleCommandScenario` and `ChronicleKernelScenario` | `@cratis/arc.core`, `@cratis/arc.testing`, `@cratis/chronicle` `^6.17.0` (tested with 6.19.0), `@cratis/fundamentals`, `zod` | | `@cratis/cratis` | `Source/Cratis` | Experimental composition, the counterpart of the C# `Cratis` package: `CratisApplication.createBuilder`, `builder.addCratis`; re-exports Arc, Chronicle and testing (`./testing`); no implicit authentication handler | Arc core, Arc Chronicle, Arc testing, Chronicle SDK, Fundamentals, `zod` | `@cratis/cratis` is experimental, like the Chronicle integration it composes, and is not published to npm yet. Unlike C# `AddCratis`, the TS composition does not install Microsoft identity automatically: for protected routes, explicitly choose an authentication handler (such as `microsoftIdentityPlatform()`) or your own trusted host principal; public routes need neither. It composes the client, not the event-store engine. See [The Cratis package](../chronicle/cratis-package.md). diff --git a/Documentation/testing/chronicle.md b/Documentation/testing/chronicle.md index c05a4b84..b53b4f80 100644 --- a/Documentation/testing/chronicle.md +++ b/Documentation/testing/chronicle.md @@ -110,7 +110,7 @@ await scenario.dispose(); Here `AccountBalanceReducer` handles `AccountOpened` and produces the `AccountBalance` injected into `CheckAccount`. The SDK's `ReadModelScenario` folds reducer history (since 6.14.0) and, starting in 6.19.0, evaluates supported flat projections from seeded events on demand for each source. -The main `@cratis/arc.chronicle` entry supports `@cratis/chronicle` 6.7.0 and later. Seeding reducer history through `given.forEventSource(...).events` requires SDK 6.14.0 or later; the scenario loads its testing subpath only when it needs to fold seeded history. A different source has no balance; +The main `@cratis/arc.chronicle` entry supports `@cratis/chronicle` 6.17.0 and later. The scenario loads the SDK's testing subpath only when it needs to fold seeded history. A different source has no balance; required `commandReadModel(AccountBalance)` rejects it and an optional read model receives `null`. Seeding does not appear in `result.appendedEvents` or `scenario.appendedEvents`. Later command appends are **not** folded into this scenario's read models, matching the .NET command scenario's seeded-history lookup. Use a kernel scenario to test diff --git a/README.md b/README.md index cb495c27..c82532c7 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ export class TaskItem { | `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.19.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. | | `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. | -Every package manifest is at version 0.49.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. +Every package manifest is at version 0.50.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. ## Try it diff --git a/Source/Chronicle/ChronicleOptions.ts b/Source/Chronicle/ChronicleOptions.ts index e0caaed5..b3b1f659 100644 --- a/Source/Chronicle/ChronicleOptions.ts +++ b/Source/Chronicle/ChronicleOptions.ts @@ -9,16 +9,20 @@ export type ChronicleRegistration = { readonly completionTimeoutMs?: number; readonly client: IChronicleClient; readonly connectionString?: never; - /** Not supported with a caller-owned client yet; pass `chronicleArtifactActivator` to the client instead. */ - readonly activateArtifactsInScopes?: 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 + * `reactorResultHandler: reactorCommandResultHandler(...)` is the caller's responsibility; without it, returned commands + * are not executed through Arc. Arc never changes or disposes the client. + */ + readonly activateArtifactsInScopes?: boolean; } | { readonly eventStore: string; /** Opt in to waiting for kernel observer completion after each committed command (milliseconds). */ readonly completionTimeoutMs?: number; /** * Preview: construct reactors and reducers in an Arc service scope per delivery, with the observation's tenant - * and correlation. Requires an Arc-owned connection and @cratis/chronicle 6.17.0 or later; registration fails on - * older SDKs. + * and correlation. */ readonly activateArtifactsInScopes?: boolean; readonly connectionString: string; diff --git a/Source/Chronicle/ChronicleRuntime.ts b/Source/Chronicle/ChronicleRuntime.ts index 6691d399..44819f57 100644 --- a/Source/Chronicle/ChronicleRuntime.ts +++ b/Source/Chronicle/ChronicleRuntime.ts @@ -12,6 +12,7 @@ import type { ChronicleArtifacts } from './ChronicleArtifacts.js'; export class ChronicleRuntime { readonly #client; readonly #owned; + #disposed = false; constructor(readonly options: ChronicleRegistration, readonly artifacts: ChronicleArtifacts, server: () => ArcServer, artifactActivator?: ClientArtifactsActivator) { if (!options.eventStore) throw new Error('A Chronicle event store is required'); @@ -25,6 +26,13 @@ export class ChronicleRuntime { getStore(context: ExecutionContext): Promise { return this.#client.getEventStore(this.options.eventStore, context.tenantId ?? EventStoreNamespaceName.default.value); } - /** Close only an integration-owned client. */ - [Symbol.dispose](): void { if (this.#owned) this.#client.dispose(); } + /** + * Close only an integration-owned client, once. Arc disposes singletons after shutdown participants, including the + * artifact activator, have stopped and drained, so the connection outlives every admitted delivery. + */ + [Symbol.dispose](): void { + if (!this.#owned || this.#disposed) return; + this.#disposed = true; + this.#client.dispose(); + } } diff --git a/Source/Chronicle/Integration/ScopedLiveArtifacts.ts b/Source/Chronicle/Integration/ScopedLiveArtifacts.ts new file mode 100644 index 00000000..e75459a7 --- /dev/null +++ b/Source/Chronicle/Integration/ScopedLiveArtifacts.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 { field } from '@cratis/fundamentals'; +import { command, key } from '@cratis/arc.core'; +import { eventType } from '@cratis/chronicle/events'; +import type { EventContext } from '@cratis/chronicle/events'; +import { reactor } from '@cratis/chronicle/reactors'; + +@eventType('ArcTypeScriptScopedLiveCreated') +export class ScopedLiveCreated { @field(String) name: string; constructor(name: string) { this.name = name; } } + +@eventType('ArcTypeScriptScopedLiveFollowedUp') +export class ScopedLiveFollowedUp { @field(String) name: string; constructor(name: string) { this.name = name; } } + +/** A scoped service the reactor receives through its constructor. */ +export class ScopedLiveGreeting { readonly prefix = 'greeted'; } + +@command() +export class CreateScopedLive { + @field(String) @key() id = ''; + @field(String) name = ''; + constructor(id = '', name = '') { this.id = id; this.name = name; } + handle(): ScopedLiveCreated { return new ScopedLiveCreated(this.name); } +} + +@command() +export class FollowUpScopedLive { + @field(String) @key() id = ''; + @field(String) name = ''; + constructor(id = '', name = '') { this.id = id; this.name = name; } + handle(): ScopedLiveFollowedUp { return new ScopedLiveFollowedUp(this.name); } +} + +/** Constructed by Arc in a scope per delivery; its returned command appends through the delivered store. */ +@reactor('ArcTypeScriptScopedLiveReactor') +export class ScopedLiveReactor { + static readonly inject = [ScopedLiveGreeting]; + constructor(readonly greeting: ScopedLiveGreeting) {} + scopedLiveCreated(event: ScopedLiveCreated, context: EventContext): FollowUpScopedLive { + return new FollowUpScopedLive(context.eventSourceId, `${this.greeting.prefix}-${event.name}`); + } +} diff --git a/Source/Chronicle/Integration/live.test.mjs b/Source/Chronicle/Integration/live.test.mjs index 3cf2627d..e8543f70 100644 --- a/Source/Chronicle/Integration/live.test.mjs +++ b/Source/Chronicle/Integration/live.test.mjs @@ -15,7 +15,7 @@ import { serve } from '@hono/node-server'; import { cratisArc as expressArc } from '@cratis/arc.express'; import { cratisArc as fastifyArc } from '@cratis/arc.fastify'; import { cratisArc as honoArc } from '@cratis/arc.hono'; -import { ArcApplication, defineQuery, serviceToken } from '@cratis/arc.core'; +import { ArcApplication, defineQuery, serviceToken, Severity } from '@cratis/arc.core'; import { MongoCollection, MongoReadModels } from '@cratis/arc.mongodb'; import { z } from 'zod'; import { context, trace } from '@opentelemetry/api'; @@ -33,6 +33,7 @@ import { CreatePrivateLive } from '../dist/Integration/CreatePrivateLive.js'; import { PrivateLiveCreated } from '../dist/Integration/PrivateLiveCreated.js'; import { PrivateLiveView } from '../dist/Integration/PrivateLiveView.js'; import { ReadPrivateLiveInCommand } from '../dist/Integration/ReadPrivateLiveInCommand.js'; +import * as scoped from '../dist/Integration/ScopedLiveArtifacts.js'; const { CreateLive, CreateLiveExactlyOnce, CreateLiveBatch, CreateLiveWithOperation, AdvanceLive, ReadLiveInCommand, AdvanceLiveWithConcurrentAppend, LiveCreated, LiveFollowedUp, FollowUpLive, LiveCommandReactor, LiveView } = live; @@ -252,6 +253,32 @@ try { } finally { await listener.close(); } })); } + checks.push(test('Chronicle reactor activated in an Arc scope', async () => { + const scopedStoreName = `ArcTsScoped${randomUUID().replaceAll('-', '')}`; + const scopedBuilder = ArcApplication.createBuilder({ development: true }); + scopedBuilder.withChronicle({ connectionString, eventStore: scopedStoreName, activateArtifactsInScopes: true }); + scopedBuilder.services.addScoped(scoped.ScopedLiveGreeting); + scopedBuilder.add(scoped.CreateScopedLive, scoped.FollowUpScopedLive, scoped.ScopedLiveCreated, + scoped.ScopedLiveFollowedUp, scoped.ScopedLiveReactor); + const scopedApplication = await scopedBuilder.build(); + try { + const id = randomUUID(); + const tenant = 'TenantScoped'; + const created = await scopedApplication.server.execute(new scoped.CreateScopedLive(id, 'scoped'), { + tenantId: tenant, correlationId: randomUUID(), principal: undefined, + signal: new globalThis.AbortController().signal, allowedSeverity: Severity.Warning + }); + assert.equal(created.isSuccess, true, JSON.stringify(created)); + const store = await client.getEventStore(scopedStoreName, tenant); + let followups = []; + for (let attempt = 0; attempt < 40 && followups.length === 0; attempt++) { + followups = await store.eventLog.getForEventSourceIdAndEventTypes(id, [scoped.ScopedLiveFollowedUp]); + if (!followups.length) await delay(250); + } + assert.equal(followups.length, 1, 'the scoped reactor returned a command that appended in the delivered tenant'); + assert.equal(followups[0].content.name, 'greeted-scoped', 'the reactor received its constructor dependency'); + } finally { await scopedApplication.dispose(); } + })); await Promise.all(checks); } finally { await application.dispose(); diff --git a/Source/Chronicle/chronicleArtifactActivator.ts b/Source/Chronicle/chronicleArtifactActivator.ts index bf82676c..4a35a083 100644 --- a/Source/Chronicle/chronicleArtifactActivator.ts +++ b/Source/Chronicle/chronicleArtifactActivator.ts @@ -2,16 +2,21 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { normalizeCorrelationId, Severity } from '@cratis/arc.core'; import type { ArcServer, ExecutionContext, ServiceRegistry, ShutdownParticipant } from '@cratis/arc.core'; -// Type-only: ArtifactDelivery is a runtime export only from @cratis/chronicle 6.16, above the peer floor. Importing it as a -// value would break loading @cratis/arc.chronicle on older SDKs even when scoped activation is off. -import type { ArtifactDelivery, ActivatedArtifact, ArtifactActivationContext, ArtifactInvocationContext, ClientArtifactsActivator } from '@cratis/chronicle/artifacts'; +import { ArtifactDelivery } from '@cratis/chronicle/artifacts'; +import type { ActivatedArtifact, ArtifactActivationContext, ArtifactInvocationContext, ClientArtifactsActivator } from '@cratis/chronicle/artifacts'; import type { Constructor } from '@cratis/fundamentals'; import { bindDeliveryStore } from './ChronicleStores.js'; /** A Chronicle artifact activator that also takes part in Arc's coordinated shutdown. */ export type ChronicleArtifactActivator = ClientArtifactsActivator & ShutdownParticipant; -const eventsDelivery: `${ArtifactDelivery.Events}` = 'events'; +const eventsDelivery = ArtifactDelivery.Events; +const expectedEventStores = new WeakMap(); + +/** @internal The event store an activator created by {@link chronicleArtifactActivator} accepts, if the value is one. */ +export function chronicleArtifactActivatorEventStore(value: unknown): string | undefined { + return typeof value === 'function' ? expectedEventStores.get(value) : undefined; +} /** * Activate Chronicle reactors and reducers in an Arc service scope, one scope per delivery. @@ -100,5 +105,6 @@ export function chronicleArtifactActivator(server: () => ArcServer, expectedEven while (active.size) await Promise.allSettled([...active]); } }); + expectedEventStores.set(activator, expectedEventStore); return activator; } diff --git a/Source/Chronicle/for_ChronicleRuntime/when_disposing_an_owned_client_twice.ts b/Source/Chronicle/for_ChronicleRuntime/when_disposing_an_owned_client_twice.ts new file mode 100644 index 00000000..3d4d7042 --- /dev/null +++ b/Source/Chronicle/for_ChronicleRuntime/when_disposing_an_owned_client_twice.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import type { ArcServer } from '@cratis/arc.core'; +import { ChronicleClient } from '@cratis/chronicle'; +import sinon from 'sinon'; +import { ChronicleArtifacts } from '../ChronicleArtifacts.js'; +import { ChronicleRuntime } from '../ChronicleRuntime.js'; + +describe('when disposing an Arc-owned Chronicle client twice', () => { + let dispose: sinon.SinonStub; + beforeEach(() => { + dispose = sinon.stub(ChronicleClient.prototype, 'dispose'); + const runtime = new ChronicleRuntime({ connectionString: 'chronicle://localhost:35000', eventStore: 'Orders' }, + new ChronicleArtifacts(), () => ({}) as ArcServer); + runtime[Symbol.dispose](); + runtime[Symbol.dispose](); + }); + afterEach(() => dispose.restore()); + it('should close the client once', () => { dispose.callCount.should.equal(1); }); +}); diff --git a/Source/Chronicle/for_requireScopedActivationSupport/given/an_sdk.ts b/Source/Chronicle/for_requireScopedActivationSupport/given/an_sdk.ts deleted file mode 100644 index 2f1d0c59..00000000 --- a/Source/Chronicle/for_requireScopedActivationSupport/given/an_sdk.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -import type { ChronicleOptions } from '@cratis/chronicle'; -import type { ClientArtifactsActivator } from '@cratis/chronicle/artifacts'; -import { requireScopedActivationSupport } from '../../requireScopedActivationSupport.js'; -import type { ScopedActivationSdk } from '../../requireScopedActivationSupport.js'; - -/** Simulates the SDK shapes the peer range allows, without installing them. */ -export class an_sdk { - readonly activator = (() => undefined) as unknown as ClientArtifactsActivator; - failure: Error | undefined; - - /** keepsActivator: 6.16 added the option; hasCompletion: 6.17 added ArtifactCompletionFailed. */ - sdk(keepsActivator: boolean, hasCompletion: boolean): ScopedActivationSdk { - return { - options: { - fromConnectionString: (_: unknown, options?: { artifactActivator?: ClientArtifactsActivator }) => - (keepsActivator ? { artifactActivator: options?.artifactActivator } : {}) as unknown as ChronicleOptions - } as ScopedActivationSdk['options'], - artifacts: hasCompletion ? { ArtifactCompletionFailed: class {} } : { DefaultClientArtifactsProvider: class {} } - }; - } - - check(sdk: ScopedActivationSdk): void { - this.failure = undefined; - try { requireScopedActivationSupport('chronicle://localhost:35000', this.activator, sdk); } - catch (error) { this.failure = error as Error; } - } -} diff --git a/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_cannot_complete_leases.ts b/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_cannot_complete_leases.ts deleted file mode 100644 index 33af6a52..00000000 --- a/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_cannot_complete_leases.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { given } from '../given.js'; -import { an_sdk } from './given/an_sdk.js'; - -describe('when the SDK accepts the activator but cannot complete leases', given(an_sdk, context => { - beforeEach(() => context.check(context.sdk(true, false))); - it('should require Chronicle 6.17.0', () => { - context.failure!.message.should.equal('activateArtifactsInScopes requires @cratis/chronicle 6.17.0 or later'); - }); -})); diff --git a/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_ignores_the_activator.ts b/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_ignores_the_activator.ts deleted file mode 100644 index 2b660536..00000000 --- a/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_ignores_the_activator.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { given } from '../given.js'; -import { an_sdk } from './given/an_sdk.js'; - -describe('when the SDK ignores the activator', given(an_sdk, context => { - beforeEach(() => context.check(context.sdk(false, false))); - it('should require Chronicle 6.17.0', () => { - context.failure!.message.should.equal('activateArtifactsInScopes requires @cratis/chronicle 6.17.0 or later'); - }); -})); diff --git a/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_supports_scoped_activation.ts b/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_supports_scoped_activation.ts deleted file mode 100644 index ee841441..00000000 --- a/Source/Chronicle/for_requireScopedActivationSupport/when_the_sdk_supports_scoped_activation.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { given } from '../given.js'; -import { an_sdk } from './given/an_sdk.js'; -import { requireScopedActivationSupport } from '../requireScopedActivationSupport.js'; - -describe('when the SDK supports scoped activation', given(an_sdk, context => { - let installedFailure: Error | undefined; - beforeEach(() => { - context.check(context.sdk(true, true)); - installedFailure = undefined; - try { requireScopedActivationSupport('chronicle://localhost:35000', context.activator); } - catch (error) { installedFailure = error as Error; } - }); - it('should accept the simulated SDK', () => { (context.failure === undefined).should.equal(true); }); - it('should accept the installed SDK', () => { (installedFailure === undefined).should.equal(true); }); -})); diff --git a/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/and_the_sdk_ignores_the_activator.ts b/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/and_the_sdk_ignores_the_activator.ts deleted file mode 100644 index fe298f56..00000000 --- a/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/and_the_sdk_ignores_the_activator.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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 { a_chronicle_builder } from '../when_registering_artifact_fallbacks/given/a_chronicle_builder.js'; - -describe('when activating artifacts in scopes and the SDK ignores the activator', given(a_chronicle_builder, context => { - let fromConnectionString: sinon.SinonStub; - let failure: Error | undefined; - beforeEach(() => { - // Chronicle below 6.16 drops the unknown artifactActivator option. - fromConnectionString = sinon.stub(ChronicleOptions, 'fromConnectionString').returns({} as ChronicleOptions); - failure = undefined; - context.start(); - try { context.withScopedActivation(); } - catch (error) { failure = error as Error; } - }); - afterEach(() => fromConnectionString.restore()); - it('should reject the registration', () => { - failure!.message.should.equal('activateArtifactsInScopes requires @cratis/chronicle 6.17.0 or later'); - }); -})); diff --git a/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/with_an_activator_for_another_event_store.ts b/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/with_an_activator_for_another_event_store.ts new file mode 100644 index 00000000..372051d7 --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/with_an_activator_for_another_event_store.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import type { ArcServer } from '@cratis/arc.core'; +import type { ChronicleOptions, IChronicleClient } from '@cratis/chronicle'; +import { ArcApplicationBuilder } from '@cratis/arc.core'; +import { chronicleArtifactActivator } from '../../../chronicleArtifactActivator.js'; +import { withChronicle } from '../../../withChronicle.js'; + +describe('when activating artifacts in scopes with a caller-owned client whose activator serves another event store', () => { + let failure: Error | undefined; + beforeEach(() => { + const artifactActivator = chronicleArtifactActivator(() => ({}) as ArcServer, 'Invoices'); + const client = { options: { artifactActivator } as unknown as ChronicleOptions } as IChronicleClient; + try { withChronicle(new ArcApplicationBuilder(), { client, eventStore: 'Orders', activateArtifactsInScopes: true }); } + catch (error) { failure = error as Error; } + }); + it('should reject the registration', () => { failure!.message.should.contain('requires creating it with artifactActivator'); }); +}); diff --git a/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/with_its_activator.ts b/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/with_its_activator.ts new file mode 100644 index 00000000..a7c53fe6 --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/with_its_activator.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 type { ArcServer } from '@cratis/arc.core'; +import type { ChronicleOptions, IChronicleClient } from '@cratis/chronicle'; +import { ArcApplicationBuilder, Severity } from '@cratis/arc.core'; +import sinon from 'sinon'; +import { chronicleArtifactActivator } from '../../../chronicleArtifactActivator.js'; +import { ChronicleRuntime } from '../../../ChronicleRuntime.js'; +import { withChronicle } from '../../../withChronicle.js'; +import { Dependency, FallbackReactor } from '../../when_registering_artifact_fallbacks/given/artifacts.js'; + +describe('when activating artifacts in scopes with a caller-owned client created with its activator', () => { + let options: ChronicleOptions; + let dispose: sinon.SinonSpy; + let resolvedReactor: unknown; + beforeEach(async () => { + const built: { server?: ArcServer } = {}; + const artifactActivator = chronicleArtifactActivator(() => built.server!, 'Orders'); + options = Object.freeze({ artifactActivator }) as unknown as ChronicleOptions; + dispose = sinon.spy(); + const client = { options, dispose, getEventStore: async () => ({}) } as unknown as IChronicleClient; + const builder = new ArcApplicationBuilder(); + withChronicle(builder, { client, eventStore: 'Orders', activateArtifactsInScopes: true }); + builder.services.addScoped(Dependency); + builder.add(FallbackReactor); + const application = await builder.build(); + built.server = application.server; + const scope = application.server.services.createScope({ tenantId: 'tenant', correlationId: crypto.randomUUID(), principal: undefined, + signal: new AbortController().signal, allowedSeverity: Severity.Error }); + await scope.resolve(ChronicleRuntime); + resolvedReactor = await scope.resolve(FallbackReactor); + await scope.dispose(); + await application.dispose(); + }); + it('should resolve the reactor with its dependencies', () => { resolvedReactor!.should.be.instanceOf(FallbackReactor); }); + it('should leave the client options unchanged', () => { Object.keys(options).should.deep.equal(['artifactActivator']); }); + it('should not dispose the client', () => { dispose.called.should.equal(false); }); +}); diff --git a/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client.ts b/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/without_its_activator.ts similarity index 56% rename from Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client.ts rename to Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/without_its_activator.ts index 91c4ee2c..528426d0 100644 --- a/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client.ts +++ b/Source/Chronicle/for_withChronicle/when_activating_artifacts_in_scopes/with_a_caller_owned_client/without_its_activator.ts @@ -2,14 +2,16 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { IChronicleClient } from '@cratis/chronicle'; import { ArcApplicationBuilder } from '@cratis/arc.core'; -import { withChronicle } from '../../withChronicle.js'; +import { withChronicle } from '../../../withChronicle.js'; -describe('when activating artifacts in scopes with a caller-owned client', () => { +describe('when activating artifacts in scopes with a caller-owned client without its activator', () => { let failure: Error | undefined; beforeEach(() => { - const client = {} as IChronicleClient; - try { withChronicle(new ArcApplicationBuilder(), { client, eventStore: 'Orders', activateArtifactsInScopes: true } as never); } + const client = { options: {} } as IChronicleClient; + try { withChronicle(new ArcApplicationBuilder(), { client, eventStore: 'Orders', activateArtifactsInScopes: true }); } catch (error) { failure = error as Error; } }); - it('should reject the registration', () => { failure!.message.should.contain('requires an Arc-owned connection'); }); + it('should name the wiring the client needs', () => { + failure!.message.should.contain("artifactActivator: chronicleArtifactActivator(server, 'Orders')"); + }); }); diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index 075ee26f..be1c004e 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.49.0", + "version": "0.50.0", "publishConfig": { "access": "public" }, @@ -34,9 +34,9 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.49.0", - "@cratis/arc.testing": "^0.49.0", - "@cratis/chronicle": "^6.7.0", + "@cratis/arc.core": "^0.50.0", + "@cratis/arc.testing": "^0.50.0", + "@cratis/chronicle": "^6.17.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", "zod": "^4.1.0" diff --git a/Source/Chronicle/requireScopedActivationSupport.ts b/Source/Chronicle/requireScopedActivationSupport.ts deleted file mode 100644 index fd98ae44..00000000 --- a/Source/Chronicle/requireScopedActivationSupport.ts +++ /dev/null @@ -1,32 +0,0 @@ -// 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'; -// Namespace import: probing for an export must not make the module fail to link on SDKs below the capability. -import * as chronicleArtifacts from '@cratis/chronicle/artifacts'; -import type { ClientArtifactsActivator } from '@cratis/chronicle/artifacts'; - -/** The lowest @cratis/chronicle version whose client honors scoped activation fully. */ -export const scopedActivationMinimumSdk = '6.17.0'; - -/** The parts of the installed SDK that decide whether scoped activation can be honored. */ -export interface ScopedActivationSdk { - readonly options: Pick; - readonly artifacts: object; -} - -const installed: ScopedActivationSdk = { options: ChronicleOptions, artifacts: chronicleArtifacts }; - -/** - * Fail fast when the installed SDK cannot honor scoped activation. Below 6.16 the client ignores the activator and - * constructs artifacts without Arc; 6.16 neither completes leases nor passes invocation metadata, so correlation and - * cleanup failures would silently degrade. Completion failures (ArtifactCompletionFailed) arrive with 6.17. - * @param connectionString - The Arc-owned connection string; parsing it does not connect. - * @param activator - The activator that must reach the client options. - * @param sdk - The SDK surface to check; defaults to the installed SDK. - */ -export function requireScopedActivationSupport(connectionString: string, activator: ClientArtifactsActivator, - sdk: ScopedActivationSdk = installed): void { - const honored = sdk.options.fromConnectionString(connectionString, { artifactActivator: activator }).artifactActivator === activator; - if (!honored || !('ArtifactCompletionFailed' in sdk.artifacts)) - throw new Error(`activateArtifactsInScopes requires @cratis/chronicle ${scopedActivationMinimumSdk} or later`); -} diff --git a/Source/Chronicle/withChronicle.ts b/Source/Chronicle/withChronicle.ts index cf3ca921..4cb6a905 100644 --- a/Source/Chronicle/withChronicle.ts +++ b/Source/Chronicle/withChronicle.ts @@ -16,8 +16,7 @@ import type { ChronicleRegistration } from './ChronicleOptions.js'; import { runChronicleCommand } from './runChronicleCommand.js'; import { ChronicleCommandScope } from './ChronicleCommandScope.js'; import { hasProtectedReadModel } from './hasProtectedReadModel.js'; -import { chronicleArtifactActivator } from './chronicleArtifactActivator.js'; -import { requireScopedActivationSupport } from './requireScopedActivationSupport.js'; +import { chronicleArtifactActivator, chronicleArtifactActivatorEventStore } from './chronicleArtifactActivator.js'; /** Register Chronicle without changing core Arc's optional dependency boundary. */ export function withChronicle(builder: ArcApplicationBuilder, options: Partial = {}): ArcApplicationBuilder { @@ -29,13 +28,16 @@ export function withChronicle(builder: ArcApplicationBuilder, options: Partial { server = built; }); - if (registration.activateArtifactsInScopes && registration.client) - throw new Error('Chronicle activateArtifactsInScopes requires an Arc-owned connection; pass chronicleArtifactActivator to your client instead'); - const activator = registration.activateArtifactsInScopes ? chronicleArtifactActivator(() => { + // A caller-owned client is never changed: its creator installs the activator, and Arc only checks that it did. + const callerActivator = registration.client?.options?.artifactActivator; + if (registration.activateArtifactsInScopes && registration.client && + chronicleArtifactActivatorEventStore(callerActivator) !== registration.eventStore) + throw new Error(`Chronicle activateArtifactsInScopes with a caller-owned client requires creating it with artifactActivator: chronicleArtifactActivator(server, '${ + registration.eventStore}'). Also pass reactorResultHandler: reactorCommandResultHandler(server, '${registration.eventStore}') so returned commands run through Arc`); + const activator = !registration.activateArtifactsInScopes ? undefined : registration.client ? callerActivator : chronicleArtifactActivator(() => { if (!server) throw new Error('Arc must be built before Chronicle artifacts can be activated'); return server; - }, registration.eventStore) : undefined; - if (activator) requireScopedActivationSupport(registration.connectionString!, activator); + }, registration.eventStore); // Arc constructs activated artifacts, so their registrations must be resolvable when the application is built. if (activator) builder.addBuiltObserver(built => { for (const artifact of [...artifacts.reactors, ...artifacts.reducers]) { @@ -64,7 +66,7 @@ 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; - }, activator)); + }, registration.client ? undefined : activator)); 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!)); diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json index 11f39315..b441465f 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "description": "ESLint diagnostics for Arc for TypeScript server artifacts", diff --git a/Source/Core/package.json b/Source/Core/package.json index 0b9bdf2a..13cdc857 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index 96dfb2ab..4429b801 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "description": "Arc and experimental Chronicle composition for Node.js", @@ -31,7 +31,7 @@ "@cratis/arc.testing": "workspace:^" }, "peerDependencies": { - "@cratis/chronicle": "^6.7.0", + "@cratis/chronicle": "^6.17.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", "zod": "^4.1.0" diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json index 859cbc81..4202ec72 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.49.0", + "@cratis/arc.core": "^0.50.0", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0", "rxjs": "^7.8.2" diff --git a/Source/Express/package.json b/Source/Express/package.json index a6e9b626..02d0d170 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index 3b64e353..d176d88f 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/package.json b/Source/Hono/package.json index c289a3a8..b683cf43 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index 6cc6ffad..e85e1647 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.49.0", + "@cratis/arc.core": "^0.50.0", "@cratis/fundamentals": "^7.19.6", "@opentelemetry/api": "^1.9.0", "mongodb": "^6.21.0", diff --git a/Source/Testing/package.json b/Source/Testing/package.json index 1c699250..09864e7b 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.49.0", + "version": "0.50.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index 6203eb2c..5d76092a 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.49.0", + "version": "0.50.0", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/scripts/chronicle-peer-floor-exports.json b/scripts/chronicle-peer-floor-exports.json index 6b94c833..41c5d742 100644 --- a/scripts/chronicle-peer-floor-exports.json +++ b/scripts/chronicle-peer-floor-exports.json @@ -1,9 +1,12 @@ { - "version": "6.7.0", + "version": "6.17.0", "exports": { "@cratis/chronicle": [ "AddBuilder", "AddChildBuilder", + "ArtifactCompletionFailed", + "ArtifactDelivery", + "ArtifactKind", "Causation", "CausationManager", "CausationType", @@ -57,6 +60,7 @@ "IdentityManager", "IdentityProvider", "IncompatibleChronicleServer", + "InvalidEventContextPropertyError", "InvalidMigrationGenerationGap", "JobId", "Jobs", @@ -66,7 +70,10 @@ "NestedBuilder", "NoUnitOfWorkHasBeenStarted", "ObserverId", + "ObserverRemovalOutcome", "ObserverRunningState", + "ObserverType", + "Observers", "PIIAndEncryptedCombinedNotSupported", "PIIManager", "PIINotSupportedOnEventSourceId", @@ -81,7 +88,9 @@ "ReadModels", "ReducerId", "Reducers", + "RejectedChronicleCredentials", "RemovedWithBuilder", + "ReplayState", "SecurityMetadataResolver", "SecurityMetadataType", "SetBuilder", @@ -179,6 +188,7 @@ "identity", "identityProvider", "increment", + "index", "isConstraint", "isEncrypted", "isEventTypeMigration", @@ -203,6 +213,7 @@ "noAutoMap", "notRewindable", "observation", + "onceOnly", "passive", "pii", "projection", @@ -213,8 +224,10 @@ "readModels", "reducer", "reducers", + "removeConstraint", "removedWith", "removedWithJoin", + "replay", "schemas", "seeder", "seeding", @@ -226,9 +239,14 @@ "subtractFrom", "tag", "tags", + "toObserverInformation", + "toObserverRemovalOutcome", + "toObserverRemovalResult", "toObserverRunningState", + "toObserverType", "transactions", "types", + "unique", "variantOf", "webhook", "webhooks" @@ -263,8 +281,10 @@ "isEventTypeMigration", "isRegisteredEvent", "mergeTags", + "removeConstraint", "tag", - "tags" + "tags", + "unique" ], "@cratis/chronicle/eventSequences": [ "CompleteStreamError", @@ -311,7 +331,9 @@ "Reactors", "getReactorMetadata", "isReactor", - "reactor" + "onceOnly", + "reactor", + "replay" ], "@cratis/chronicle/reducers": [ "ReducerId", @@ -320,6 +342,11 @@ "isReducer", "reducer" ], + "@cratis/chronicle/testing": [ + "ReadModelScenario", + "ReadModelScenarioGiven", + "ReadModelScenarioGivenBuilder" + ], "@cratis/chronicle/seeding": [ "EventSeeding", "getSeederMetadata", @@ -332,6 +359,7 @@ "ReadModelSubjectResolver", "ReadModels", "getReadModelMetadata", + "index", "isReadModel", "readModel" ], @@ -342,6 +370,7 @@ "CompositeKeyBuilder", "FromBuilder", "GlobalHandlerPropertyNotOnVariant", + "InvalidEventContextPropertyError", "JoinBuilder", "NestedBuilder", "ProjectionBuilderCore", @@ -434,8 +463,15 @@ "@cratis/chronicle/observation": [ "FailedPartitions", "ObserverId", + "ObserverRemovalOutcome", "ObserverRunningState", - "toObserverRunningState" + "ObserverType", + "Observers", + "toObserverInformation", + "toObserverRemovalOutcome", + "toObserverRemovalResult", + "toObserverRunningState", + "toObserverType" ], "@cratis/chronicle/sinks": [ "WellKnownSinks" @@ -451,6 +487,9 @@ "TypeIntrospector" ], "@cratis/chronicle/artifacts": [ + "ArtifactCompletionFailed", + "ArtifactDelivery", + "ArtifactKind", "DefaultClientArtifactsProvider" ], "@cratis/chronicle/identity": [ diff --git a/yarn.lock b/yarn.lock index d98202b9..e0e03dcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -110,9 +110,9 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/arc.core": ^0.49.0 - "@cratis/arc.testing": ^0.49.0 - "@cratis/chronicle": ^6.7.0 + "@cratis/arc.core": ^0.50.0 + "@cratis/arc.testing": ^0.50.0 + "@cratis/chronicle": ^6.17.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 zod: ^4.1.0 @@ -216,7 +216,7 @@ __metadata: rxjs: "npm:^7.8.2" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.49.0 + "@cratis/arc.core": ^0.50.0 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 rxjs: ^7.8.2 @@ -282,7 +282,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.49.0 + "@cratis/arc.core": ^0.50.0 "@cratis/fundamentals": ^7.19.6 "@opentelemetry/api": ^1.9.0 mongodb: ^6.21.0 @@ -451,7 +451,7 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/chronicle": ^6.7.0 + "@cratis/chronicle": ^6.17.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 zod: ^4.1.0