Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ContractTests/Client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.core-client-contract",
"version": "0.39.0",
"version": "0.40.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
10 changes: 10 additions & 0 deletions Documentation/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,18 @@ A factory declares its own `dependencies` for preflight and resolves them with i

Registry shutdown drains admitted work and pending singleton construction, then aborts the registry signal and disposes singletons. It rejects new executions and scopes. A singleton factory that fails poisons the registry and triggers the same shutdown. Cancellation is cooperative: factories and handlers must observe their signal. Do not await `registry.dispose()` from inside its own handler, factory, or disposer; it rejects to prevent a deadlock. Stop singleton background loops when the registry signal aborts, then join them in the singleton's disposer.

For every scope, `scope.identity`, the `execution` argument passed to scoped **and transient** factories, and `currentContext()` during their construction use the same frozen, plain creation-time snapshot of the declared execution context fields, including fields supplied by class getters (such as `tenantId` and `signal`). This remains true even when a factory is first resolved during borrowed work or an outer scope's service is resolved from a nested invocation. The snapshot retains the caller's **original principal object reference**, not a clone or frozen copy. Its mutable roles and claims remain mutable for ordinary requests; changing the caller's context fields after scope creation does not change the scoped tenant, correlation ID, or signal. Outside factory construction, `currentContext()` continues to reflect the current execution. Built-in Chronicle, MongoDB, and Drizzle factories also read this stable scope identity.

Nested commands and queries keep their causal dependency ancestry for cycle detection but get their own execution identity and scoped lifetime guard; the same scoped token in two independent nested scopes is not a cycle.

## Borrow a scope in a host integration

A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Only scopes created with `server.services.createScope(context)` can be borrowed; Arc's own request scopes skip the principal copy. Dispose the host-created scope yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. The callback's ambient `currentContext()` receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal; factories instead see the scope snapshot. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: both `execution` and `currentContext()` inside a scoped or transient factory use the scope's original signal, correlation ID, and principal reference. A scoped instance created during one invocation retains those values when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. If a singleton factory fails before a borrowed invocation drains, `runInScope` rejects with a service dependency error rather than returning the callback's value; registry shutdown waits for the invocation without being joined inside it.

Borrowing requires a strictly plain-data principal throughout its roles, claims, and extra fields: ordinary objects (including null-prototype objects), arrays, and primitive values, with only own enumerable string properties and no accessors. Arrays' built-in `length` is allowed. Symbol keys or values, non-enumerable properties, getters, functions, `Map`, `Set`, `Date`, class instances, invalid identity fields, or nesting beyond the supported depth make the scope non-borrowable. Ordinary requests still use the original principal reference and their creation-time context fields; `runInScope` rejects a non-borrowable scope before calling back.

This is a trusted host API, **not an authorization mechanism** or a way to authenticate a principal. Use the normal command or query pipeline for authorization; do not expose scope creation or borrowed execution to untrusted callers.

## Related

- [Build an application](core/getting-started.md)
Expand Down
2 changes: 1 addition & 1 deletion Documentation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.39.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.40.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
Expand Down
2 changes: 1 addition & 1 deletion Documentation/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_<Su
| Argument binding | Supported | Case-insensitive names, number and boolean conversion on GET, repeated keys for array arguments. | `Source/Core/for_ArcServer/when_handling_a_query`, `Samples/Tasks/Features/Tasks/Listing/for_TaskItem/when_performing/with_a_concept_argument.ts` |
| Paging and sorting | Supported | Arrays are sorted and paged in memory; a provider returns `queryPage(items, totalItems, sorting?)`. Page offsets clamp to signed int32. See [Paging and sorting](../queries/model-bound/paging.md). | `Source/Core/for_ArcServer/when_sorting_a_query`, `Source/Core/queries/for_queryRendering`, `Samples/Tasks/Features/Tasks/Listing/for_TaskItem/when_performing/with_sorting_and_paging.ts` |
| Renderers and read-model interceptors | Bounded | Ordered scoped `QueryRenderer`s own provider results; exact-class `ReadModelInterceptor`s transform snapshots and each emission, including HTTP snapshots. No automatic provider pushdown. | `Source/Core/queries/for_queryRendering`, `Source/Core/for_ArcApplicationBuilder/when_registering_read_model_interceptors` |
| Services and dependencies | Supported | Class and `serviceToken` tokens with singleton, scoped, and transient lifetimes; the build preflights declared graphs. See [Dependency injection](../dependency-injection.md). | `Source/Core/dependencyInjection/for_ServiceRegistry`, `.../for_ServiceScope`, `Source/Core/for_ArcApplicationBuilder/when_resolving_model_bound_services`, `yarn test:legacy-decorators` |
| Services and dependencies | Supported | Class and `serviceToken` tokens with singleton, scoped, and transient lifetimes; the build preflights declared graphs. Scopes snapshot every declared execution-context field at creation (including inherited getters) but retain the original principal for ordinary factories. Trusted hosts can borrow a live scope only when its principal is strictly plain data; borrowed work uses a separate deep-frozen principal copy and may override only correlation and link an additional signal. This is not authorization. See [Dependency injection](../dependency-injection.md). | `Source/Core/dependencyInjection/for_ServiceRegistry`, `.../for_ServiceScope`, `Source/Core/for_ArcServer/when_borrowing_a_scope`, `Source/Core/for_ArcApplicationBuilder/when_resolving_model_bound_services`, `yarn test:legacy-decorators` |

- **Paging and sorting.** GET rejects nonpositive integer `pageSize` with a paging rule; nonnumeric and out-of-int32 GET paging values default, while leading zeros, `+`, and surrounding whitespace parse as integers. Structured `QUERY` treats nonpositive sizes as unpaged and rejects invalid sort directions with an owning member.

Expand Down
2 changes: 1 addition & 1 deletion Documentation/reference/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.39.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.40.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 <package> pack --out <file>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.14.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.39.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.40.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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// 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, Severity } from '@cratis/arc.core';
import type { IChronicleClient, IEventStore } from '@cratis/chronicle';
import { ChronicleReadModels } from '../ChronicleReadModels.js';
import '../index.js';

should();
describe('when resolving Chronicle read models in a mutated scope', () => {
let namespace: string;
beforeEach(async () => {
const client = { getEventStore: async (_store: string, requested: string) => {
namespace = requested;
return {} as IEventStore;
} } as unknown as IChronicleClient;
const builder = ArcApplication.createBuilder();
builder.withChronicle({ client, eventStore: 'Items' });
const application = await builder.build();
const identity = { tenantId: 'first', correlationId: crypto.randomUUID(), principal: undefined,
signal: new AbortController().signal, allowedSeverity: Severity.Warning };
const scope = application.server.services.createScope(identity);
identity.tenantId = 'second';
try { await (await scope.resolve(ChronicleReadModels)).getStore(); }
finally { await scope.dispose(); await application.dispose(); }
});
it('should resolve the creation-time tenant namespace', () => namespace.should.equal('first'));
});
6 changes: 3 additions & 3 deletions Source/Chronicle/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.chronicle",
"version": "0.39.0",
"version": "0.40.0",
"publishConfig": {
"access": "public"
},
Expand Down Expand Up @@ -34,8 +34,8 @@
"README.md"
],
"peerDependencies": {
"@cratis/arc.core": "^0.39.0",
"@cratis/arc.testing": "^0.39.0",
"@cratis/arc.core": "^0.40.0",
"@cratis/arc.testing": "^0.40.0",
"@cratis/chronicle": "^6.7.0",
"@cratis/fundamentals": "^7.19.6",
"rxjs": "^7.8.2",
Expand Down
2 changes: 1 addition & 1 deletion Source/CodeAnalysis/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/eslint-plugin-arc-core",
"version": "0.39.0",
"version": "0.40.0",
"type": "module",
"license": "MIT",
"description": "ESLint diagnostics for Arc for TypeScript server artifacts",
Expand Down
13 changes: 13 additions & 0 deletions Source/Core/ArcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import { requestContext } from './execution/RequestContextStore.js';
import { isObservableOperation } from './queries/observable/ObservableOperation.js';
import { CommandOperationBoundary } from './commands/CommandOperationBoundary.js';
import { runOwned } from './execution/runOwned.js';
import { runInScope } from './execution/runInScope.js';
import type { RunInScopeOptions } from './execution/RunInScopeOptions.js';
import type { ServiceScope } from './dependencyInjection/ServiceScope.js';
import { runProvider } from './execution/runProvider.js';
import { disposeObservableServer } from './queries/observable/disposeObservableServer.js';
import type { ObservableQuerySession } from './queries/observable/ObservableQuerySession.js';
Expand Down Expand Up @@ -86,6 +89,16 @@ export class ArcServer {
registerObservableCleanup(this, this.#sessions);
}

/**
* Run trusted host work in a borrowed scope without disposing it. Authority comes from the
* scope's creation snapshot; only correlation and an additional cancellation signal can vary.
* This is not an authorization mechanism. The caller must dispose the scope when work ends.
*/
runInScope<T>(scope: ServiceScope, callback: () => T | Promise<T>,
options?: RunInScopeOptions): Promise<T> {
return runInScope(this.services, this.#generatedMetadata, scope, callback, options);
}

private runScoped(operation: Operation, input: unknown, context: ExecutionContext, options?: QueryOptions,
mode = OperationMode.Execute): Promise<CommandResult | QueryResult> {
if (operation.kind === 'command' && CommandOperationBoundary.attempt(this))
Expand Down
3 changes: 2 additions & 1 deletion Source/Core/build/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Artifact } from '../reflection/Artifact.js';
import type { ClassType } from '../reflection/ClassType.js';
import { ownMetadata } from '../reflection/ownMetadata.js';
import type { ServiceIdentifier } from '../dependencyInjection/ServiceIdentifier.js';
import { createOwnedServiceScope } from '../dependencyInjection/ServiceScope.js';
import type { AuthorizationPolicy, AuthorizationPolicyRegistration } from '../authorization/AuthorizationPolicy.js';
import { readModelArgument } from '../commands/modelBound/commandReadModel.js';
import { BaseValidator } from '../validation/BaseValidator.js';
Expand All @@ -20,7 +21,7 @@ export async function preflight(server: ArcServer, dependencies: ServiceIdentifi
server.services.preflight([...dependencies, ...[...policies.values()].filter(
(policy): policy is (abstract new (...arguments_: never[]) => AuthorizationPolicy) =>
typeof policy.prototype?.authorize === 'function')]);
const scope = server.services.createScope({ correlationId: '', principal: undefined, tenantId: undefined,
const scope = createOwnedServiceScope(server.services, { correlationId: '', principal: undefined, tenantId: undefined,
signal: new AbortController().signal, allowedSeverity: Severity.Error });
try {
const resolvers = await Promise.all([...options.readModelForCommandResolvers ?? [], ...readModelResolvers]
Expand Down
Loading
Loading