diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e23af247..f90a570d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,20 +1,59 @@ # Copyright (c) Cratis. All rights reserved. # Licensed under the MIT license. See LICENSE file in the project root for full license information. -name: Manual CI +name: CI on: + pull_request: + push: + branches: [main] workflow_dispatch: permissions: contents: read concurrency: - group: manual-ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - verify: + changes: + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + code: ${{ steps.classify.outputs.code }} + steps: + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + fetch-depth: 0 + - name: Check for code changes + id: classify + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if [[ "${{ github.event_name }}" == workflow_dispatch || "$BASE" =~ ^0+$ ]]; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + files=$(git diff --name-only "$BASE" HEAD) + if [[ -z "$files" ]]; then + echo 'No changed files to classify' >&2 + exit 2 + fi + code=false + while IFS= read -r file; do + case "$file" in + Documentation/*|*.md) ;; + *) code=true; break ;; + esac + done <<< "$files" + echo "code=$code" >> "$GITHUB_OUTPUT" + + docs: + needs: changes + if: needs.changes.outputs.code == 'false' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -25,12 +64,31 @@ jobs: - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '22' + node-version: '22.19.0' + - name: Enable Yarn from the repository package manager + run: corepack enable + - name: Install locked dependencies + run: yarn install --immutable + - name: Check documentation + run: yarn build && yarn docs:lint && yarn docs:snippets:self-test && yarn docs:snippets + + verify: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22.19.0' - name: Enable Yarn from the repository package manager run: corepack enable - name: Install locked dependencies run: yarn install --immutable - - name: Test release guards - run: node --test scripts/for_release/*.test.mjs - name: Run the complete local gate run: yarn ci diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57a6f6e4..b9d16417 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,18 +44,20 @@ The workspaces link to each other, so the sample and the adapters use the local ## Verify your change -Run the complete local gate from the repository root before you open a pull request: +Run the clean release gate from the repository root before you open a pull request: ```bash -yarn ci +yarn ci:clean ``` -It runs, in order: +`yarn clean` lists and removes workspace `dist` directories and `*.tsbuildinfo` files (never tracked files), then `yarn ci` rebuilds and checks the repository. The hosted CI also runs `yarn ci` on fresh Linux checkouts for pull requests and pushes to main. Use `yarn ci` alone for faster verification while working. + +The gate runs, in order: 1. ESLint (`yarn lint`). 2. The type check (`yarn typecheck`): `tsc -b` for every package, then `tsc -p tsconfig.specs.json` for the specs. 3. The build (`yarn build`). -4. The installed-package check (`yarn check:consumers`): packs all ten non-private workspaces, installs them with lockfile-pinned peers outside the workspace, checks tarball contents and dependencies, type-checks NodeNext and Bundler consumers, and runs native ESM HTTP and CLI probes. Core is also checked without optional RxJS. Chronicle and Drizzle use a separate type-check with `skipLibCheck: true` for documented upstream declaration errors; all other consumer files use `skipLibCheck: false`. Run `yarn check:consumers --self-test` to confirm it rejects a planted forbidden file. No package is published. +4. The installed-package check (`yarn check:consumers`): packs all eleven non-private workspaces, installs them with lockfile-pinned peers outside the workspace, checks tarball contents and dependencies, type-checks NodeNext and Bundler consumers, and runs native ESM HTTP and CLI probes. Core is also checked without optional RxJS. Chronicle and Drizzle use a separate type-check with `skipLibCheck: true` for documented upstream declaration errors; all other consumer files use `skipLibCheck: false`. Run `yarn check:consumers --self-test` to confirm it rejects a planted forbidden file. No package is published. 5. The client generation checks (`yarn test:client-generation:verify`): a strict `Bundler` compile of the proxy fixtures with `skipLibCheck: false`, then the generation tests with Node.js. Run `yarn test:client-generation` on its own to build first. 6. The Vitest specs (`yarn test`), including the MongoDB unit specs and the Chronicle specs, which use typed substitutes and never start a Chronicle kernel. 7. The legacy-decorator and decorator-type contract checks (`yarn test:legacy-decorators`, `yarn test:decorator-types`). @@ -70,7 +72,7 @@ Two checks need more than Node.js and are not part of `yarn ci`. Run them when y - `yarn test:conformance` restores and builds the .NET reference host from its lock file, builds the workspace, and runs the 57 paired HTTP checks against `Cratis.Arc` 22.23.0. It needs the .NET 10 SDK and the .NET and ASP.NET Core 10.0.11 runtimes. - `bash Source/MongoDB/run-integration.sh` runs the live MongoDB spec in a disposable Docker container. It exits with 2 when Docker is not available, which means the check did not run. -A hosted run does not replace local verification. The hosted CI workflow is started manually. +A hosted run does not replace local verification. The hosted CI workflow also supports manual runs. ## Conventions diff --git a/ContractTests/Client/observable-direct-sse.test.mjs b/ContractTests/Client/observable-direct-sse.test.mjs index d3065d80..34aa3be9 100644 --- a/ContractTests/Client/observable-direct-sse.test.mjs +++ b/ContractTests/Client/observable-direct-sse.test.mjs @@ -97,9 +97,10 @@ for (const kind of ['express', 'fastify', 'hono']) test(`generated installed cli assert.equal(timedOut.status, 408); assert.equal((await timedOut.json()).hasExceptions, true); const encoded = new WebSocket(`${listening.origin.replace('http:', 'ws:')}/api/%6eumbers`); - encoded.onerror = () => {}; - await within(new Promise(resolve => encoded.addEventListener('close', resolve, { once: true })), - 'Encoded query upgrade rejection'); + await within(new Promise((resolve, reject) => { + encoded.addEventListener('error', resolve, { once: true }); + encoded.addEventListener('open', () => reject(new Error('Encoded query unexpectedly upgraded')), { once: true }); + }), 'Encoded query upgrade rejection'); Globals.queryTransportMethod = QueryTransportMethod.WebSocket; const live = new Numbers(); live.setOrigin(listening.origin); diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index 5f702397..cf4f27ff 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.23.0", + "version": "0.24.0", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/commands/calling-commands-from-code.md b/Documentation/commands/calling-commands-from-code.md index f518662e..13dbbe44 100644 --- a/Documentation/commands/calling-commands-from-code.md +++ b/Documentation/commands/calling-commands-from-code.md @@ -8,8 +8,10 @@ Sometimes a command has to run without an HTTP client: an import job, a message | Entry point | Runs | Trusts the caller with | | --- | --- | --- | | `handle(request, native?)` | The full HTTP pipeline | Nothing beyond an HTTP client | -| `executeCommand(name, input, context, validateOnly?)` | Authorization, binding, validators, and the command | The whole execution context, including allowed severity | -| `execute(command, context, validateOnly?)` | The same, for a decorated command instance | The same | +| `executeCommand(name, input, context)` | Authorization, binding, validators, and the command | The whole execution context, including allowed severity | +| `execute(command, context)` | The same, for a decorated command instance | The same | +| `validateCommand(name, input, context)` | Authorization, binding, and validators, without `provide` or `handle` | The whole execution context | +| `validate(command, context)` | The same, for a decorated command instance | The same | | `performQuery(name, input, context, options?)` | Authorization, binding, validators, and the query | The whole execution context, except allowed severity | ## Run a command and a query directly @@ -20,8 +22,8 @@ This example uses the Tasks sample's classes, added explicitly: import { randomUUID } from 'node:crypto'; import { ArcApplication, Severity } from '@cratis/arc.core'; import { Tasks } from './Features/Tasks/Tasks.js'; -import { RegisterTask } from './Features/Tasks/Registration/RegisterTask.js'; -import { TaskItem } from './Features/Tasks/Listing/TaskItem.js'; +import { RegisterTask } from './Features/Tasks/Registration/Registration.js'; +import { TaskItem } from './Features/Tasks/Listing/Listing.js'; const builder = ArcApplication.createBuilder(); builder.services.addSingleton(Tasks); @@ -56,7 +58,7 @@ The first log line is `true 1a638f8e-4444-4444-8888-a0b10cdd9977`; the query ret | Model-bound query | Namespace, read-model name, and method: `Tasks.Listing.TaskItem.allTasks` when discovered | | Low-level definition | Namespace and name joined with a dot, such as `Tasks.Create`, or the bare name without a namespace | -If you already hold a decorated command instance, `app.server.execute(command, context)` serializes its decorated fields and runs the same pipeline; the registered command name must be unambiguous. Pass `true` as the last argument of `executeCommand` or `execute` to validate without running the command, like the `/validate` route. `performQuery` takes paging and sorting as its fourth argument, for example `{ paging: { page: 0, pageSize: 10 }, sorting: { field: 'title', direction: 'asc' } }`. +If you already hold a decorated command instance, `app.server.execute(command, context)` serializes its decorated fields and runs the same pipeline; the registered command name must be unambiguous. Call `validateCommand(name, input, context)` or `validate(command, context)` to check authorization and validation without running the command, like the `/validate` route. `performQuery` takes paging and sorting as its fourth argument, for example `{ paging: { page: 0, pageSize: 10 }, sorting: { field: 'title', direction: 'asc' } }`. ## What a direct call does differently diff --git a/Documentation/core/getting-started.md b/Documentation/core/getting-started.md index cd86b1b8..5c71f185 100644 --- a/Documentation/core/getting-started.md +++ b/Documentation/core/getting-started.md @@ -62,7 +62,7 @@ The builder also registers services that change pipeline behavior: | `addQueryRenderer(token)` | A renderer for provider-owned query results; see [Query renderers](../queries/renderers.md) | | `addReadModelInterceptor(token)` | A read-model transform; see [Read-model interception](../queries/read-model-interception.md) | -After importing their packages, call `withMongoDB`, `withDrizzle`, or `withChronicle`. The old `add*` methods and standalone functions remain as deprecated aliases. The builder uses a `Symbol.for`-keyed extension registry rather than changing its prototype; importing an integration registers its install function even if the core is loaded twice. A missing integration fails at the call site. Configuration from `appsettings.json` is available to Chronicle and MongoDB, but model classes, clients, and authentication must be provided explicitly. +After importing their packages, call `withMongoDB`, `withDrizzle`, or `withChronicle`. The old `add*` methods and standalone functions remain as deprecated aliases. Each integration adds its typed method to the portable builder prototype and registers its installer in a `Symbol.for`-keyed registry. The shared registry lets a builder loaded from another copy of core find the installer. A missing integration fails at the call site. Configuration from `appsettings.json` is available to Chronicle and MongoDB, but model classes, clients, and authentication must be provided explicitly. ## Run it, or mount it diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index 3ae8a3f8..5aa060de 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -80,7 +80,7 @@ const context = { correlationId: crypto.randomUUID(), principal: undefined, tenantId: 'acme', signal: new AbortController().signal, allowedSeverity: Severity.Warning }; -const validation = await server.executeCommand('Write', { text: 'hello' }, context, true); +const validation = await server.validateCommand('Write', { text: 'hello' }, context); const result = await server.executeCommand('Write', { text: 'hello' }, context); console.log(validation.isSuccess, result.response, created); // true ['hello'] 1 await server.dispose(); diff --git a/Documentation/index.md b/Documentation/index.md index 7434e945..9800f999 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.23.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.24.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/capabilities.md b/Documentation/reference/capabilities.md index 8e2c3095..2efc4cd2 100644 --- a/Documentation/reference/capabilities.md +++ b/Documentation/reference/capabilities.md @@ -28,8 +28,8 @@ Evidence paths are relative to the repository root. Spec folders follow `for_/validate` runs authorization and validation only; a command named `Validate` executes on its own route. | `Source/Core/for_ArcServer/when_validating_a_command`, `.../when_handling_a_validate_named_command` | -| `provide()` and outcomes | Supported | `provide()` runs after validation and may short-circuit with `rejected(...)` or `denied(...)`. Only helper-created values are outcomes; `isOutcome` recognizes them. See [Command outcomes](../commands/command-outcomes.md). | `Source/Core/for_ArcServer/when_providing_a_command`, `Source/Core/for_ArcApplicationBuilder/when_providing_a_model_bound_command`, `Source/Core/results/for_Outcome` | -| Several return values and response value handlers | Bounded | `tuple(...)` flattens branded groups; at most one unhandled value becomes the response, and scoped `CommandResponseValueHandler`s process every other value in deterministic name order. Ordinary arrays are not flattened. At generation time, Chronicle event types/arrays and integration wrappers plus Arc operations are omitted from command responses; branded tuple results select the sole unhandled value, and unions with a single visible type are supported. No general OneOf or Result union classification. | `Source/Core/results/for_tuple`, `Source/Core/for_ArcServer/when_processing_command_response`, `Source/Core/for_ArcApplicationBuilder/when_registering_a_response_handler` | +| `provide()` and outcomes | Supported | `provide()` runs after validation and may short-circuit with `rejected(...)` or `denied(...)`. Only helper-created values are outcomes; `isOutcome` recognizes them. See [Command outcomes](../commands/command-outcomes.md). | `Source/Core/for_ArcServer/when_providing_a_command`, `Source/Core/for_ArcApplicationBuilder/when_providing_a_model_bound_command`, `Source/Core/commands/for_Outcome` | +| Several return values and response value handlers | Bounded | `tuple(...)` flattens branded groups; at most one unhandled value becomes the response, and scoped `CommandResponseValueHandler`s process every other value in deterministic name order. Ordinary arrays are not flattened. At generation time, Chronicle event types/arrays and integration wrappers plus Arc operations are omitted from command responses; branded tuple results select the sole unhandled value, and unions with a single visible type are supported. No general OneOf or Result union classification. | `Source/Core/commands/for_tuple`, `Source/Core/for_ArcServer/when_processing_command_response`, `Source/Core/for_ArcApplicationBuilder/when_registering_a_response_handler` | | Execution scopes | Supported | Low-level `scopes` complete once in reverse order, including a scope whose `begin` threw; a failed completion removes the response. Model-bound commands use command execution runners instead. | `Source/Core/for_ArcServer/when_beginning_a_command_scope`, `.../when_completing_a_command_scope`, `Source/Core/for_ArcApplicationBuilder/when_executing_command_runners` | | Command operations | Bounded | `CommandOperation` declarations are preflighted, executed in order, and compensated in reverse after a known uncommitted failure; `Unknown` and `Mixed` fail closed. No distributed transaction, durable recovery, or crash guarantee. See [Command operations](../commands/operations/index.md). | `Source/Core/for_ArcServer/when_executing_an_operation`, `.../when_an_operation_fails`, `.../when_a_commit_is_unknown`, `.../when_declaring_nested_operations` | | Command context and keys | Bounded | `CommandContext` carries the command, a key resolved once, case-insensitive values, and request identity. `@key()`, `getKey()`, and scoped `CommandKeyResolver`s; `abortSignal()`, `commandContext()`, and `provided(Type)` markers. See [Command context](../commands/command-context.md). | `Source/Core/for_ArcServer/when_resolving_command_keys`, `Source/Core/for_ArcApplicationBuilder/when_resolving_command_keys`, `.../when_resolving_command_arguments` | diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md index 7734ec13..62fbeec3 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.23.0, the version of the source preview. **None is published to npm**; reference them from a clone with the `workspace:^` protocol. They ship ES modules only. +Every package in this repository is at version 0.24.0, the version of the source preview. **None is published to npm**; reference them from a clone with the `workspace:^` protocol. They ship ES modules only. ## Server packages diff --git a/Documentation/testing/low-level-definitions.md b/Documentation/testing/low-level-definitions.md index 7804587f..1b1af25c 100644 --- a/Documentation/testing/low-level-definitions.md +++ b/Documentation/testing/low-level-definitions.md @@ -28,7 +28,8 @@ try { | Method | Runs | | --- | --- | -| `executeCommand(name, input, context?, validateOnly?)` | The direct command pipeline; `context` overrides the scenario's default context fields | +| `executeCommand(name, input, context?)` | The direct command pipeline; `context` overrides the scenario's default context fields | +| `validateCommand(name, input, context?)` | Authorization and validation without running the command handler | | `performQuery(name, input, context?, options?)` | The direct query pipeline, with optional paging and sorting | | `handle(request)` | The full HTTP pipeline, authenticating with the configured handlers | diff --git a/README.md b/README.md index 4e8a7410..94e703d2 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.7.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.23.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 ran in Deno 2.9.7, while Bun, Cloudflare Workers, and Next.js deployments remain unverified. 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.24.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 ran in Deno 2.9.7, while Bun, Cloudflare Workers, and Next.js deployments remain unverified. 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/Samples/Library/e2e.test.mjs b/Samples/Library/e2e.test.mjs index ccd4b10e..b2b491ad 100644 --- a/Samples/Library/e2e.test.mjs +++ b/Samples/Library/e2e.test.mjs @@ -9,10 +9,10 @@ import { setTimeout } from 'node:timers/promises'; import { once } from 'node:events'; import { after, test } from 'node:test'; import { Guid } from '@cratis/fundamentals'; -import { RegisterAuthor } from './Web/src/generated/Authors/Registration/RegisterAuthor.proxy.ts'; -import { AuthorsPage } from './Web/src/generated/Authors/Listing/AuthorsPage.proxy.ts'; -import { AllAuthors } from './Web/src/generated/Authors/Listing/AllAuthors.proxy.ts'; -import { AddBook } from './Web/src/generated/Books/Registration/AddBook.proxy.ts'; +import { RegisterAuthor } from './Web/src/generated/Authors/Registration/RegisterAuthor.proxy.js'; +import { AuthorsPage } from './Web/src/generated/Authors/Listing/AuthorsPage.proxy.js'; +import { AllAuthors } from './Web/src/generated/Authors/Listing/AllAuthors.proxy.js'; +import { AddBook } from './Web/src/generated/Books/Registration/AddBook.proxy.js'; const socket = createServer(); socket.listen(0, '127.0.0.1'); diff --git a/Samples/Library/run-integration.sh b/Samples/Library/run-integration.sh index d988f79b..2d18efac 100644 --- a/Samples/Library/run-integration.sh +++ b/Samples/Library/run-integration.sh @@ -22,9 +22,10 @@ trap 'exit 143' TERM yarn workspace @cratis/arc.sample.library build container_id=$(docker run -d --name "arc-library-chronicle-$$" -p 127.0.0.1::35000 cratis/chronicle:latest-development) port=$(docker port "$container_id" 35000/tcp) +chronicle_host=${CHRONICLE_HOST:-localhost} ready=0 for attempt in $(seq 1 90); do - if curl -kfsS --max-time 2 "https://localhost:${port##*:}/" >/dev/null 2>&1; then ready=1; break; fi + if curl -kfsS --max-time 2 "https://${chronicle_host}:${port##*:}/" >/dev/null 2>&1; then ready=1; break; fi sleep 1 done if [ "$ready" -ne 1 ]; then @@ -32,5 +33,5 @@ if [ "$ready" -ne 1 ]; then docker logs "$container_id" >&2 exit 2 fi -CHRONICLE_URL="chronicle://localhost:${port##*:}" \ +CHRONICLE_URL="chronicle://${chronicle_host}:${port##*:}" \ node --import tsx --test --test-force-exit Samples/Library/e2e.test.mjs diff --git a/Samples/Tasks/package.json b/Samples/Tasks/package.json index 636aad1b..35cbf5cd 100644 --- a/Samples/Tasks/package.json +++ b/Samples/Tasks/package.json @@ -11,5 +11,13 @@ "@cratis/arc.testing": "workspace:^", "@cratis/fundamentals": "7.19.6", "rxjs": "^7.8.2" + }, + "devDependencies": { + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", + "@types/react": "18.3.28", + "react": "18.3.1", + "reflect-metadata": "0.2.2", + "tsyringe": "^4.10.0" } } diff --git a/Source/Chronicle/ChronicleUnitOfWork.ts b/Source/Chronicle/ChronicleUnitOfWork.ts index b670bf61..5c2dfe5c 100644 --- a/Source/Chronicle/ChronicleUnitOfWork.ts +++ b/Source/Chronicle/ChronicleUnitOfWork.ts @@ -3,7 +3,8 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { IEventStore } from '@cratis/chronicle'; import type { AppendOptions, ConcurrencyScope, EventForEventSourceId } from '@cratis/chronicle/eventSequences'; -import { recordFailure, type CommandContext, type CommandResult } from '@cratis/arc.core'; +import type { CommandContext, CommandResult } from '@cratis/arc.core'; +import { recordFailure } from '@cratis/arc.core/hosting'; import type { CommandCommitDisposition } from '@cratis/arc.core'; import { checkResults } from './ChronicleCommand.js'; import type { AggregateRoot } from './AggregateRoot.js'; diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index 5ba8d06a..bb83547e 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.23.0", + "version": "0.24.0", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.23.0", - "@cratis/arc.testing": "^0.23.0", + "@cratis/arc.core": "^0.24.0", + "@cratis/arc.testing": "^0.24.0", "@cratis/chronicle": "^6.7.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", diff --git a/Source/Chronicle/testing/ChronicleCommandScenario.ts b/Source/Chronicle/testing/ChronicleCommandScenario.ts index 2996560c..d7b41af8 100644 --- a/Source/Chronicle/testing/ChronicleCommandScenario.ts +++ b/Source/Chronicle/testing/ChronicleCommandScenario.ts @@ -4,7 +4,7 @@ import { CommandScenario, type ScenarioCommandResult } from '@cratis/arc.testing import type { IChronicleClient, IEventStore } from '@cratis/chronicle'; import { EventSequenceNumber, type AppendOptions, type AppendResult, type EventForEventSourceId } from '@cratis/chronicle/eventSequences'; type ClassType = new () => T; -import '../index.js'; +import '../withChronicle.js'; interface Appended { readonly tenant: string; readonly source: string; readonly event: object; readonly eventSourceType?: string; readonly eventStreamType?: string; readonly eventStreamId?: string; diff --git a/Source/Chronicle/tsconfig.json b/Source/Chronicle/tsconfig.json index 8f871b86..a37caf66 100644 --- a/Source/Chronicle/tsconfig.json +++ b/Source/Chronicle/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["**/*.ts"], - "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "vite.config.mts"], + "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "given.ts", "vite.config.mts"], "references": [{ "path": "../Core" }, { "path": "../Testing" }] } diff --git a/Source/Chronicle/withChronicle.ts b/Source/Chronicle/withChronicle.ts index 4d6bf6c2..56b146d6 100644 --- a/Source/Chronicle/withChronicle.ts +++ b/Source/Chronicle/withChronicle.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ArcApplicationBuilder } from '@cratis/arc.core'; import type { ArcServer } from '@cratis/arc.core'; +import { ArcApplicationBuilder as FetchArcApplicationBuilder } from '@cratis/arc.core/fetch'; import type { Constructor } from '@cratis/fundamentals'; import { ChronicleArtifacts } from './ChronicleArtifacts.js'; import { ChronicleReadModels } from './ChronicleReadModels.js'; @@ -43,8 +44,8 @@ export function withChronicle(builder: ArcApplicationBuilder, options: Partial): this; } @@ -54,3 +55,6 @@ ArcApplicationBuilder.registerExtension('chronicle', withChronicle); ArcApplicationBuilder.prototype.withChronicle = function (options: Partial) { return this.extend('chronicle', options); }; +FetchArcApplicationBuilder.prototype.withChronicle = function (options: Partial) { + return this.extend('chronicle', options); +}; diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json index a662b033..7fc98e60 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "description": "ESLint diagnostics for Arc for TypeScript server artifacts", diff --git a/Source/Core/ArcApplicationBuilder.ts b/Source/Core/ArcApplicationBuilder.ts index 1561aa86..4787297d 100644 --- a/Source/Core/ArcApplicationBuilder.ts +++ b/Source/Core/ArcApplicationBuilder.ts @@ -2,8 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { z } from 'zod'; import type { ArcOptions } from './ArcOptions.js'; +import type { ArcBuilderExtensions } from './fetch.js'; import type { CratisConfiguration } from './configuration/loadConfiguration.js'; -import { ArcApplicationServices } from './ArcApplicationServices.js'; +import { ArcApplicationServices } from './dependencyInjection/ArcApplicationServices.js'; import { FetchArcApplication } from './FetchArcApplication.js'; import { ArcServer } from './ArcServer.js'; import type { CommandDefinition } from './commands/CommandDefinition.js'; @@ -28,7 +29,7 @@ import type { CommandKeyResolver } from './commands/CommandKeyResolver.js'; import type { QueryRenderer } from './queries/QueryRenderer.js'; import type { ReadModelInterceptor } from './queries/ReadModelInterceptor.js'; import type { ReadModelForCommandResolver } from './commands/ReadModelForCommandResolver.js'; -import { readModelArgument } from './commands/modelBound/readModel.js'; +import { readModelArgument } from './commands/modelBound/commandReadModel.js'; import type { CommandContext } from './commands/CommandContext.js'; import type { CommandResult } from './commands/CommandResult.js'; import type { CommandExecutionScope } from './commands/CommandExecutionScope.js'; @@ -37,6 +38,10 @@ import { isIdentityDetailsProvider } from './identity/discoverIdentityDetails.js import type { IdentityDetailsProvider } from './identity/IdentityDetailsProvider.js'; /** Collect decorated artifacts and their services into one executable application. */ +// Interface merging exposes integration-owned methods without depending on optional packages in core. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-unsafe-declaration-merging +export interface ArcApplicationBuilder extends ArcBuilderExtensions {} +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class ArcApplicationBuilder { readonly services = new ArcApplicationServices(); readonly #artifacts: Artifact[] = []; diff --git a/Source/Core/ArcServer.ts b/Source/Core/ArcServer.ts index 449afe4a..1c022fe1 100644 --- a/Source/Core/ArcServer.ts +++ b/Source/Core/ArcServer.ts @@ -1,6 +1,5 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { z } from 'zod'; import type { CommandResult } from './commands/CommandResult.js'; import type { ExecutionContext } from './execution/ExecutionContext.js'; import type { QueryOptions } from './queries/QueryOptions.js'; @@ -10,15 +9,16 @@ import { ownMetadata } from './reflection/ownMetadata.js'; import { withGeneratedMetadata } from './reflection/registerGeneratedMetadata.js'; import type { ArtifactMetadata } from './reflection/ArtifactMetadata.js'; import type { ClassType } from './reflection/ClassType.js'; -import { encode, objectSchema } from './reflection/wireSchema.js'; +import { encode } from './reflection/wireSchema.js'; import type { NativeRequestContext } from './http/NativeRequestContext.js'; -import { validateTenancy } from './tenancy/validateTenancy.js'; +import { validateOptions, validateRegistryOptions, validateTransportOptions } from './validateOptions.js'; import { handleRequest } from './http/handleRequest.js'; import { createRouteTable } from './http/createRouteTable.js'; import { renderOpenApi } from './openApi/renderOpenApi.js'; import type { Operation } from './http/Operation.js'; -import { commandResult, queryResult } from './results/index.js'; -import { recordFailure } from './results/failureTracking.js'; +import { commandResult } from './commands/createCommandResult.js'; +import { queryResult } from './queries/createQueryResult.js'; +import { recordFailure } from './execution/failureTracking.js'; import { Severity } from './validation/Severity.js'; import { ServiceRegistry } from './dependencyInjection/ServiceRegistry.js'; import { withServices } from './dependencyInjection/ServiceScope.js'; @@ -26,14 +26,15 @@ import { requestContext } from './execution/RequestContextStore.js'; import { isObservableOperation } from './queries/observable/ObservableOperation.js'; import { CommandOperationBoundary } from './commands/CommandOperationBoundary.js'; import type { ObservableQuerySession } from './queries/observable/ObservableQuerySession.js'; -import { ObservableSessions } from './queries/ObservableSessions.js'; +import { ObservableSessions } from './queries/observable/ObservableSessions.js'; import { ObservableLimits } from './queries/observable/ObservableLimits.js'; import { ObservableQueryHub } from './queries/observable/ObservableQueryHub.js'; import type { ObservableSocket } from './queries/observable/ObservableSocket.js'; import type { ResolvedConnectionContext } from './queries/observable/ResolvedConnectionContext.js'; import { registerObservableCleanup } from './queries/observable/observableCleanupFailures.js'; -import { observe } from './observability.js'; +import { observe } from './execution/observability.js'; export function currentContext(): ExecutionContext | undefined { return requestContext.getStore(); } +enum OperationMode { Execute, Validate } export class ArcServer { readonly commands: readonly Operation[]; readonly queries: readonly Operation[]; @@ -56,47 +57,14 @@ export class ArcServer { constructor(options: ArcOptions, generatedMetadata?: ReadonlyMap) { this.#generatedMetadata = generatedMetadata; - const detailsSchema = options.identityDetails?.schema ?? (options.identityDetails?.detailsType - ? objectSchema(options.identityDetails.detailsType) : undefined); - this.options = detailsSchema && options.identityDetails ? { - ...options, identityDetails: { ...options.identityDetails, schema: detailsSchema } - } : options; - if (options.correlationId?.httpHeader !== undefined && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(options.correlationId?.httpHeader)) - throw new Error('Invalid correlation header'); - if (options.commandCompensationTimeoutMs !== undefined && - (!Number.isSafeInteger(options.commandCompensationTimeoutMs) || options.commandCompensationTimeoutMs < 1 || - options.commandCompensationTimeoutMs > 4_294_967_294)) - throw new Error('Compensation timeout must be positive and at most 4294967294 milliseconds'); - validateTenancy(options.tenancy); - if (options.nativePrincipal && options.authentication?.length) throw new Error('Native principal and Arc authentication handlers cannot be combined'); - if (options.identityDetails && (!(detailsSchema instanceof z.ZodType) || typeof options.identityDetails.provide !== 'function')) - throw new Error('Identity details require a provider schema'); - if ((options.developmentUsers || options.developmentTenants) && !options.development) throw new Error('Discovery providers require development mode'); - this.#identitySchema = detailsSchema ? z.toJSONSchema(detailsSchema) : undefined; - this.observableLimits = new ObservableLimits(options); - if (options.query?.allowedOrigins !== undefined && !Array.isArray(options.query?.allowedOrigins) && - typeof options.query?.allowedOrigins !== 'function') throw new Error('Invalid allowed Origins'); - if (Array.isArray(options.query?.allowedOrigins) && options.query?.allowedOrigins.some(origin => { - if (typeof origin !== 'string') return true; - try { - const parsed = new URL(origin); - return parsed.origin !== origin || !['http:', 'https:'].includes(parsed.protocol); - } catch { return true; } - })) throw new Error('Invalid allowed Origin'); + const validated = validateOptions(options); + this.options = validated.options; + this.#identitySchema = validated.identitySchema; + this.observableLimits = validated.observableLimits; this.#ownsServices = !(options.services instanceof ServiceRegistry); this.services = options.services instanceof ServiceRegistry ? options.services : new ServiceRegistry(options.services); - for (const token of [...options.queryRenderers ?? [], ...options.readModelInterceptors ?? []]) { - if (this.services.registration(token).lifetime === 'singleton') - throw new Error(`Query renderer or read-model interceptor ${this.services.registration(token).token.name} must not be singleton`); - } - if (options.hosting?.maxBodyBytes !== undefined && - (!Number.isSafeInteger(options.hosting.maxBodyBytes) || options.hosting.maxBodyBytes <= 0)) - throw new Error('Invalid maximum body size'); - if (options.query?.keepAliveIntervalMs !== undefined && - (!Number.isSafeInteger(options.query?.keepAliveIntervalMs) || options.query?.keepAliveIntervalMs < 0 || - options.query?.keepAliveIntervalMs > 120_000)) throw new Error('Invalid observable keep-alive interval'); - if (options.query?.enableObservableHealth !== undefined && typeof options.query?.enableObservableHealth !== 'boolean') - throw new Error('Invalid observable health option'); + validateRegistryOptions(options, this.services); + validateTransportOptions(options); const table = createRouteTable(options, context => this.#hub.observeHealth(context)); this.commands = table.commands; this.queries = table.queries; @@ -149,11 +117,15 @@ export class ArcServer { return outcome.value; } - private runScoped(operation: Operation, input: unknown, context: ExecutionContext, options?: QueryOptions, validateOnly = false): Promise { + private runScoped(operation: Operation, input: unknown, context: ExecutionContext, options?: QueryOptions, + mode = OperationMode.Execute): Promise { if (operation.kind === 'command' && CommandOperationBoundary.attempt(this)) return Promise.resolve(commandResult(context, { exceptionMessages: ['Nested commands are unsupported in command operations'] })); const run = () => this.runOwned(context, async () => { - try { return await operation.run(input, context, options, validateOnly); } + try { + if (mode === OperationMode.Validate) return await operation.validateCommand!(input, context); + return await operation.run(input, context, options); + } catch (error) { const result = operation.kind === 'command' ? commandResult(context, { exceptionMessages: [String(error)] }) @@ -168,7 +140,8 @@ export class ArcServer { recordFailure(result, error, previous); return result; }); - const name = operation.kind === 'command' ? validateOnly ? 'cratis.arc.command.validate' : 'cratis.arc.command.execute' : 'cratis.arc.query.perform'; + const name = operation.kind === 'query' ? 'cratis.arc.query.perform' : + mode === OperationMode.Validate ? 'cratis.arc.command.validate' : 'cratis.arc.command.execute'; const qualified = operation.fullyQualifiedName; const attributes = operation.kind === 'command' ? { command_type: qualified } : { query_name: qualified }; const traced = () => observe(name, context.correlationId, attributes, run, undefined, result => result.hasExceptions); @@ -221,19 +194,33 @@ export class ArcServer { return this.#hub.webSocket(request, transport, native, resolved); } - /** Execute a decorated command instance through the ordinary direct-call pipeline. */ - async execute(command: object, context: ExecutionContext, validateOnly = false): Promise { + private operationFor(command: object): Operation { const type = command.constructor; if (!ownMetadata(type as never).command) throw new Error(`Not an Arc command: ${type.name}`); const matches = this.commands.filter(item => item.name === type.name); if (matches.length !== 1) throw new Error(`Ambiguous or unregistered Arc command: ${type.name}`); - const operation = matches[0]!; - return this.executeCommand(operation.fullyQualifiedName, encode(command), context, validateOnly); + return matches[0]!; + } + + /** Execute a decorated command instance through the ordinary direct-call pipeline. */ + async execute(command: object, context: ExecutionContext): Promise { + return this.executeCommand(this.operationFor(command).fullyQualifiedName, encode(command), context); + } + /** Validate a decorated command without running provide, handle, or execution scopes. */ + async validate(command: object, context: ExecutionContext): Promise { + return this.validateCommand(this.operationFor(command).fullyQualifiedName, encode(command), context); + } + /** Execute a registered command by its fully qualified name. */ + async executeCommand(name: string, input: unknown, context: ExecutionContext): Promise { + const operation = this.#commandsByName.get(name); + if (!operation) throw new Error(`Unknown command: ${name}`); + return this.runScoped(operation, input, Object.freeze({ ...context })) as Promise; } - async executeCommand(name: string, input: unknown, context: ExecutionContext, validateOnly = false): Promise { + /** Validate a registered command by its fully qualified name, without running its handler. */ + async validateCommand(name: string, input: unknown, context: ExecutionContext): Promise { const operation = this.#commandsByName.get(name); if (!operation) throw new Error(`Unknown command: ${name}`); - return this.runScoped(operation, input, Object.freeze({ ...context }), undefined, validateOnly) as Promise; + return this.runScoped(operation, input, Object.freeze({ ...context }), undefined, OperationMode.Validate) as Promise; } async performQuery(name: string, input: unknown, context: ExecutionContext, options?: QueryOptions): Promise { const operation = this.#queriesByName.get(name); @@ -250,7 +237,9 @@ export class ArcServer { identitySchema: this.#identitySchema, hubHttp: (incoming, context) => this.#hub.http(incoming, context), runProvider: (context, callback) => this.runProvider(context, callback), - runScoped: (operation, input, context, options, validateOnly) => this.runScoped(operation, input, context, options, validateOnly), + runScoped: (operation, input, context, options) => this.runScoped(operation, input, context, options), + validateCommand: (operation, input, context) => this.runScoped(operation, input, context, undefined, + OperationMode.Validate) as Promise, openSession: (name, input, context, options, admission) => this.#sessions.openSession(name, input, context, options, admission), reserveSession: (session, context) => this.#sessions.reserveSession(session, context) }, request, native); diff --git a/Source/Core/authorization/AuthorizationPolicy.ts b/Source/Core/authorization/AuthorizationPolicy.ts index d5891a6b..3082a2fc 100644 --- a/Source/Core/authorization/AuthorizationPolicy.ts +++ b/Source/Core/authorization/AuthorizationPolicy.ts @@ -3,7 +3,7 @@ import type { ExecutionContext } from '../execution/ExecutionContext.js'; import type { Principal } from '../identity/Principal.js'; import type { ClassType } from '../reflection/ClassType.js'; -import type { DescriptorBase } from '../DescriptorBase.js'; +import type { DescriptorBase } from '../http/DescriptorBase.js'; /** The selected caller, operation and request data supplied before validation. */ export interface AuthorizationPolicyContext { diff --git a/Source/Core/authorization/authorized.ts b/Source/Core/authorization/authorized.ts index a8b526db..66d2c5c2 100644 --- a/Source/Core/authorization/authorized.ts +++ b/Source/Core/authorization/authorized.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { Authorization } from './Authorization.js'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; -import type { DescriptorBase } from '../DescriptorBase.js'; +import type { DescriptorBase } from '../http/DescriptorBase.js'; import { currentServices } from '../dependencyInjection/ServiceScope.js'; import type { AuthorizationPolicy, AuthorizationPolicyFunction, AuthorizationPolicyRegistration } from './AuthorizationPolicy.js'; import { authorizationRequirements } from './authorizationRequirements.js'; diff --git a/Source/Core/results/ArcTuple.ts b/Source/Core/commands/ArcTuple.ts similarity index 100% rename from Source/Core/results/ArcTuple.ts rename to Source/Core/commands/ArcTuple.ts diff --git a/Source/Core/commands/CommandDefinition.ts b/Source/Core/commands/CommandDefinition.ts index 41394e31..033f71f0 100644 --- a/Source/Core/commands/CommandDefinition.ts +++ b/Source/Core/commands/CommandDefinition.ts @@ -2,10 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { z } from 'zod'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; -import type { Outcome } from '../results/Outcome.js'; +import type { Outcome } from './Outcome.js'; import type { CommandExecutionScope } from './CommandExecutionScope.js'; import type { CommandFilter } from './CommandFilter.js'; -import type { DescriptorBase } from '../DescriptorBase.js'; +import type { DescriptorBase } from '../http/DescriptorBase.js'; import type { ServiceIdentifier } from '../dependencyInjection/ServiceIdentifier.js'; export interface CommandDefinition extends DescriptorBase { schema: S; diff --git a/Source/Core/commands/CommandFailureSnapshot.ts b/Source/Core/commands/CommandFailureSnapshot.ts index c5dccef2..5fc0c73f 100644 --- a/Source/Core/commands/CommandFailureSnapshot.ts +++ b/Source/Core/commands/CommandFailureSnapshot.ts @@ -2,8 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { CommandContext } from './CommandContext.js'; import type { CommandResult } from './CommandResult.js'; -import { commandResult } from '../results/commandResult.js'; -import { hasFailure, originalFailure, recordFailure } from '../results/failureTracking.js'; +import { commandResult } from './createCommandResult.js'; +import { hasFailure, originalFailure, recordFailure } from '../execution/failureTracking.js'; /** Preserve the first failure even if a completion scope mutates the result. */ export class CommandFailureSnapshot { original?: CommandResult; diff --git a/Source/Core/commands/CommandOperationDeclaration.ts b/Source/Core/commands/CommandOperation.ts similarity index 100% rename from Source/Core/commands/CommandOperationDeclaration.ts rename to Source/Core/commands/CommandOperation.ts diff --git a/Source/Core/commands/CommandOperationExecution.ts b/Source/Core/commands/CommandOperationExecution.ts index a177e472..d942a01e 100644 --- a/Source/Core/commands/CommandOperationExecution.ts +++ b/Source/Core/commands/CommandOperationExecution.ts @@ -3,7 +3,7 @@ import { currentServices } from '../dependencyInjection/ServiceScope.js'; import type { CommandContext } from './CommandContext.js'; import type { CommandCommitDisposition } from './CommandCommitDisposition.js'; -import { isCommandOperation, type CommandOperation } from './CommandOperationDeclaration.js'; +import { isCommandOperation, type CommandOperation } from './CommandOperation.js'; import { CommandOperationBoundary } from './CommandOperationBoundary.js'; import type { CommandOperationFailure } from './CommandOperationFailure.js'; import type { CommandOperationOutcome } from './CommandOperationOutcome.js'; diff --git a/Source/Core/commands/CommandOperations.ts b/Source/Core/commands/CommandOperations.ts index 323ba49b..fe1ab6f6 100644 --- a/Source/Core/commands/CommandOperations.ts +++ b/Source/Core/commands/CommandOperations.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 { CommandOperation } from './CommandOperationDeclaration.js'; +import type { CommandOperation } from './CommandOperation.js'; const brand = Symbol.for('@cratis/arc.core/command-operations'); /** Immutable, explicit batch of server-only operation declarations. */ export class CommandOperations { diff --git a/Source/Core/commands/CommandResponseValueHandler.ts b/Source/Core/commands/CommandResponseValueHandler.ts index 8a1388fe..66bb9e63 100644 --- a/Source/Core/commands/CommandResponseValueHandler.ts +++ b/Source/Core/commands/CommandResponseValueHandler.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { CommandContext } from './CommandContext.js'; -import type { Outcome } from '../results/Outcome.js'; +import type { Outcome } from './Outcome.js'; /** Consume a server-only command return value. Every matching handler processes the value. */ export interface CommandResponseValueHandler { /** Reject an operation journal before any response handlers stage irreversible effects. */ diff --git a/Source/Core/results/Outcome.ts b/Source/Core/commands/Outcome.ts similarity index 100% rename from Source/Core/results/Outcome.ts rename to Source/Core/commands/Outcome.ts diff --git a/Source/Core/results/commandResult.ts b/Source/Core/commands/createCommandResult.ts similarity index 94% rename from Source/Core/results/commandResult.ts rename to Source/Core/commands/createCommandResult.ts index 8c6d9b55..106ea36a 100644 --- a/Source/Core/results/commandResult.ts +++ b/Source/Core/commands/createCommandResult.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 { CommandResult } from '../commands/CommandResult.js'; +import type { CommandResult } from './CommandResult.js'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; export function commandResult(context: ExecutionContext, values: Partial> = {}): CommandResult { diff --git a/Source/Core/results/for_Outcome/when_identifying_branded_outcomes.ts b/Source/Core/commands/for_Outcome/when_identifying_branded_outcomes.ts similarity index 100% rename from Source/Core/results/for_Outcome/when_identifying_branded_outcomes.ts rename to Source/Core/commands/for_Outcome/when_identifying_branded_outcomes.ts diff --git a/Source/Core/results/for_Outcome/when_identifying_unbranded_values.ts b/Source/Core/commands/for_Outcome/when_identifying_unbranded_values.ts similarity index 100% rename from Source/Core/results/for_Outcome/when_identifying_unbranded_values.ts rename to Source/Core/commands/for_Outcome/when_identifying_unbranded_values.ts diff --git a/Source/Core/results/for_tuple/when_grouping_values.ts b/Source/Core/commands/for_tuple/when_grouping_values.ts similarity index 100% rename from Source/Core/results/for_tuple/when_grouping_values.ts rename to Source/Core/commands/for_tuple/when_grouping_values.ts diff --git a/Source/Core/commands/index.ts b/Source/Core/commands/index.ts index a309ce65..0e65a911 100644 --- a/Source/Core/commands/index.ts +++ b/Source/Core/commands/index.ts @@ -13,16 +13,19 @@ export type { CommandKeyResolver, CanProvideKeyForCommand } from './CommandKeyRe export { DefaultKeyForCommandResolver } from './DefaultKeyForCommandResolver.js'; export type { CommandResponseValueHandler } from './CommandResponseValueHandler.js'; export { commandResponseValueHandler } from './responseValueHandler.js'; -export { CommandOperation } from './CommandOperationDeclaration.js'; +export { CommandOperation } from './CommandOperation.js'; export { CommandOperations, operations } from './CommandOperations.js'; export type { CommandCommitDisposition } from './CommandCommitDisposition.js'; export type { CommandOperationExecutionScope } from './CommandExecutionScope.js'; export type { CommandOperationFailure } from './CommandOperationFailure.js'; export type { CommandOperationOutcome } from './CommandOperationOutcome.js'; export type { CommandRecoverySummary } from './CommandRecoverySummary.js'; -export { abortSignal, commandContext } from './modelBound/commandArgument.js'; +export { abortSignal, commandContext } from './modelBound/commandContextArgument.js'; export { provided } from './modelBound/provided.js'; -export { commandReadModel } from './modelBound/readModel.js'; -export { readModelForValidation } from '../validation/readModelForValidation.js'; -export { commandArgument } from './modelBound/commandContextArgument.js'; +export { commandReadModel } from './modelBound/commandReadModel.js'; +export { commandArgument } from './modelBound/commandArgument.js'; +export { response, rejected, denied, isOutcome } from './Outcome.js'; +export { tuple } from './tuple.js'; +export type { ArcTuple } from './ArcTuple.js'; +export type { Outcome } from './Outcome.js'; export type { ReadModelForCommandResolver } from './ReadModelForCommandResolver.js'; diff --git a/Source/Core/commands/modelBound/PreparedValue.ts b/Source/Core/commands/modelBound/PreparedValue.ts index 2d795415..cd8cfa53 100644 --- a/Source/Core/commands/modelBound/PreparedValue.ts +++ b/Source/Core/commands/modelBound/PreparedValue.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 { Outcome } from '../../results/Outcome.js'; +import type { Outcome } from '../Outcome.js'; /** Exclude short-circuit outcomes from the preparation value passed into handle. */ export type PreparedValue = T extends Outcome ? T extends { kind: 'response'; value: infer Value } ? Value : never : T; diff --git a/Source/Core/commands/modelBound/commandArgument.ts b/Source/Core/commands/modelBound/commandArgument.ts index bccaec80..87af0834 100644 --- a/Source/Core/commands/modelBound/commandArgument.ts +++ b/Source/Core/commands/modelBound/commandArgument.ts @@ -1,65 +1,16 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; -import type { ServiceIdentifier } from '../../dependencyInjection/ServiceIdentifier.js'; -import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import type { ServiceToken } from '../../dependencyInjection/ServiceToken.js'; import type { CommandContext } from '../CommandContext.js'; -import { flattenCommandResponse } from '../processCommandResponse.js'; -import { providedType } from './provided.js'; -import { optionalServiceType } from '../../reflection/optionalService.js'; -import { readModelArgument } from './readModel.js'; -import { ReadModelForCommandError } from '../ReadModelForCommandError.js'; -import { contextArgumentResolver } from './commandContextArgument.js'; -const signalToken = serviceToken('Arc command signal'); -const contextToken = serviceToken('Arc command context'); -const readModels = new WeakMap>(); -/** Explicit AbortSignal parameter marker for @inject on handle() or provide(). */ -export function abortSignal(): typeof signalToken { return signalToken; } -/** Explicit CommandContext parameter marker for @inject on handle() or provide(). */ -export function commandContext(): typeof contextToken { return contextToken; } -/** Resolve service tokens alongside built-in command arguments. */ -export async function resolveCommandArguments(tokens: readonly ServiceIdentifier[], command: CommandContext, - provided: unknown = undefined): Promise { - const candidates = flattenCommandResponse(provided); - const values: unknown[] = []; - for (const token of tokens) { - if (token === signalToken) { values.push(command.signal); continue; } - if (token === contextToken) { values.push(command); continue; } - const contextResolver = contextArgumentResolver(token); - if (contextResolver) { values.push(await contextResolver(command)); continue; } - const model = readModelArgument(token); - if (model) { - const resolvers = await Promise.all((command.readModelResolvers ?? []).map(item => currentServices().resolve(item))); - const matching = resolvers.filter(resolver => resolver.supports(model.type)); - if (matching.length !== 1) throw new Error(`Expected one read-model resolver for ${model.type.name}, found ${matching.length}`); - if (!command.key?.trim()) throw new ReadModelForCommandError(`A command key is required for ${model.type.name}`); - let cached = readModels.get(command); - if (!cached) { cached = new Map(); readModels.set(command, cached); } - if (!cached.has(model.type)) cached.set(model.type, await matching[0]!.find(model.type, command.key, command)); - const found = cached.get(model.type); - if (found === undefined) throw new Error(`Read-model resolver returned no outcome for ${model.type.name}`); - if (found === null && !model.optional) throw new ReadModelForCommandError(`${model.type.name} was not found for the command key`); - values.push(found); - continue; - } - const optional = optionalServiceType(token); - if (optional) { - const scope = currentServices(); - values.push(scope.registry.hasRegistration(optional) ? await scope.resolve(optional) : null); - continue; - } - const type = providedType(token); - if (!type) { values.push(await currentServices().resolve(token)); continue; } - const index = candidates.findIndex(candidate => candidate instanceof type || - type === String && typeof candidate === 'string' || type === Number && typeof candidate === 'number' || - type === Boolean && typeof candidate === 'boolean'); - if (index < 0) throw new Error(`No provided value matches ${type.name}`); - values.push(candidates.splice(index, 1)[0]); - } - return values; + +const resolvers = new WeakMap unknown | Promise>(); +/** Bind an integration-owned handle/provide parameter to the current command context. */ +export function commandArgument(name: string, resolve: (context: CommandContext) => T | Promise): ServiceToken { + const token = serviceToken(name); + resolvers.set(token, resolve); + return token; } -/** Service tokens alone participate in DI preflight. */ -export function commandServiceTokens(tokens: readonly ServiceIdentifier[]): ServiceIdentifier[] { - return tokens.filter(token => token !== signalToken && token !== contextToken && !providedType(token) && - !readModelArgument(token) && !contextArgumentResolver(token) && !optionalServiceType(token)); +export function contextArgumentResolver(token: object): ((context: CommandContext) => unknown | Promise) | undefined { + return resolvers.get(token); } diff --git a/Source/Core/commands/modelBound/commandContextArgument.ts b/Source/Core/commands/modelBound/commandContextArgument.ts index 87af0834..a288f591 100644 --- a/Source/Core/commands/modelBound/commandContextArgument.ts +++ b/Source/Core/commands/modelBound/commandContextArgument.ts @@ -1,16 +1,65 @@ // 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 { ServiceToken } from '../../dependencyInjection/ServiceToken.js'; +import type { ServiceIdentifier } from '../../dependencyInjection/ServiceIdentifier.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; import type { CommandContext } from '../CommandContext.js'; - -const resolvers = new WeakMap unknown | Promise>(); -/** Bind an integration-owned handle/provide parameter to the current command context. */ -export function commandArgument(name: string, resolve: (context: CommandContext) => T | Promise): ServiceToken { - const token = serviceToken(name); - resolvers.set(token, resolve); - return token; +import { flattenCommandResponse } from '../processCommandResponse.js'; +import { providedType } from './provided.js'; +import { optionalServiceType } from '../../dependencyInjection/optionalService.js'; +import { readModelArgument } from './commandReadModel.js'; +import { ReadModelForCommandError } from '../ReadModelForCommandError.js'; +import { contextArgumentResolver } from './commandArgument.js'; +const signalToken = serviceToken('Arc command signal'); +const contextToken = serviceToken('Arc command context'); +const readModels = new WeakMap>(); +/** Explicit AbortSignal parameter marker for @inject on handle() or provide(). */ +export function abortSignal(): typeof signalToken { return signalToken; } +/** Explicit CommandContext parameter marker for @inject on handle() or provide(). */ +export function commandContext(): typeof contextToken { return contextToken; } +/** Resolve service tokens alongside built-in command arguments. */ +export async function resolveCommandArguments(tokens: readonly ServiceIdentifier[], command: CommandContext, + provided: unknown = undefined): Promise { + const candidates = flattenCommandResponse(provided); + const values: unknown[] = []; + for (const token of tokens) { + if (token === signalToken) { values.push(command.signal); continue; } + if (token === contextToken) { values.push(command); continue; } + const contextResolver = contextArgumentResolver(token); + if (contextResolver) { values.push(await contextResolver(command)); continue; } + const model = readModelArgument(token); + if (model) { + const resolvers = await Promise.all((command.readModelResolvers ?? []).map(item => currentServices().resolve(item))); + const matching = resolvers.filter(resolver => resolver.supports(model.type)); + if (matching.length !== 1) throw new Error(`Expected one read-model resolver for ${model.type.name}, found ${matching.length}`); + if (!command.key?.trim()) throw new ReadModelForCommandError(`A command key is required for ${model.type.name}`); + let cached = readModels.get(command); + if (!cached) { cached = new Map(); readModels.set(command, cached); } + if (!cached.has(model.type)) cached.set(model.type, await matching[0]!.find(model.type, command.key, command)); + const found = cached.get(model.type); + if (found === undefined) throw new Error(`Read-model resolver returned no outcome for ${model.type.name}`); + if (found === null && !model.optional) throw new ReadModelForCommandError(`${model.type.name} was not found for the command key`); + values.push(found); + continue; + } + const optional = optionalServiceType(token); + if (optional) { + const scope = currentServices(); + values.push(scope.registry.hasRegistration(optional) ? await scope.resolve(optional) : null); + continue; + } + const type = providedType(token); + if (!type) { values.push(await currentServices().resolve(token)); continue; } + const index = candidates.findIndex(candidate => candidate instanceof type || + type === String && typeof candidate === 'string' || type === Number && typeof candidate === 'number' || + type === Boolean && typeof candidate === 'boolean'); + if (index < 0) throw new Error(`No provided value matches ${type.name}`); + values.push(candidates.splice(index, 1)[0]); + } + return values; } -export function contextArgumentResolver(token: object): ((context: CommandContext) => unknown | Promise) | undefined { - return resolvers.get(token); +/** Service tokens alone participate in DI preflight. */ +export function commandServiceTokens(tokens: readonly ServiceIdentifier[]): ServiceIdentifier[] { + return tokens.filter(token => token !== signalToken && token !== contextToken && !providedType(token) && + !readModelArgument(token) && !contextArgumentResolver(token) && !optionalServiceType(token)); } diff --git a/Source/Core/commands/modelBound/readModel.ts b/Source/Core/commands/modelBound/commandReadModel.ts similarity index 100% rename from Source/Core/commands/modelBound/readModel.ts rename to Source/Core/commands/modelBound/commandReadModel.ts diff --git a/Source/Core/commands/modelBound/compileCommand.ts b/Source/Core/commands/modelBound/compileCommand.ts index 0200eb48..98914728 100644 --- a/Source/Core/commands/modelBound/compileCommand.ts +++ b/Source/Core/commands/modelBound/compileCommand.ts @@ -5,9 +5,9 @@ import { Fields } from '@cratis/fundamentals'; import type { CommandContext } from '../CommandContext.js'; import { keyFieldFor } from '../../reflection/key.js'; import type { CommandDefinition } from '../CommandDefinition.js'; -import { isOutcome } from '../../results/Outcome.js'; +import { isOutcome } from '../Outcome.js'; import { reflectedParameters } from '../../reflection/reflectedParameters.js'; -import { commandServiceTokens, resolveCommandArguments } from './commandArgument.js'; +import { commandServiceTokens, resolveCommandArguments } from './commandContextArgument.js'; import { encodeCommandResponse } from './encodeCommandResponse.js'; import { providedType } from './provided.js'; import { ownMetadata } from '../../reflection/ownMetadata.js'; diff --git a/Source/Core/commands/modelBound/encodeCommandResponse.ts b/Source/Core/commands/modelBound/encodeCommandResponse.ts index d19dd6af..f8e30d51 100644 --- a/Source/Core/commands/modelBound/encodeCommandResponse.ts +++ b/Source/Core/commands/modelBound/encodeCommandResponse.ts @@ -1,10 +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 { isArcTuple } from '../../results/ArcTuple.js'; -import { tuple } from '../../results/tuple.js'; -import { isOutcome, response } from '../../results/Outcome.js'; +import { isArcTuple } from '../ArcTuple.js'; +import { tuple } from '../tuple.js'; +import { isOutcome, response } from '../Outcome.js'; import { encode } from '../../reflection/wireSchema.js'; -import { CommandOperation } from '../CommandOperationDeclaration.js'; +import { CommandOperation } from '../CommandOperation.js'; import { isCommandOperations } from '../CommandOperations.js'; /** Preserve server-only declarations and branded return branches until the pipeline classifies them. */ export function encodeCommandResponse(value: unknown): unknown { diff --git a/Source/Core/commands/prepareCommandResponse.ts b/Source/Core/commands/prepareCommandResponse.ts index 394e67cc..81f474fd 100644 --- a/Source/Core/commands/prepareCommandResponse.ts +++ b/Source/Core/commands/prepareCommandResponse.ts @@ -4,7 +4,7 @@ import type { ArcOptions } from '../ArcOptions.js'; import { currentServices } from '../dependencyInjection/ServiceScope.js'; import type { CommandContext } from './CommandContext.js'; import type { CommandExecutionScope } from './CommandExecutionScope.js'; -import { isCommandOperation } from './CommandOperationDeclaration.js'; +import { isCommandOperation } from './CommandOperation.js'; import { isCommandOperations } from './CommandOperations.js'; import { CommandOperationBoundary } from './CommandOperationBoundary.js'; import { CommandOperationExecution } from './CommandOperationExecution.js'; diff --git a/Source/Core/commands/processCommandResponse.ts b/Source/Core/commands/processCommandResponse.ts index 57f1f0f2..27c78ce6 100644 --- a/Source/Core/commands/processCommandResponse.ts +++ b/Source/Core/commands/processCommandResponse.ts @@ -1,11 +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 { isArcTuple } from '../results/ArcTuple.js'; -import { isOutcome, type Outcome } from '../results/Outcome.js'; -import { commandResult } from '../results/commandResult.js'; +import { isArcTuple } from './ArcTuple.js'; +import { isOutcome, type Outcome } from './Outcome.js'; +import { commandResult } from './createCommandResult.js'; import type { CommandResult } from './CommandResult.js'; import type { CommandContext } from './CommandContext.js'; -import { isCommandOperation } from './CommandOperationDeclaration.js'; +import { isCommandOperation } from './CommandOperation.js'; import { isCommandOperations } from './CommandOperations.js'; import type { CommandResponseValueHandler } from './CommandResponseValueHandler.js'; diff --git a/Source/Core/commands/commandOperation.ts b/Source/Core/commands/runCommandOperation.ts similarity index 92% rename from Source/Core/commands/commandOperation.ts rename to Source/Core/commands/runCommandOperation.ts index 823cfe60..5f7f6aa8 100644 --- a/Source/Core/commands/commandOperation.ts +++ b/Source/Core/commands/runCommandOperation.ts @@ -2,16 +2,17 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { z } from 'zod'; import type { ArcOptions } from '../ArcOptions.js'; +import type { ExecutionContext } from '../execution/ExecutionContext.js'; import type { CommandDefinition } from './CommandDefinition.js'; import type { CommandResult } from './CommandResult.js'; import type { ValidationResult } from '../validation/ValidationResult.js'; -import { isOutcome } from '../results/Outcome.js'; +import { isOutcome } from './Outcome.js'; import { authorized } from '../authorization/authorized.js'; -import { commandResult } from '../results/commandResult.js'; -import { malformed } from '../results/malformed.js'; +import { commandResult } from './createCommandResult.js'; +import { malformed } from '../http/malformed.js'; import type { Operation } from '../http/Operation.js'; import { fullyQualifiedName } from '../http/fullyQualifiedName.js'; -import { recordFailure } from '../results/failureTracking.js'; +import { recordFailure } from '../execution/failureTracking.js'; import { CommandFailureSnapshot } from './CommandFailureSnapshot.js'; import { ServiceDependencyError } from '../dependencyInjection/ServiceDependencyError.js'; import { ReadModelForCommandError } from './ReadModelForCommandError.js'; @@ -25,7 +26,7 @@ import { setCommandRecovery } from './commandRecovery.js'; import type { CommandExecutionScope, CommandOperationExecutionScope } from './CommandExecutionScope.js'; import type { CommandCommitDisposition } from './CommandCommitDisposition.js'; import type { CommandContext } from './CommandContext.js'; -import { observe } from '../observability.js'; +import { observe } from '../execution/observability.js'; function disposition(scopes: readonly CommandExecutionScope[], context: CommandContext): CommandCommitDisposition { const participants = scopes.filter((scope): scope is CommandOperationExecutionScope => @@ -46,13 +47,11 @@ function failure(context: CommandContext, error: unknown, previous?: CommandResu recordFailure(result, error, previous); return result; } +enum CommandOperationMode { Execute, Validate } + /** Compile a command into the shared direct and HTTP execution pipeline. */ export function commandOperation(definition: CommandDefinition, route: string, options: ArcOptions = {}): Operation { - return { - ...definition, kind: 'command', route, fullyQualifiedName: fullyQualifiedName(definition), - dynamicAuthorization: typeof definition.authorize === 'function', - inputSchema: definition.wireInputSchema ?? z.toJSONSchema(definition.schema), - async run(input, execution, _options, validateOnly): Promise { + const invoke = async (input: unknown, execution: ExecutionContext, mode: CommandOperationMode): Promise => { if (!await authorized(definition.authorization, execution, options.authorizationPolicies ?? {}, definition, input)) return commandResult(execution, { isAuthorized: false }); const parsed = definition.schema.safeParse(input); if (!parsed.success) return commandResult(execution, { validationResults: malformed(execution) }); @@ -78,7 +77,7 @@ export function commandOperation(definition: CommandDefi return result; } if (issues.length) return commandResult(context, { validationResults: issues }); - if (validateOnly) return commandResult(context); + if (mode === CommandOperationMode.Validate) return commandResult(context); const scopes: CommandExecutionScope[] = []; let result: CommandResult = commandResult(context); let journal: CommandOperationExecution | undefined; @@ -157,6 +156,12 @@ export function commandOperation(definition: CommandDefi } catch (error) { return failure(context, error); } }; return options.commandExecutionRunner ? options.commandExecutionRunner(context, execute) : execute(); - } + }; + return { + ...definition, kind: 'command', route, fullyQualifiedName: fullyQualifiedName(definition), + dynamicAuthorization: typeof definition.authorize === 'function', + inputSchema: definition.wireInputSchema ?? z.toJSONSchema(definition.schema), + run: (input, execution) => invoke(input, execution, CommandOperationMode.Execute), + validateCommand: (input, execution) => invoke(input, execution, CommandOperationMode.Validate) }; } diff --git a/Source/Core/results/tuple.ts b/Source/Core/commands/tuple.ts similarity index 100% rename from Source/Core/results/tuple.ts rename to Source/Core/commands/tuple.ts diff --git a/Source/Core/ArcApplicationServices.ts b/Source/Core/dependencyInjection/ArcApplicationServices.ts similarity index 87% rename from Source/Core/ArcApplicationServices.ts rename to Source/Core/dependencyInjection/ArcApplicationServices.ts index 77546930..3768faf6 100644 --- a/Source/Core/ArcApplicationServices.ts +++ b/Source/Core/dependencyInjection/ArcApplicationServices.ts @@ -1,10 +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 type { ServiceRegistration } from './dependencyInjection/ServiceRegistration.js'; -import type { ServiceIdentifier, ServiceClass } from './dependencyInjection/ServiceIdentifier.js'; -import { reflectedParameters } from './reflection/reflectedParameters.js'; -import { ownMetadata } from './reflection/ownMetadata.js'; -import type { ServiceScope } from './dependencyInjection/ServiceScope.js'; +import type { ServiceRegistration } from './ServiceRegistration.js'; +import type { ServiceIdentifier, ServiceClass } from './ServiceIdentifier.js'; +import { reflectedParameters } from '../reflection/reflectedParameters.js'; +import { ownMetadata } from '../reflection/ownMetadata.js'; +import type { ServiceScope } from './ServiceScope.js'; /** Collect class and factory registrations for a built application. */ export class ArcApplicationServices { diff --git a/Source/Core/dependencyInjection/ServiceRegistry.ts b/Source/Core/dependencyInjection/ServiceRegistry.ts index 737809b7..03a55851 100644 --- a/Source/Core/dependencyInjection/ServiceRegistry.ts +++ b/Source/Core/dependencyInjection/ServiceRegistry.ts @@ -50,7 +50,9 @@ export class ServiceRegistry { } get disposed(): boolean { return this.#state !== ServiceRegistryState.Running; } get singletonFailed(): boolean { return this.#singletonFailed; } + /** @internal Registry-lifetime context for singleton factories. */ get singletonContext(): SingletonServiceContext { return this.#singletonContext; } + /** @internal Poison the registry after a singleton factory failure. */ markSingletonFailure(): void { if (this.#singletonFailed) return; this.#singletonFailed = true; @@ -119,6 +121,7 @@ export class ServiceRegistry { hasRegistration(identifier: ServiceIdentifier): boolean { return this.#registrations.has(normalizeServiceToken(identifier).key); } + /** @internal Retrieve a declaration without constructing its service. */ registration(identifier: ServiceIdentifier): ServiceRegistration { const token = normalizeServiceToken(identifier); if (!token || typeof token.key !== 'symbol' || typeof token.name !== 'string') diff --git a/Source/Core/dependencyInjection/for_ServiceRegistry/when_publishing_a_result_during_singleton_failure/with_a_failing_disposer.ts b/Source/Core/dependencyInjection/for_ServiceRegistry/when_publishing_a_result_during_singleton_failure/with_a_failing_disposer.ts index f8732805..97d6729c 100644 --- a/Source/Core/dependencyInjection/for_ServiceRegistry/when_publishing_a_result_during_singleton_failure/with_a_failing_disposer.ts +++ b/Source/Core/dependencyInjection/for_ServiceRegistry/when_publishing_a_result_during_singleton_failure/with_a_failing_disposer.ts @@ -6,7 +6,7 @@ import { ArcServer } from '../../../ArcServer.js'; import { currentServices } from '../../ServiceScope.js'; import { serviceToken } from '../../ServiceToken.js'; import { defineQuery } from '../../../queries/defineQuery.js'; -import { originalFailure } from '../../../results/failureTracking.js'; +import { originalFailure } from '../../../execution/failureTracking.js'; import { beforeDeadline, captureFailure, gate, serviceContext } from '../given/a_service_lifecycle.js'; should(); diff --git a/Source/Core/reflection/optionalService.ts b/Source/Core/dependencyInjection/optionalService.ts similarity index 81% rename from Source/Core/reflection/optionalService.ts rename to Source/Core/dependencyInjection/optionalService.ts index c8967743..8cc71281 100644 --- a/Source/Core/reflection/optionalService.ts +++ b/Source/Core/dependencyInjection/optionalService.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { serviceToken, type ServiceToken } from '../dependencyInjection/ServiceToken.js'; -import type { ServiceIdentifier } from '../dependencyInjection/ServiceIdentifier.js'; +import { serviceToken, type ServiceToken } from './ServiceToken.js'; +import type { ServiceIdentifier } from './ServiceIdentifier.js'; const optional = new WeakMap>(); /** Bind a nullable service to null when it is not registered. */ diff --git a/Source/Core/results/failureTracking.ts b/Source/Core/execution/failureTracking.ts similarity index 100% rename from Source/Core/results/failureTracking.ts rename to Source/Core/execution/failureTracking.ts diff --git a/Source/Core/observability.ts b/Source/Core/execution/observability.ts similarity index 100% rename from Source/Core/observability.ts rename to Source/Core/execution/observability.ts diff --git a/Source/Core/fetch.ts b/Source/Core/fetch.ts index 3b26a8f7..c3ca5e14 100644 --- a/Source/Core/fetch.ts +++ b/Source/Core/fetch.ts @@ -4,13 +4,16 @@ export { ArcServer, currentContext } from './ArcServer.js'; export { FetchArcApplication as ArcApplication } from './FetchArcApplication.js'; export { ArcApplicationBuilder } from './ArcApplicationBuilder.js'; +/** Integration packages augment this interface on the portable builder. */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ArcBuilderExtensions {} export type { ArcBuilderIntegrationOptions } from './ArcBuilderIntegrationOptions.js'; export { canonicalMetadataSignature } from './reflection/generatedMetadataSignature.js'; -export { optionalService } from './reflection/optionalService.js'; +export { optionalService } from './dependencyInjection/optionalService.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'; -export { ArcApplicationServices } from './ArcApplicationServices.js'; +export { ArcApplicationServices } from './dependencyInjection/ArcApplicationServices.js'; export { key } from './reflection/key.js'; export { fieldsFor, wireName } from './reflection/wireSchema.js'; export type { ClassType } from './reflection/ClassType.js'; @@ -20,7 +23,7 @@ export { nullable } from './reflection/nullable.js'; export { defaultValue } from './reflection/defaultValue.js'; export { enumeration } from './reflection/enumeration.js'; export type { ArcOptions } from './ArcOptions.js'; -export type { DescriptorBase } from './DescriptorBase.js'; +export type { DescriptorBase } from './http/DescriptorBase.js'; export * from './commands/index.js'; export * from './queries/index.js'; export * from './validation/index.js'; @@ -34,4 +37,3 @@ export { routeFor, includeRouteName } from './http/createRouteTable.js'; export type { NativeRequestContext } from './http/NativeRequestContext.js'; export type { Operation } from './http/Operation.js'; export * from './introspection/index.js'; -export * from './results/index.js'; diff --git a/Source/Core/for_ArcApplicationBuilder/when_validating_an_instance.ts b/Source/Core/for_ArcApplicationBuilder/when_validating_an_instance.ts new file mode 100644 index 00000000..9a4eada2 --- /dev/null +++ b/Source/Core/for_ArcApplicationBuilder/when_validating_an_instance.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { randomUUID } from 'node:crypto'; +import { Severity } from '../validation/Severity.js'; +import { RegisterItem } from './given/RegisterItem.js'; +import { ItemName } from './given/ItemName.js'; +import { Items } from './given/Items.js'; +import type { CommandResult } from '../commands/CommandResult.js'; +import { ArcApplication } from '../ArcApplication.js'; + +describe('when validating a decorated command instance directly', () => { + let result: CommandResult; + let items: Items; + beforeEach(async () => { + items = new Items(); + const builder = ArcApplication.createBuilder({ configuration: false }); + builder.services.addSingleton(Items, () => items); + builder.add(RegisterItem); + const application = await builder.build(); + try { + result = await application.server.validate(Object.assign(new RegisterItem(), { name: new ItemName('direct') }), { + correlationId: randomUUID(), signal: new AbortController().signal, allowedSeverity: Severity.Warning, + principal: undefined, tenantId: undefined + }); + } finally { await application.dispose(); } + }); + it('should return a successful validation result without a response', () => { + result.isSuccess.should.equal(true); + (result.response === undefined).should.equal(true); + }); + it('should not invoke the handler', () => { items.values.should.have.lengthOf(0); }); +}); diff --git a/Source/Core/for_ArcServer/given/an_operation_command.ts b/Source/Core/for_ArcServer/given/an_operation_command.ts index d9cd257e..e8e4cd7e 100644 --- a/Source/Core/for_ArcServer/given/an_operation_command.ts +++ b/Source/Core/for_ArcServer/given/an_operation_command.ts @@ -3,11 +3,11 @@ import { z } from 'zod'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; -import { CommandOperation } from '../../commands/CommandOperationDeclaration.js'; +import { CommandOperation } from '../../commands/CommandOperation.js'; import type { CommandCommitDisposition } from '../../commands/CommandCommitDisposition.js'; import type { CommandContext } from '../../commands/CommandContext.js'; import type { CommandResult } from '../../commands/CommandResult.js'; -import { tuple } from '../../results/tuple.js'; +import { tuple } from '../../commands/tuple.js'; import type { ExecutionContext } from '../../execution/ExecutionContext.js'; export class ProbeOperation extends CommandOperation { diff --git a/Source/Core/for_ArcServer/when_an_operation_calls_a_nested_command/with_ignored_failure.ts b/Source/Core/for_ArcServer/when_an_operation_calls_a_nested_command/with_ignored_failure.ts index 696ada74..46718ab6 100644 --- a/Source/Core/for_ArcServer/when_an_operation_calls_a_nested_command/with_ignored_failure.ts +++ b/Source/Core/for_ArcServer/when_an_operation_calls_a_nested_command/with_ignored_failure.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { given } from '../../given.js'; import { an_operation_command } from '../given/an_operation_command.js'; -import { CommandOperation } from '../../commands/CommandOperationDeclaration.js'; +import { CommandOperation } from '../../commands/CommandOperation.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); describe('when an operation calls a nested command with ignored failure', given(an_operation_command, context => { diff --git a/Source/Core/for_ArcServer/when_an_operation_fails/with_a_committed_or_failing_compensation.ts b/Source/Core/for_ArcServer/when_an_operation_fails/with_a_committed_or_failing_compensation.ts index 8afda80c..79624620 100644 --- a/Source/Core/for_ArcServer/when_an_operation_fails/with_a_committed_or_failing_compensation.ts +++ b/Source/Core/for_ArcServer/when_an_operation_fails/with_a_committed_or_failing_compensation.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, it, should } from 'vitest'; import { given } from '../../given.js'; import { an_operation_command, ProbeOperation } from '../given/an_operation_command.js'; -import { CommandOperation } from '../../commands/CommandOperationDeclaration.js'; -import { tuple } from '../../results/tuple.js'; +import { CommandOperation } from '../../commands/CommandOperation.js'; +import { tuple } from '../../commands/tuple.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); describe('when a scope commits before recovery', given(an_operation_command, context => { diff --git a/Source/Core/for_ArcServer/when_an_operation_fails/with_a_later_scope_failure.ts b/Source/Core/for_ArcServer/when_an_operation_fails/with_a_later_scope_failure.ts index 7c1173eb..aafeb272 100644 --- a/Source/Core/for_ArcServer/when_an_operation_fails/with_a_later_scope_failure.ts +++ b/Source/Core/for_ArcServer/when_an_operation_fails/with_a_later_scope_failure.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { given } from '../../given.js'; import { an_operation_command } from '../given/an_operation_command.js'; -import { CommandOperation } from '../../commands/CommandOperationDeclaration.js'; +import { CommandOperation } from '../../commands/CommandOperation.js'; import type { CommandOperationFailure } from '../../commands/CommandOperationFailure.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); diff --git a/Source/Core/for_ArcServer/when_an_operation_fails/with_cancellation_or_budget_expiry.ts b/Source/Core/for_ArcServer/when_an_operation_fails/with_cancellation_or_budget_expiry.ts index 5959ae16..40c34fa1 100644 --- a/Source/Core/for_ArcServer/when_an_operation_fails/with_cancellation_or_budget_expiry.ts +++ b/Source/Core/for_ArcServer/when_an_operation_fails/with_cancellation_or_budget_expiry.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { z } from 'zod'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; -import { CommandOperation } from '../../commands/CommandOperationDeclaration.js'; +import { CommandOperation } from '../../commands/CommandOperation.js'; import type { CommandOperationFailure } from '../../commands/CommandOperationFailure.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); diff --git a/Source/Core/for_ArcServer/when_declaring_nested_operations/with_a_child_or_parent_declaration.ts b/Source/Core/for_ArcServer/when_declaring_nested_operations/with_a_child_or_parent_declaration.ts index c8aef710..2b76403c 100644 --- a/Source/Core/for_ArcServer/when_declaring_nested_operations/with_a_child_or_parent_declaration.ts +++ b/Source/Core/for_ArcServer/when_declaring_nested_operations/with_a_child_or_parent_declaration.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { given } from '../../given.js'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; -import { CommandOperation } from '../../commands/CommandOperationDeclaration.js'; +import { CommandOperation } from '../../commands/CommandOperation.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); class Effect extends CommandOperation { diff --git a/Source/Core/for_ArcServer/when_declaring_operations/with_invalid_configuration.ts b/Source/Core/for_ArcServer/when_declaring_operations/with_invalid_configuration.ts index b656bf33..c10f62c9 100644 --- a/Source/Core/for_ArcServer/when_declaring_operations/with_invalid_configuration.ts +++ b/Source/Core/for_ArcServer/when_declaring_operations/with_invalid_configuration.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { z } from 'zod'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; -import { CommandOperation } from '../../commands/CommandOperationDeclaration.js'; +import { CommandOperation } from '../../commands/CommandOperation.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); class Effect extends CommandOperation { execute(signal: AbortSignal): void { void signal; } } diff --git a/Source/Core/for_ArcServer/when_handling_a_validate_named_command/with_denied_and_validation_only_requests.ts b/Source/Core/for_ArcServer/when_handling_a_validate_named_command/with_denied_and_validation_only_requests.ts index 7a18a273..078d4242 100644 --- a/Source/Core/for_ArcServer/when_handling_a_validate_named_command/with_denied_and_validation_only_requests.ts +++ b/Source/Core/for_ArcServer/when_handling_a_validate_named_command/with_denied_and_validation_only_requests.ts @@ -23,8 +23,8 @@ describe('when handling a validate named command with denied and validation only const url = 'http://localhost/api/submit/validate'; deniedStatus = (await server.handle(new Request(url, { method: 'POST', body: '{}' })))?.status; afterDenial = [...events]; - validationSuccess = (await server.executeCommand('Submit', {}, { ...serviceContext('alpha'), - principal: { id: 'a', isAuthenticated: true, roles: [] } }, true)).isSuccess; + validationSuccess = (await server.validateCommand('Submit', {}, { ...serviceContext('alpha'), + principal: { id: 'a', isAuthenticated: true, roles: [] } })).isSuccess; afterValidation = [...events]; const http = new ArcServer({ services: [{ token: handler, lifetime: 'scoped', factory: () => { events.push('http factory'); return {}; } }], commands: [defineCommand({ name: 'Submit', schema: z.object({}), handlerDependencies: [handler], handle: () => { events.push('http handle'); return 1; } })] }); diff --git a/Source/Core/for_ArcServer/when_preflighting_a_command/with_handler_dependencies.ts b/Source/Core/for_ArcServer/when_preflighting_a_command/with_handler_dependencies.ts index 02d56778..4de66d8e 100644 --- a/Source/Core/for_ArcServer/when_preflighting_a_command/with_handler_dependencies.ts +++ b/Source/Core/for_ArcServer/when_preflighting_a_command/with_handler_dependencies.ts @@ -22,7 +22,7 @@ describe('when preflighting a command with handler dependencies', () => { scopes: [() => ({ begin: () => { calls.push('begin'); }, complete: () => { calls.push('complete'); } })], provide: () => { calls.push('provide'); return 1; }, handle: () => { calls.push('handle'); return 2; } })] }); try { - validationSuccess = (await server.executeCommand('Save', {}, serviceContext('alpha'), true)).isSuccess; + validationSuccess = (await server.validateCommand('Save', {}, serviceContext('alpha'))).isSuccess; validationCalls = [...calls]; response = (await server.executeCommand('Save', {}, serviceContext('alpha'))).response; executionCalls = [...calls]; diff --git a/Source/Core/for_ArcServer/when_preflighting_a_command/with_missing_cyclic_or_captive_dependencies.ts b/Source/Core/for_ArcServer/when_preflighting_a_command/with_missing_cyclic_or_captive_dependencies.ts index 13773dd3..59e0e200 100644 --- a/Source/Core/for_ArcServer/when_preflighting_a_command/with_missing_cyclic_or_captive_dependencies.ts +++ b/Source/Core/for_ArcServer/when_preflighting_a_command/with_missing_cyclic_or_captive_dependencies.ts @@ -22,7 +22,7 @@ describe('when preflighting a command with missing cyclic or captive dependencie ], commands: [missing, cycleA, singleton].map((token, index) => defineCommand({ name: `Action${index}`, schema: z.object({}), handlerDependencies: [token], validate: () => { calls.push('validate'); return []; }, provide: () => { calls.push('provide'); }, handle: () => { calls.push('handle'); } })) }); try { - const outcomes = await Promise.all([0, 1, 2].map(index => server.executeCommand(`Action${index}`, {}, serviceContext('alpha'), true))); + const outcomes = await Promise.all([0, 1, 2].map(index => server.validateCommand(`Action${index}`, {}, serviceContext('alpha')))); reasons = outcomes.map(result => result.validationResults[0]?.reason); successes = outcomes.map(result => result.isSuccess); } finally { await server.dispose(); } diff --git a/Source/Core/for_ArcServer/when_preflighting_operations/with_a_missing_later_dependency.ts b/Source/Core/for_ArcServer/when_preflighting_operations/with_a_missing_later_dependency.ts index 6b13147c..7bc3803d 100644 --- a/Source/Core/for_ArcServer/when_preflighting_operations/with_a_missing_later_dependency.ts +++ b/Source/Core/for_ArcServer/when_preflighting_operations/with_a_missing_later_dependency.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { given } from '../../given.js'; import { an_operation_command, ProbeOperation } from '../given/an_operation_command.js'; import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; -import { tuple } from '../../results/tuple.js'; +import { tuple } from '../../commands/tuple.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); describe('when preflighting operations with a missing later dependency', given(an_operation_command, context => { diff --git a/Source/Core/for_ArcServer/when_processing_command_response/with_invalid_values_or_controlled_operations.ts b/Source/Core/for_ArcServer/when_processing_command_response/with_invalid_values_or_controlled_operations.ts index 28673daf..b1cefe81 100644 --- a/Source/Core/for_ArcServer/when_processing_command_response/with_invalid_values_or_controlled_operations.ts +++ b/Source/Core/for_ArcServer/when_processing_command_response/with_invalid_values_or_controlled_operations.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, it, should } from 'vitest'; import { given } from '../../given.js'; import { an_operation_command, ProbeOperation } from '../given/an_operation_command.js'; -import { denied, rejected } from '../../results/index.js'; -import { tuple } from '../../results/tuple.js'; +import { denied, rejected } from '../../commands/Outcome.js'; +import { tuple } from '../../commands/tuple.js'; import type { CommandResult } from '../../commands/CommandResult.js'; should(); for (const [name, value] of [ diff --git a/Source/Core/for_ArcServer/when_processing_command_response/with_multiple_handlers_and_control.ts b/Source/Core/for_ArcServer/when_processing_command_response/with_multiple_handlers_and_control.ts index 62c563ce..e7d430f7 100644 --- a/Source/Core/for_ArcServer/when_processing_command_response/with_multiple_handlers_and_control.ts +++ b/Source/Core/for_ArcServer/when_processing_command_response/with_multiple_handlers_and_control.ts @@ -5,8 +5,8 @@ import { z } from 'zod'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; -import { tuple } from '../../results/tuple.js'; -import { denied, rejected, response } from '../../results/index.js'; +import { tuple } from '../../commands/tuple.js'; +import { denied, rejected, response } from '../../commands/Outcome.js'; import type { CommandContext } from '../../commands/CommandContext.js'; import type { CommandResponseValueHandler } from '../../commands/CommandResponseValueHandler.js'; import type { CommandResult } from '../../commands/CommandResult.js'; diff --git a/Source/Core/for_ArcServer/when_processing_command_response/with_scoped_handlers.ts b/Source/Core/for_ArcServer/when_processing_command_response/with_scoped_handlers.ts index a987de1e..a8107569 100644 --- a/Source/Core/for_ArcServer/when_processing_command_response/with_scoped_handlers.ts +++ b/Source/Core/for_ArcServer/when_processing_command_response/with_scoped_handlers.ts @@ -6,7 +6,7 @@ import { given } from '../../given.js'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; -import { tuple } from '../../results/tuple.js'; +import { tuple } from '../../commands/tuple.js'; import type { CommandResult } from '../../commands/CommandResult.js'; import type { CommandContext } from '../../commands/CommandContext.js'; import type { CommandResponseValueHandler } from '../../commands/CommandResponseValueHandler.js'; diff --git a/Source/Core/for_ArcServer/when_providing_a_command/with_a_rejection.ts b/Source/Core/for_ArcServer/when_providing_a_command/with_a_rejection.ts index 3b8191f9..a7615b8c 100644 --- a/Source/Core/for_ArcServer/when_providing_a_command/with_a_rejection.ts +++ b/Source/Core/for_ArcServer/when_providing_a_command/with_a_rejection.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { z } from 'zod'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; -import { rejected } from '../../results/Outcome.js'; +import { rejected } from '../../commands/Outcome.js'; import { Severity } from '../../validation/Severity.js'; import { validation } from '../../validation/ValidationResult.js'; import { runtimePost } from '../given/a_runtime_request.js'; diff --git a/Source/Core/for_ArcServer/when_providing_a_command/with_an_empty_rejection.ts b/Source/Core/for_ArcServer/when_providing_a_command/with_an_empty_rejection.ts index dd6d9a77..43c60b66 100644 --- a/Source/Core/for_ArcServer/when_providing_a_command/with_an_empty_rejection.ts +++ b/Source/Core/for_ArcServer/when_providing_a_command/with_an_empty_rejection.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { z } from 'zod'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; -import { rejected } from '../../results/Outcome.js'; +import { rejected } from '../../commands/Outcome.js'; import { runtimePost } from '../given/a_runtime_request.js'; should(); diff --git a/Source/Core/for_ArcServer/when_providing_a_command/with_filtered_warnings_and_denial.ts b/Source/Core/for_ArcServer/when_providing_a_command/with_filtered_warnings_and_denial.ts index effd5051..8375efd7 100644 --- a/Source/Core/for_ArcServer/when_providing_a_command/with_filtered_warnings_and_denial.ts +++ b/Source/Core/for_ArcServer/when_providing_a_command/with_filtered_warnings_and_denial.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, it, should } from 'vitest'; import { z } from 'zod'; import { ArcServer } from '../../ArcServer.js'; import { defineCommand } from '../../commands/defineCommand.js'; -import { denied, rejected } from '../../results/Outcome.js'; +import { denied, rejected } from '../../commands/Outcome.js'; import { Severity } from '../../validation/Severity.js'; import { validation } from '../../validation/ValidationResult.js'; import { runtimePost } from '../given/a_runtime_request.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 new file mode 100644 index 00000000..22270273 --- /dev/null +++ b/Source/Core/for_exports/when_loading_the_public_barrels.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 * as node from '../index.js'; +import * as fetch from '../fetch.js'; +import * as hosting from '../hosting.js'; + +describe('when loading the public Arc barrels', () => { + const internal = ['commandResult', 'malformed', 'status', 'emptyPaging', 'recordFailure']; + it('should keep result helpers off the Node root', () => { + internal.filter(name => Object.hasOwn(node, name)).should.deep.equal([]); + }); + it('should keep result helpers off the Fetch root', () => { + internal.filter(name => Object.hasOwn(fetch, name)).should.deep.equal([]); + }); + it('should expose failure tracking to hosting integrations', () => { + hosting.recordFailure.should.be.a('function'); + }); + 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/hosting.ts b/Source/Core/hosting.ts index 979407b2..bd397528 100644 --- a/Source/Core/hosting.ts +++ b/Source/Core/hosting.ts @@ -13,4 +13,6 @@ export { serveUpgradedSocket } from './queries/observable/serveUpgradedSocket.js export { observableLimits } from './queries/observable/observableHosting.js'; export type { NodeWebSocketLike } from './queries/observable/NodeWebSocketLike.js'; export { prepareObservableUpgrade } from './queries/observable/prepareObservableUpgrade.js'; +/** Preserve the original integration failure for host-side diagnostics and cleanup. */ +export { recordFailure } from './execution/failureTracking.js'; export { ObservableHandshakeTimeoutError, withObservableHandshakeTimeout } from './queries/observable/withObservableHandshakeTimeout.js'; diff --git a/Source/Core/DescriptorBase.ts b/Source/Core/http/DescriptorBase.ts similarity index 77% rename from Source/Core/DescriptorBase.ts rename to Source/Core/http/DescriptorBase.ts index 460cf6f0..8b3a2b60 100644 --- a/Source/Core/DescriptorBase.ts +++ b/Source/Core/http/DescriptorBase.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 type { Authorization } from './authorization/Authorization.js'; -import type { ClientContract } from './introspection/ClientContract.js'; -import type { GeneratedReturn } from './reflection/GeneratedReturn.js'; +import type { Authorization } from '../authorization/Authorization.js'; +import type { ClientContract } from '../introspection/ClientContract.js'; +import type { GeneratedReturn } from '../reflection/GeneratedReturn.js'; /** Shared operation description for direct and model-bound definitions. */ export interface DescriptorBase { name: string; diff --git a/Source/Core/http/Operation.ts b/Source/Core/http/Operation.ts index e00a2688..a0c0efe6 100644 --- a/Source/Core/http/Operation.ts +++ b/Source/Core/http/Operation.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { z } from 'zod'; import type { CommandResult } from '../commands/CommandResult.js'; -import type { DescriptorBase } from '../DescriptorBase.js'; +import type { DescriptorBase } from './DescriptorBase.js'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; import type { QueryOptions } from '../queries/QueryOptions.js'; import type { QueryResult } from '../queries/QueryResult.js'; @@ -17,5 +17,8 @@ export interface Operation extends DescriptorBase { /** Built-in endpoints are served and documented, but not emitted as application client proxies. */ readonly internal?: boolean; readonly schema: z.ZodType; - run(input: unknown, context: ExecutionContext, options?: QueryOptions, validateOnly?: boolean): Promise; + /** Execute the command or query through its registered pipeline. */ + run(input: unknown, context: ExecutionContext, options?: QueryOptions): Promise; + /** Validate a command without running provide, handle, or execution scopes. */ + validateCommand?(input: unknown, context: ExecutionContext): Promise; } diff --git a/Source/Core/http/createRouteTable.ts b/Source/Core/http/createRouteTable.ts index 2e54446b..a1ce102c 100644 --- a/Source/Core/http/createRouteTable.ts +++ b/Source/Core/http/createRouteTable.ts @@ -8,7 +8,7 @@ import type { ExecutionContext } from '../execution/ExecutionContext.js'; import type { ObservableSource } from '../queries/observable/ObservableSource.js'; import type { QueryHealthSnapshot } from '../queries/observable/QueryHealthSnapshot.js'; import { inspectClientInput, inspectClientQueryInput } from '../introspection/ClientManifest.js'; -import { commandOperation } from '../commands/commandOperation.js'; +import { commandOperation } from '../commands/runCommandOperation.js'; import { queryOperation } from '../queries/queryOperation.js'; import { observableOperation } from '../queries/observable/ObservableOperation.js'; import { authorizationRequirements, validateAuthorization } from '../authorization/authorizationRequirements.js'; diff --git a/Source/Core/http/handleRequest.ts b/Source/Core/http/handleRequest.ts index 4fe1ff78..7eed0a31 100644 --- a/Source/Core/http/handleRequest.ts +++ b/Source/Core/http/handleRequest.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { z } from 'zod'; -import { observe } from '../observability.js'; +import { observe } from '../execution/observability.js'; import { stringifyWire } from '../reflection/stringifyWire.js'; import type { ArcServer } from '../ArcServer.js'; import type { NativeRequestContext } from './NativeRequestContext.js'; @@ -17,12 +17,15 @@ import { BadRequest } from './BadRequest.js'; import { body } from './body.js'; import { utf8Bytes } from './utf8Bytes.js'; import { getQuery, structuredQuery } from './queryBinding.js'; -import { commandResult, malformed, queryResult, status } from '../results/index.js'; +import { commandResult } from '../commands/createCommandResult.js'; +import { queryResult } from '../queries/createQueryResult.js'; +import { malformed } from './malformed.js'; +import { status } from './status.js'; import { allowedSeverity } from '../validation/allowedSeverity.js'; import { authenticate, verifiedPrincipal } from '../authentication/authenticate.js'; import { correlation } from '../execution/correlation.js'; import { exposeExceptionDetails } from '../execution/exposeExceptionDetails.js'; -import { hasFailure, originalFailure } from '../results/failureTracking.js'; +import { hasFailure, originalFailure } from '../execution/failureTracking.js'; import { Severity } from '../validation/Severity.js'; import { requestContext } from '../execution/RequestContextStore.js'; import { isObservableOperation } from '../queries/observable/ObservableOperation.js'; @@ -40,7 +43,8 @@ export interface RequestBindings { readonly identitySchema: Record | undefined; hubHttp(request: Request, native?: NativeRequestContext): Promise; runProvider(context: ExecutionContext, callback: () => T | Promise): Promise; - runScoped(operation: Operation, input: unknown, context: ExecutionContext, options?: QueryOptions, validateOnly?: boolean): Promise; + runScoped(operation: Operation, input: unknown, context: ExecutionContext, options?: QueryOptions): Promise; + validateCommand(operation: Operation, input: unknown, context: ExecutionContext): Promise; openSession(name: string, input: unknown, context: ExecutionContext, options: QueryOptions | undefined, admission: 'subscription' | 'snapshot'): Promise; reserveSession(session: ObservableQuerySession, context: ExecutionContext): void; } @@ -192,7 +196,8 @@ export async function handleRequest(server: ArcServer, bindings: RequestBindings return send(outcome.result, outcome.code); } finally { await session.close(); } } - const result = await bindings.runScoped(operation, input, context, options, isValidation); + const result = isValidation ? await bindings.validateCommand(operation, input, context) : + await bindings.runScoped(operation, input, context, options); if (hasFailure(result) && !await logFailure(originalFailure(result))) return serverFailure(); if (result.exceptionMessages.length) { if (!exposeExceptionDetails(server.options)) { diff --git a/Source/Core/results/malformed.ts b/Source/Core/http/malformed.ts similarity index 100% rename from Source/Core/results/malformed.ts rename to Source/Core/http/malformed.ts diff --git a/Source/Core/results/status.ts b/Source/Core/http/status.ts similarity index 100% rename from Source/Core/results/status.ts rename to Source/Core/http/status.ts diff --git a/Source/Core/index.ts b/Source/Core/index.ts index c61cfc0c..82fa519c 100644 --- a/Source/Core/index.ts +++ b/Source/Core/index.ts @@ -8,11 +8,11 @@ export type { ArcBuilderIntegrationOptions } from './ArcBuilderIntegrationOption export type { ArcBuilderOptions } from './configuration/ArcBuilderOptions.js'; export { discoveryFiles } from './reflection/discoveryFiles.js'; export { canonicalMetadataSignature } from './reflection/generatedMetadataSignature.js'; -export { optionalService } from './reflection/optionalService.js'; +export { optionalService } from './dependencyInjection/optionalService.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'; -export { ArcApplicationServices } from './ArcApplicationServices.js'; +export { ArcApplicationServices } from './dependencyInjection/ArcApplicationServices.js'; export { key } from './reflection/key.js'; export { fieldsFor, wireName } from './reflection/wireSchema.js'; export type { ClassType } from './reflection/ClassType.js'; @@ -22,7 +22,7 @@ export { nullable } from './reflection/nullable.js'; export { defaultValue } from './reflection/defaultValue.js'; export { enumeration } from './reflection/enumeration.js'; export type { ArcOptions } from './ArcOptions.js'; -export type { DescriptorBase } from './DescriptorBase.js'; +export type { DescriptorBase } from './http/DescriptorBase.js'; export * from './commands/index.js'; export * from './queries/index.js'; export * from './validation/index.js'; @@ -34,4 +34,3 @@ export * from './execution/index.js'; export * from './dependencyInjection/index.js'; export * from './http/index.js'; export * from './introspection/index.js'; -export * from './results/index.js'; diff --git a/Source/Core/package.json b/Source/Core/package.json index 59c81db4..502ae0ec 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Core/queries/QueryDefinition.ts b/Source/Core/queries/QueryDefinition.ts index 5e921b5b..5e8ebe6c 100644 --- a/Source/Core/queries/QueryDefinition.ts +++ b/Source/Core/queries/QueryDefinition.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { z } from 'zod'; -import type { DescriptorBase } from '../DescriptorBase.js'; +import type { DescriptorBase } from '../http/DescriptorBase.js'; import type { QueryFilter } from './QueryFilter.js'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; import type { QueryOptions } from './QueryOptions.js'; diff --git a/Source/Core/results/queryResult.ts b/Source/Core/queries/createQueryResult.ts similarity index 94% rename from Source/Core/results/queryResult.ts rename to Source/Core/queries/createQueryResult.ts index 9c3adbb3..60b94ec4 100644 --- a/Source/Core/results/queryResult.ts +++ b/Source/Core/queries/createQueryResult.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { ExecutionContext } from '../execution/ExecutionContext.js'; -import type { QueryResult } from '../queries/QueryResult.js'; +import type { QueryResult } from './QueryResult.js'; import { emptyPaging } from './emptyPaging.js'; export function queryResult(context: ExecutionContext, values: Partial> = {}): QueryResult { diff --git a/Source/Core/results/emptyPaging.ts b/Source/Core/queries/emptyPaging.ts similarity index 82% rename from Source/Core/results/emptyPaging.ts rename to Source/Core/queries/emptyPaging.ts index 0060434d..9da57679 100644 --- a/Source/Core/results/emptyPaging.ts +++ b/Source/Core/queries/emptyPaging.ts @@ -1,5 +1,5 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import type { Paging } from '../queries/Paging.js'; +import type { Paging } from './Paging.js'; export const emptyPaging = (): Paging => ({ page: 0, size: 0, totalItems: 0, totalPages: 0 }); diff --git a/Source/Core/queries/observable/ObservableOperation.ts b/Source/Core/queries/observable/ObservableOperation.ts index 65bc0272..6e61858e 100644 --- a/Source/Core/queries/observable/ObservableOperation.ts +++ b/Source/Core/queries/observable/ObservableOperation.ts @@ -8,8 +8,8 @@ import type { QueryResult } from '../QueryResult.js'; import type { Operation } from '../../http/Operation.js'; import { queryOperation } from '../queryOperation.js'; import { renderQuery } from '../renderQuery.js'; -import { queryResult } from '../../results/index.js'; -import { recordFailure } from '../../results/failureTracking.js'; +import { queryResult } from '../createQueryResult.js'; +import { recordFailure } from '../../execution/failureTracking.js'; import type { ObservableQueryDefinition } from './ObservableQueryDefinition.js'; import type { ObservableSource } from './ObservableSource.js'; diff --git a/Source/Core/queries/observable/ObservableQuerySession.ts b/Source/Core/queries/observable/ObservableQuerySession.ts index 5ec4f19e..5bb502be 100644 --- a/Source/Core/queries/observable/ObservableQuerySession.ts +++ b/Source/Core/queries/observable/ObservableQuerySession.ts @@ -2,8 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { ExecutionContext } from '../../execution/ExecutionContext.js'; import type { QueryResult } from '../QueryResult.js'; -import { hasFailure, originalFailure } from '../../results/failureTracking.js'; -import { queryResult } from '../../results/index.js'; +import { hasFailure, originalFailure } from '../../execution/failureTracking.js'; +import { queryResult } from '../createQueryResult.js'; import { requestContext } from '../../execution/RequestContextStore.js'; import { withServices } from '../../dependencyInjection/ServiceScope.js'; import type { ObservableSource } from './ObservableSource.js'; @@ -12,7 +12,7 @@ import { ObservableEmissionDecision } from './ObservableEmissionDecision.js'; import type { ObservableEmissionContext } from './ObservableEmissionContext.js'; import type { ObservableSessionConfig } from './ObservableSessionConfig.js'; import { clonePrincipal } from './clonePrincipal.js'; -import { beginSubscription, observe } from '../../observability.js'; +import { beginSubscription, observe } from '../../execution/observability.js'; /** An opened pipeline and scope owned by one live subscription (or snapshot request). */ export class ObservableQuerySession { diff --git a/Source/Core/queries/ObservableSessions.ts b/Source/Core/queries/observable/ObservableSessions.ts similarity index 88% rename from Source/Core/queries/ObservableSessions.ts rename to Source/Core/queries/observable/ObservableSessions.ts index 3c85e3c4..3e4cf51d 100644 --- a/Source/Core/queries/ObservableSessions.ts +++ b/Source/Core/queries/observable/ObservableSessions.ts @@ -1,16 +1,16 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import type { ArcOptions } from '../ArcOptions.js'; -import type { ExecutionContext } from '../execution/ExecutionContext.js'; -import type { QueryOptions } from './QueryOptions.js'; -import type { Operation } from '../http/Operation.js'; -import type { ServiceRegistry } from '../dependencyInjection/ServiceRegistry.js'; -import { ObservableQuerySession } from './observable/ObservableQuerySession.js'; -import { ObservableSubscriptionLimitError } from './observable/ObservableSubscriptionLimitError.js'; -import { isObservableOperation } from './observable/ObservableOperation.js'; -import { observableCallerKey } from './observable/observableCallerKey.js'; -import type { ObservableLimits } from './observable/ObservableLimits.js'; -import { exposeExceptionDetails } from '../execution/exposeExceptionDetails.js'; +import type { ArcOptions } from '../../ArcOptions.js'; +import type { ExecutionContext } from '../../execution/ExecutionContext.js'; +import type { QueryOptions } from '../QueryOptions.js'; +import type { Operation } from '../../http/Operation.js'; +import type { ServiceRegistry } from '../../dependencyInjection/ServiceRegistry.js'; +import { ObservableQuerySession } from './ObservableQuerySession.js'; +import { ObservableSubscriptionLimitError } from './ObservableSubscriptionLimitError.js'; +import { isObservableOperation } from './ObservableOperation.js'; +import { observableCallerKey } from './observableCallerKey.js'; +import type { ObservableLimits } from './ObservableLimits.js'; +import { exposeExceptionDetails } from '../../execution/exposeExceptionDetails.js'; export class ObservableSessions { readonly #observableSessions = new Set(); diff --git a/Source/Core/queries/observable/directWebSocket.ts b/Source/Core/queries/observable/directWebSocket.ts index bc1c4db2..248f82fe 100644 --- a/Source/Core/queries/observable/directWebSocket.ts +++ b/Source/Core/queries/observable/directWebSocket.ts @@ -5,7 +5,8 @@ import { BadRequest } from '../../http/BadRequest.js'; import { getQuery } from '../../http/queryBinding.js'; import type { ExecutionContext } from '../../execution/ExecutionContext.js'; import type { NativeRequestContext } from '../../http/NativeRequestContext.js'; -import { malformed, queryResult } from '../../results/index.js'; +import { malformed } from '../../http/malformed.js'; +import { queryResult } from '../createQueryResult.js'; import { correlation } from '../../execution/correlation.js'; import { Severity } from '../../validation/Severity.js'; import { isObservableOperation } from './ObservableOperation.js'; diff --git a/Source/Core/queries/for_ObservableSessions/when_recording_cleanup_failure/with_repeated_session.ts b/Source/Core/queries/observable/for_ObservableSessions/when_recording_cleanup_failure/with_repeated_session.ts similarity index 89% rename from Source/Core/queries/for_ObservableSessions/when_recording_cleanup_failure/with_repeated_session.ts rename to Source/Core/queries/observable/for_ObservableSessions/when_recording_cleanup_failure/with_repeated_session.ts index b8ef915f..3afad9c5 100644 --- a/Source/Core/queries/for_ObservableSessions/when_recording_cleanup_failure/with_repeated_session.ts +++ b/Source/Core/queries/observable/for_ObservableSessions/when_recording_cleanup_failure/with_repeated_session.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 { should } from 'vitest'; -import { ArcServer } from '../../../ArcServer.js'; -import { ObservableLimits } from '../../observable/ObservableLimits.js'; +import { ArcServer } from '../../../../ArcServer.js'; +import { ObservableLimits } from '../../ObservableLimits.js'; import { ObservableSessions } from '../../ObservableSessions.js'; should(); diff --git a/Source/Core/queries/observable/for_ObservableTransfer/given/a_transfer_result.ts b/Source/Core/queries/observable/for_ObservableTransfer/given/a_transfer_result.ts index d697e741..5ecfb215 100644 --- a/Source/Core/queries/observable/for_ObservableTransfer/given/a_transfer_result.ts +++ b/Source/Core/queries/observable/for_ObservableTransfer/given/a_transfer_result.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { Severity } from '../../../../validation/Severity.js'; -import { queryResult } from '../../../../results/queryResult.js'; +import { queryResult } from '../../../createQueryResult.js'; import type { ExecutionContext } from '../../../../execution/ExecutionContext.js'; const context: ExecutionContext = { diff --git a/Source/Core/queries/observable/observableCleanupFailures.ts b/Source/Core/queries/observable/observableCleanupFailures.ts index 37728c02..f8aed8b3 100644 --- a/Source/Core/queries/observable/observableCleanupFailures.ts +++ b/Source/Core/queries/observable/observableCleanupFailures.ts @@ -1,7 +1,7 @@ // 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 '../../ArcServer.js'; -import type { ObservableSessions } from '../ObservableSessions.js'; +import type { ObservableSessions } from './ObservableSessions.js'; const owners = new WeakMap(); diff --git a/Source/Core/queries/observable/snapshot.ts b/Source/Core/queries/observable/snapshot.ts index 1c7d98bb..e16ab9f5 100644 --- a/Source/Core/queries/observable/snapshot.ts +++ b/Source/Core/queries/observable/snapshot.ts @@ -3,7 +3,8 @@ import { BadRequest } from '../../http/BadRequest.js'; import type { ExecutionContext } from '../../execution/ExecutionContext.js'; import type { QueryResult } from '../QueryResult.js'; -import { queryResult, status } from '../../results/index.js'; +import { queryResult } from '../createQueryResult.js'; +import { status } from '../../http/status.js'; import type { ObservableQuerySession } from './ObservableQuerySession.js'; /** Only query routes consume these reserved parameters. Maximum wait bounds retained subscriptions. */ diff --git a/Source/Core/queries/queryOperation.ts b/Source/Core/queries/queryOperation.ts index 89b23999..f43bdd1e 100644 --- a/Source/Core/queries/queryOperation.ts +++ b/Source/Core/queries/queryOperation.ts @@ -6,13 +6,13 @@ import type { QueryDefinition } from './QueryDefinition.js'; import type { QueryResult } from './QueryResult.js'; import type { ValidationResult } from '../validation/ValidationResult.js'; import { authorized } from '../authorization/authorized.js'; -import { queryResult } from '../results/queryResult.js'; -import { malformed } from '../results/malformed.js'; +import { queryResult } from './createQueryResult.js'; +import { malformed } from '../http/malformed.js'; import { renderQuery } from './renderQuery.js'; -import { observe } from '../observability.js'; +import { observe } from '../execution/observability.js'; import type { Operation } from '../http/Operation.js'; import { fullyQualifiedName } from '../http/fullyQualifiedName.js'; -import { recordFailure } from '../results/failureTracking.js'; +import { recordFailure } from '../execution/failureTracking.js'; import { ServiceDependencyError } from '../dependencyInjection/ServiceDependencyError.js'; import { prepareDependencies, dependencyFailure, validate, validatorFailure } from '../commands/OperationValidation.js'; import { InvalidQuerySort } from './InvalidQuerySort.js'; diff --git a/Source/Core/queries/queryRendering.ts b/Source/Core/queries/queryRendering.ts index 05b9fe95..84d4fa5d 100644 --- a/Source/Core/queries/queryRendering.ts +++ b/Source/Core/queries/queryRendering.ts @@ -1,12 +1,14 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { ExecutionContext } from '../execution/ExecutionContext.js'; -import type { DescriptorBase } from '../DescriptorBase.js'; +import type { DescriptorBase } from '../http/DescriptorBase.js'; import type { QueryOptions } from './QueryOptions.js'; import type { QueryResult } from './QueryResult.js'; import { isQueryPage } from './QueryPage.js'; import { assertClientOutput } from '../introspection/ClientManifest.js'; -import { emptyPaging, malformed, queryResult } from '../results/index.js'; +import { emptyPaging } from './emptyPaging.js'; +import { malformed } from '../http/malformed.js'; +import { queryResult } from './createQueryResult.js'; function compareValues(left: unknown, right: unknown): number { if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime(); diff --git a/Source/Core/queries/renderQuery.ts b/Source/Core/queries/renderQuery.ts index 4c085904..4f8a48cb 100644 --- a/Source/Core/queries/renderQuery.ts +++ b/Source/Core/queries/renderQuery.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { ArcOptions } from '../ArcOptions.js'; -import type { DescriptorBase } from '../DescriptorBase.js'; +import type { DescriptorBase } from '../http/DescriptorBase.js'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; import { currentServices } from '../dependencyInjection/ServiceScope.js'; import { encode, fieldsFor, wireName } from '../reflection/wireSchema.js'; diff --git a/Source/Core/reflection/resolveAll.ts b/Source/Core/reflection/resolveAll.ts deleted file mode 100644 index 673df8a5..00000000 --- a/Source/Core/reflection/resolveAll.ts +++ /dev/null @@ -1,9 +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 { ServiceIdentifier } from '../dependencyInjection/ServiceIdentifier.js'; -import { currentServices } from '../dependencyInjection/ServiceScope.js'; - -/** Resolve ordered service tokens in the current execution scope. */ -export async function resolveAll(tokens: readonly ServiceIdentifier[]): Promise { - return Promise.all(tokens.map(token => currentServices().resolve(token))); -} diff --git a/Source/Core/reflection/wireSchema.ts b/Source/Core/reflection/wireSchema.ts index eb510636..8996db3f 100644 --- a/Source/Core/reflection/wireSchema.ts +++ b/Source/Core/reflection/wireSchema.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ConceptAs, DateOnly, DerivedType, Fields, Guid, TimeOnly, TimeSpan, type Field } from '@cratis/fundamentals'; import { z } from 'zod'; -import { isArcTuple } from '../results/ArcTuple.js'; +import { isArcTuple } from '../commands/ArcTuple.js'; import { isQueryPage, queryPage } from '../queries/QueryPage.js'; import { readFieldOptions } from './readFieldOptions.js'; import type { FieldOptions } from './FieldOptions.js'; diff --git a/Source/Core/results/index.ts b/Source/Core/results/index.ts deleted file mode 100644 index 47ded970..00000000 --- a/Source/Core/results/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -export { commandResult } from './commandResult.js'; -/** Preserve the original failure for HTTP logging when an integration fails after command execution. */ -export { recordFailure } from './failureTracking.js'; -export { queryResult } from './queryResult.js'; -export { malformed } from './malformed.js'; -export { status } from './status.js'; -export { emptyPaging } from './emptyPaging.js'; -export { response, rejected, denied, isOutcome } from './Outcome.js'; -export { tuple } from './tuple.js'; -export type { ArcTuple } from './ArcTuple.js'; -export type { Outcome } from './Outcome.js'; diff --git a/Source/Core/validateOptions.ts b/Source/Core/validateOptions.ts new file mode 100644 index 00000000..18e4bb98 --- /dev/null +++ b/Source/Core/validateOptions.ts @@ -0,0 +1,69 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { z } from 'zod'; +import type { ArcOptions } from './ArcOptions.js'; +import { ServiceRegistry } from './dependencyInjection/ServiceRegistry.js'; +import { objectSchema } from './reflection/wireSchema.js'; +import { ObservableLimits } from './queries/observable/ObservableLimits.js'; +import type { ObservableQueryOptions } from './queries/ObservableQueryOptions.js'; +import { validateTenancy } from './tenancy/validateTenancy.js'; + +function validateOrigins(origins: ObservableQueryOptions['allowedOrigins']): void { + if (origins !== undefined && !Array.isArray(origins) && typeof origins !== 'function') + throw new Error('Invalid allowed Origins'); + if (Array.isArray(origins) && origins.some(origin => { + if (typeof origin !== 'string') return true; + try { + const parsed = new URL(origin); + return parsed.origin !== origin || !['http:', 'https:'].includes(parsed.protocol); + } catch { return true; } + })) throw new Error('Invalid allowed Origin'); +} + +/** Validate input-independent server settings and normalize an identity provider schema. */ +export function validateOptions(options: ArcOptions): { + options: ArcOptions; identitySchema: Record | undefined; observableLimits: ObservableLimits +} { + const detailsSchema = options.identityDetails?.schema ?? (options.identityDetails?.detailsType + ? objectSchema(options.identityDetails.detailsType) : undefined); + const normalized = detailsSchema && options.identityDetails ? { + ...options, identityDetails: { ...options.identityDetails, schema: detailsSchema } + } : options; + if (options.correlationId?.httpHeader !== undefined && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(options.correlationId.httpHeader)) + throw new Error('Invalid correlation header'); + if (options.commandCompensationTimeoutMs !== undefined && + (!Number.isSafeInteger(options.commandCompensationTimeoutMs) || options.commandCompensationTimeoutMs < 1 || + options.commandCompensationTimeoutMs > 4_294_967_294)) + throw new Error('Compensation timeout must be positive and at most 4294967294 milliseconds'); + validateTenancy(options.tenancy); + if (options.nativePrincipal && options.authentication?.length) + throw new Error('Native principal and Arc authentication handlers cannot be combined'); + if (options.identityDetails && (!(detailsSchema instanceof z.ZodType) || typeof options.identityDetails.provide !== 'function')) + throw new Error('Identity details require a provider schema'); + if ((options.developmentUsers || options.developmentTenants) && !options.development) + throw new Error('Discovery providers require development mode'); + const identitySchema = detailsSchema ? z.toJSONSchema(detailsSchema) : undefined; + const observableLimits = new ObservableLimits(options); + validateOrigins(options.query?.allowedOrigins); + return { options: normalized, identitySchema, observableLimits }; +} + +/** Enforce scoped read-side extensions after the service registry has been created. */ +export function validateRegistryOptions(options: ArcOptions, services: ServiceRegistry): void { + for (const token of [...options.queryRenderers ?? [], ...options.readModelInterceptors ?? []]) { + if (services.registration(token).lifetime === 'singleton') + throw new Error(`Query renderer or read-model interceptor ${services.registration(token).token.name} must not be singleton`); + } +} + +/** Validate transport limits after service registration, before routes are published. */ +export function validateTransportOptions(options: ArcOptions): void { + if (options.hosting?.maxBodyBytes !== undefined && + (!Number.isSafeInteger(options.hosting.maxBodyBytes) || options.hosting.maxBodyBytes <= 0)) + throw new Error('Invalid maximum body size'); + if (options.query?.keepAliveIntervalMs !== undefined && + (!Number.isSafeInteger(options.query.keepAliveIntervalMs) || options.query.keepAliveIntervalMs < 0 || + options.query.keepAliveIntervalMs > 120_000)) throw new Error('Invalid observable keep-alive interval'); + if (options.query?.enableObservableHealth !== undefined && typeof options.query.enableObservableHealth !== 'boolean') + throw new Error('Invalid observable health option'); +} diff --git a/Source/Core/validation/index.ts b/Source/Core/validation/index.ts index 9b8b64ee..eac0685b 100644 --- a/Source/Core/validation/index.ts +++ b/Source/Core/validation/index.ts @@ -11,3 +11,4 @@ export { CommandValidator } from './CommandValidator.js'; export { QueryValidator } from './QueryValidator.js'; export { ConceptValidator } from './ConceptValidator.js'; export { ModelValidator } from './ModelValidator.js'; +export { readModelForValidation } from './readModelForValidation.js'; diff --git a/Source/Core/validation/readModelForValidation.ts b/Source/Core/validation/readModelForValidation.ts index 7679a5b2..fa1f82f3 100644 --- a/Source/Core/validation/readModelForValidation.ts +++ b/Source/Core/validation/readModelForValidation.ts @@ -2,9 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { AsyncLocalStorage } from 'node:async_hooks'; import type { CommandContext } from '../commands/CommandContext.js'; -import { commandReadModel } from '../commands/modelBound/readModel.js'; +import { commandReadModel } from '../commands/modelBound/commandReadModel.js'; import { ReadModelForCommandError } from '../commands/ReadModelForCommandError.js'; -import { resolveCommandArguments } from '../commands/modelBound/commandArgument.js'; +import { resolveCommandArguments } from '../commands/modelBound/commandContextArgument.js'; import type { ClassType } from '../reflection/ClassType.js'; const current = new AsyncLocalStorage(); diff --git a/Source/Cratis/index.ts b/Source/Cratis/index.ts index 81976917..27a0d516 100644 --- a/Source/Cratis/index.ts +++ b/Source/Cratis/index.ts @@ -2,14 +2,15 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ArcApplication, ArcApplicationBuilder } from '@cratis/arc.core'; import type { ArcBuilderOptions } from '@cratis/arc.core'; +import { ArcApplicationBuilder as FetchArcApplicationBuilder } from '@cratis/arc.core/fetch'; import '@cratis/arc.chronicle'; import type { ChronicleRegistration } from '@cratis/arc.chronicle'; export * from '@cratis/arc.core'; export * from '@cratis/arc.chronicle'; -declare module '@cratis/arc.core' { - interface ArcApplicationBuilder { +declare module '@cratis/arc.core/fetch' { + interface ArcBuilderExtensions { /** Attach Chronicle as part of the Cratis composition. */ addCratis(options?: Partial): this; } @@ -18,6 +19,9 @@ declare module '@cratis/arc.core' { ArcApplicationBuilder.prototype.addCratis = function (options: Partial = {}) { return this.extend('chronicle', options); }; +FetchArcApplicationBuilder.prototype.addCratis = function (options: Partial = {}) { + return this.extend('chronicle', options); +}; /** Node setup analog of AddCratis; Chronicle connection settings can come from appsettings.json. */ export class CratisApplication { static createBuilder(options: ArcBuilderOptions = {}, chronicle: Partial = {}): ArcApplicationBuilder { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index c86b1718..1d4cced0 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "description": "Arc and experimental Chronicle composition for Node.js", diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json index 23120cde..99966999 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.23.0", + "@cratis/arc.core": "^0.24.0", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0" }, diff --git a/Source/Drizzle/tsconfig.json b/Source/Drizzle/tsconfig.json index 3a7447dd..106b06a3 100644 --- a/Source/Drizzle/tsconfig.json +++ b/Source/Drizzle/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["**/*.ts"], - "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "vite.config.mts", "vitest.integration.config.ts", "*.integration.ts"], + "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "given.ts", "vite.config.mts", "vitest.integration.config.ts", "*.integration.ts"], "references": [{ "path": "../Core" }] } diff --git a/Source/Drizzle/withDrizzle.ts b/Source/Drizzle/withDrizzle.ts index 22a04f99..28af1841 100644 --- a/Source/Drizzle/withDrizzle.ts +++ b/Source/Drizzle/withDrizzle.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ArcApplicationBuilder } from '@cratis/arc.core'; import type { ExecutionContext } from '@cratis/arc.core'; +import { ArcApplicationBuilder as FetchArcApplicationBuilder } from '@cratis/arc.core/fetch'; import type { DrizzleOptions } from './DrizzleOptions.js'; import { DrizzleReadModels } from './DrizzleReadModels.js'; import { DrizzleHandle } from './DrizzleHandle.js'; @@ -42,8 +43,8 @@ export function withDrizzle(builder: ArcApplicationBuilder, options: DrizzleOpti return builder; } -declare module '@cratis/arc.core' { - interface ArcApplicationBuilder { +declare module '@cratis/arc.core/fetch' { + interface ArcBuilderExtensions { /** Attach Drizzle after importing @cratis/arc.drizzle. */ withDrizzle(options: DrizzleOptions): this; } @@ -53,3 +54,6 @@ ArcApplicationBuilder.registerExtension('drizzle', withDrizzle); ArcApplicationBuilder.prototype.withDrizzle = function (options: DrizzleOptions) { return this.extend('drizzle', options); }; +FetchArcApplicationBuilder.prototype.withDrizzle = function (options: DrizzleOptions) { + return this.extend('drizzle', options); +}; diff --git a/Source/Express/package.json b/Source/Express/package.json index a02475e6..2dc8ff40 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Express/tsconfig.json b/Source/Express/tsconfig.json index 11fc5cd3..2f035575 100644 --- a/Source/Express/tsconfig.json +++ b/Source/Express/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["**/*.ts"], - "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "vite.config.mts"], + "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "given.ts", "vite.config.mts"], "references": [{ "path": "../Core" }] } diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index 1bac2ac7..d67da6e9 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/tsconfig.json b/Source/Fastify/tsconfig.json index 11fc5cd3..2f035575 100644 --- a/Source/Fastify/tsconfig.json +++ b/Source/Fastify/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["**/*.ts"], - "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "vite.config.mts"], + "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "given.ts", "vite.config.mts"], "references": [{ "path": "../Core" }] } diff --git a/Source/Hono/package.json b/Source/Hono/package.json index a69e7dac..50a19be5 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/tsconfig.json b/Source/Hono/tsconfig.json index 11fc5cd3..2f035575 100644 --- a/Source/Hono/tsconfig.json +++ b/Source/Hono/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["**/*.ts"], - "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "vite.config.mts"], + "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "given.ts", "vite.config.mts"], "references": [{ "path": "../Core" }] } diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index a8046167..5e1700d2 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.23.0", + "@cratis/arc.core": "^0.24.0", "@cratis/fundamentals": "^7.19.6", "mongodb": "^6.21.0", "rxjs": "^7.8.2" diff --git a/Source/MongoDB/tsconfig.json b/Source/MongoDB/tsconfig.json index 3a7447dd..106b06a3 100644 --- a/Source/MongoDB/tsconfig.json +++ b/Source/MongoDB/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["**/*.ts"], - "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "vite.config.mts", "vitest.integration.config.ts", "*.integration.ts"], + "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "given.ts", "vite.config.mts", "vitest.integration.config.ts", "*.integration.ts"], "references": [{ "path": "../Core" }] } diff --git a/Source/MongoDB/withMongoDB.ts b/Source/MongoDB/withMongoDB.ts index e724a639..104ed317 100644 --- a/Source/MongoDB/withMongoDB.ts +++ b/Source/MongoDB/withMongoDB.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ArcApplicationBuilder, serviceToken } from '@cratis/arc.core'; import type { ExecutionContext } from '@cratis/arc.core'; +import { ArcApplicationBuilder as FetchArcApplicationBuilder } from '@cratis/arc.core/fetch'; import { MongoClientFactory } from './MongoClientFactory.js'; import { MongoCollection } from './MongoCollection.js'; import { MongoReadModelForCommandResolver } from './MongoReadModelForCommandResolver.js'; @@ -44,8 +45,8 @@ export function withMongoDB(builder: ArcApplicationBuilder, configured: MongoDBO return builder; } -declare module '@cratis/arc.core' { - interface ArcApplicationBuilder { +declare module '@cratis/arc.core/fetch' { + interface ArcBuilderExtensions { /** Attach MongoDB after importing @cratis/arc.mongodb. */ withMongoDB(options: MongoDBOptions): this; } @@ -55,3 +56,6 @@ ArcApplicationBuilder.registerExtension('mongodb', withMongoDB); ArcApplicationBuilder.prototype.withMongoDB = function (options: MongoDBOptions) { return this.extend('mongodb', options); }; +FetchArcApplicationBuilder.prototype.withMongoDB = function (options: MongoDBOptions) { + return this.extend('mongodb', options); +}; diff --git a/Source/Testing/ArcScenario.ts b/Source/Testing/ArcScenario.ts index a791ce3d..47146d9f 100644 --- a/Source/Testing/ArcScenario.ts +++ b/Source/Testing/ArcScenario.ts @@ -14,8 +14,13 @@ export class ArcScenario { return { correlationId: randomUUID(), principal: undefined, tenantId: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Warning, ...this.context, ...overrides }; } - executeCommand(name: string, input: unknown, context: Partial = {}, validateOnly = false): Promise { - return this.server.executeCommand(name, input, this.execution(context), validateOnly); + /** Execute a registered command using the scenario's trusted context. */ + executeCommand(name: string, input: unknown, context: Partial = {}): Promise { + return this.server.executeCommand(name, input, this.execution(context)); + } + /** Validate a registered command without executing its handler. */ + validateCommand(name: string, input: unknown, context: Partial = {}): Promise { + return this.server.validateCommand(name, input, this.execution(context)); } performQuery(name: string, input: unknown, context: Partial = {}, options?: QueryOptions): Promise { return this.server.performQuery(name, input, this.execution(context), options); diff --git a/Source/Testing/CommandScenario.ts b/Source/Testing/CommandScenario.ts index 01845dca..082674cb 100644 --- a/Source/Testing/CommandScenario.ts +++ b/Source/Testing/CommandScenario.ts @@ -33,13 +33,13 @@ export class CommandScenario { withAllowedValidationSeverity(severity: Severity): this { this.#host.withAllowedValidationSeverity(severity); return this; } /** Execute values or an instance; return the actual pipeline result with focused assertions. */ - async execute(command: T | Partial): Promise { return this.run(command, false); } + async execute(command: T | Partial): Promise { return this.run(command, 'execute'); } /** Run validation without invoking provide or handle. */ - async validate(command: T | Partial): Promise { return this.run(command, true); } + async validate(command: T | Partial): Promise { return this.run(command, 'validate'); } /** Dispose application-owned services. Safe to call more than once. */ dispose(): Promise { return this.#host.dispose(); } - private async run(command: T | Partial, validateOnly: boolean): Promise { + private async run(command: T | Partial, action: 'execute' | 'validate'): Promise { const application = await this.#host.application(); const instance = command instanceof this.#type ? command : Object.assign(Reflect.construct(this.#type, []) as T, command); const matches = application.server.commands.filter(item => item.name === this.#type.name); @@ -47,7 +47,9 @@ export class CommandScenario { const operation = matches[0]!; const input = this.#host.serializationRoundTrip ? wireRoundTrip(instance) : encodeWireValue(instance); const name = [operation.namespace, operation.name].filter(Boolean).join('.'); - const result = await application.server.executeCommand(name, input, this.#host.execution(), validateOnly); + const context = this.#host.execution(); + const result = action === 'validate' ? await application.server.validateCommand(name, input, context) : + await application.server.executeCommand(name, input, context); if (this.#host.serializationRoundTrip && result.response !== undefined) result.response = wireRoundTrip(result.response); return withCommandAssertions(result); } diff --git a/Source/Testing/for_ArcScenario/when_validating_without_running_handler.ts b/Source/Testing/for_ArcScenario/when_validating_without_running_handler.ts new file mode 100644 index 00000000..dbf7bb8b --- /dev/null +++ b/Source/Testing/for_ArcScenario/when_validating_without_running_handler.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 { z } from 'zod'; +import { defineCommand } from '@cratis/arc.core'; +import { ArcScenario } from '../ArcScenario.js'; + +describe('when validating a command through a low-level scenario', () => { + let valid: boolean; + let executions: number; + beforeEach(async () => { + executions = 0; + const scenario = new ArcScenario({ commands: [defineCommand({ name: 'Submit', schema: z.object({}), + handle: () => { executions++; return 1; } })] }); + try { valid = (await scenario.validateCommand('Submit', {})).isSuccess; } + finally { await scenario.dispose(); } + }); + it('should validate successfully without running the handler', () => { + valid.should.equal(true); + executions.should.equal(0); + }); +}); diff --git a/Source/Testing/package.json b/Source/Testing/package.json index 43a6f082..2b201324 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index d4959045..109fa498 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.23.0", + "version": "0.24.0", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/Source/Tools/ProxyGenerator/tsconfig.json b/Source/Tools/ProxyGenerator/tsconfig.json index 11b9adbb..69686ec7 100644 --- a/Source/Tools/ProxyGenerator/tsconfig.json +++ b/Source/Tools/ProxyGenerator/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "rootDir": ".", "outDir": "dist" }, "include": ["**/*.ts"], - "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "vite.config.mts"], + "exclude": ["dist", "node_modules", "**/for_*/**", "**/given/**", "given.ts", "vite.config.mts"], "references": [{ "path": "../../Core" }] } diff --git a/package.json b/package.json index 2397b7d4..f7aa6c39 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,10 @@ "test:legacy-decorators": "yarn build && yarn tsc -p ContractTests/LegacyDecorators/tsconfig.json && node --test ContractTests/LegacyDecorators/conformance.test.mjs", "test:conformance": "dotnet restore ContractTests/DotNET/HttpFixture.csproj --locked-mode && dotnet build ContractTests/DotNET/HttpFixture.csproj -c Debug --no-restore && yarn build && yarn tsc -p ContractTests/Http/modelBound/tsconfig.json && node --test ContractTests/Http/conformance.test.mjs", "typecheck": "tsc -b && tsc -p tsconfig.specs.json", - "build": "tsc -b && yarn check:metadata", + "build": "tsc -b Source/Core Source/Testing && tsc -b && yarn check:metadata", + "clean": "node scripts/clean.mjs", + "ci:clean": "yarn clean && yarn ci", + "ci:linux": "bash scripts/ci-linux.sh", "check:metadata": "node Samples/Tasks/check-metadata.mjs && node Source/Tools/ProxyGenerator/dist/cli.js --project Samples/Library/tsconfig.json --artifacts Samples/Library/Features --output Samples/Library/Web/src/generated --metadata Samples/Library/Features/generatedMetadata.ts --check-metadata", "check:consumers": "node scripts/check-consumers.mjs", "check:fetch": "node scripts/check-fetch-runtime.mjs", @@ -34,9 +37,10 @@ "set-version": "node scripts/set-version.mjs", "test": "vitest run", "specs": "yarn test", - "ci": "yarn tsc -b Source/Core Source/Tools/ProxyGenerator && yarn check:metadata && yarn workspace @cratis/arc.core.sample.tasks generate-proxies && yarn workspace @cratis/arc.sample.library generate-proxies && yarn build && yarn check:consumers && yarn check:fetch && yarn workspace @cratis/arc.sample.library.web build && yarn lint && yarn typecheck && yarn lint:tasks:arc && yarn test:client-generation:verify && yarn test && yarn workspace @cratis/arc.sample.library test:e2e && yarn test:legacy-decorators && yarn test:decorator-types && yarn docs:lint && yarn docs:snippets:self-test && yarn docs:snippets && node --test scripts/for_release/*.test.mjs && yarn set-version --check" + "ci": "yarn tsc -b Source/Core Source/Testing Source/Chronicle Source/Tools/ProxyGenerator && yarn check:metadata && yarn workspace @cratis/arc.core.sample.tasks generate-proxies && yarn workspace @cratis/arc.sample.library generate-proxies && yarn build && yarn check:consumers && yarn check:fetch && yarn workspace @cratis/arc.sample.library.web build && yarn lint && yarn typecheck && yarn lint:tasks:arc && yarn test:client-generation:verify && yarn test && yarn workspace @cratis/arc.sample.library test:e2e && yarn test:legacy-decorators && yarn test:decorator-types && yarn docs:lint && yarn docs:snippets:self-test && yarn docs:snippets && node --test scripts/for_release/*.test.mjs && yarn set-version --check" }, "devDependencies": { + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.6", "@eslint/js": "^10.0.1", "@opentelemetry/api": "^1.9.0", @@ -62,6 +66,7 @@ "eslint": "^10.11.0", "markdownlint-cli2": "^0.20.0", "mocha": "^11.7.5", + "react": "18.3.1", "sinon": "^21.0.0", "sinon-chai": "^4.0.1", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/scripts/check-consumers.mjs b/scripts/check-consumers.mjs index 858a4f67..cce50eec 100644 --- a/scripts/check-consumers.mjs +++ b/scripts/check-consumers.mjs @@ -217,7 +217,10 @@ import { CommandScenario, QueryScenario } from '@cratis/arc.testing'; import { analyzeSource } from '@cratis/arc.proxygenerator'; import plugin from '@cratis/eslint-plugin-arc-core'; void [attachNodeWebSockets, CommandScenario, QueryScenario, analyzeSource, plugin]; -function configureMongo(builder: ArcApplicationBuilder, options: MongoDBOptions) { withMongoDB(builder, options); } +function configureMongo(builder: ArcApplicationBuilder, options: MongoDBOptions) { + withMongoDB(builder, options); + builder.withMongoDB(options); +} void configureMongo; const adapterApp = await ArcApplication.createBuilder().build(); function attachSockets(host: HttpServer) { @@ -242,6 +245,7 @@ import { CommandScenario as CratisCommandScenario, ChronicleCommandScenario as C function configureIntegrations(builder: ArcApplicationBuilder, sql: DrizzleOptions, events: ChronicleRegistration) { withDrizzle(builder, sql); withChronicle(builder, events); + builder.withDrizzle(sql).withChronicle(events); } function configureCratis(events: ChronicleRegistration) { const composed = CratisApplication.createBuilder({ configuration: false }, events); diff --git a/scripts/ci-linux.sh b/scripts/ci-linux.sh new file mode 100644 index 00000000..5a6f42f4 --- /dev/null +++ b/scripts/ci-linux.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +set -euo pipefail + +cd "$(dirname "$0")/.." +if [[ -n "$(git status --porcelain)" ]]; then + printf 'Commit changes before running ci:linux; it checks a fresh clone of HEAD.\n' >&2 + exit 2 +fi + +branch=$(git branch --show-current) +clone=$(mktemp -d /tmp/arc-ci-linux-XXXXXXXX) +container="arc-ci-linux-$$" +cleanup() { + if docker ps --format '{{.Names}}' | grep -Fxq "$container" || + ! command -v lsof >/dev/null || lsof -t +D "$clone" >/dev/null 2>&1 || + [[ -n "$(git -C "$clone" status --porcelain 2>/dev/null)" ]]; then + printf 'Container, process, or changes still hold %s; leaving the clone intact.\n' "$clone" >&2 + else + rm -rf -- "$clone" + fi +} +trap cleanup EXIT + +git clone --quiet --no-local --single-branch --branch "$branch" "$PWD" "$clone" +network=() +if [[ "$(uname -s)" == Darwin ]]; then + network=(-e CHRONICLE_HOST=host.docker.internal) +else + network=(--network host) +fi +docker run --rm --name "$container" --mount "type=bind,src=$clone,dst=/workspace" \ + --mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \ + "${network[@]}" --workdir /workspace node:22.19.0 \ + sh -lc 'apt-get update -qq && apt-get install -y -qq --no-install-recommends docker.io lsof curl && corepack enable && yarn install --immutable && yarn ci' diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 00000000..752e810f --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, rmSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const { workspaces } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const folders = workspaces.flatMap(pattern => pattern.endsWith('/*') + ? readdirSync(join(root, pattern.slice(0, -2)), { withFileTypes: true }) + .filter(entry => entry.isDirectory()).map(entry => join(root, pattern.slice(0, -2), entry.name)) + : [join(root, pattern)]).filter(folder => existsSync(join(folder, 'package.json'))); +if (!folders.length) throw new Error('No workspaces found; refusing to clean'); +const targets = new Set(); +function scan(folder) { + for (const entry of readdirSync(folder, { withFileTypes: true })) { + const path = join(folder, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.ai-work' || entry.name === '.git') continue; + if (entry.name === 'dist') targets.add(path); + else scan(path); + } else if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(path); + } +} +for (const folder of folders) scan(folder); +for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(root, entry.name)); +} +const paths = [...targets].sort(); +if (paths.length > 256) throw new Error(`Refusing to clean ${paths.length} outputs in one pass (limit: 256)`); +for (const path of paths) { + const tracked = execFileSync('git', ['ls-files', '--', relative(root, path)], { cwd: root, encoding: 'utf8' }); + if (tracked.trim()) throw new Error(`Refusing to remove tracked files in ${relative(root, path)}: ${tracked.trim()}`); +} +console.log(`Removing ${paths.length} build outputs:`); +for (const path of paths) { + console.log(relative(root, path)); + rmSync(path, { recursive: true }); +} diff --git a/yarn.lock b/yarn.lock index ef153ed7..5e67e688 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45,8 +45,8 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/arc.core": ^0.23.0 - "@cratis/arc.testing": ^0.23.0 + "@cratis/arc.core": ^0.24.0 + "@cratis/arc.testing": ^0.24.0 "@cratis/chronicle": ^6.7.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 @@ -96,10 +96,16 @@ __metadata: version: 0.0.0-use.local resolution: "@cratis/arc.core.sample.tasks@workspace:Samples/Tasks" dependencies: + "@cratis/arc": "npm:22.19.1" "@cratis/arc.core": "workspace:^" + "@cratis/arc.react": "npm:22.19.1" "@cratis/arc.testing": "workspace:^" "@cratis/fundamentals": "npm:7.19.6" + "@types/react": "npm:18.3.28" + react: "npm:18.3.1" + reflect-metadata: "npm:0.2.2" rxjs: "npm:^7.8.2" + tsyringe: "npm:^4.10.0" languageName: unknown linkType: soft @@ -138,7 +144,7 @@ __metadata: postgres: "npm:^3.4.9" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.23.0 + "@cratis/arc.core": ^0.24.0 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 languageName: unknown @@ -202,7 +208,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.23.0 + "@cratis/arc.core": ^0.24.0 "@cratis/fundamentals": ^7.19.6 mongodb: ^6.21.0 rxjs: ^7.8.2 @@ -2436,6 +2442,7 @@ __metadata: version: 0.0.0-use.local resolution: "arc-typescript-workspace@workspace:." dependencies: + "@cratis/arc.react": "npm:22.19.1" "@cratis/fundamentals": "npm:7.19.6" "@eslint/js": "npm:^10.0.1" "@opentelemetry/api": "npm:^1.9.0" @@ -2461,6 +2468,7 @@ __metadata: eslint: "npm:^10.11.0" markdownlint-cli2: "npm:^0.20.0" mocha: "npm:^11.7.5" + react: "npm:18.3.1" sinon: "npm:^21.0.0" sinon-chai: "npm:^4.0.1" typescript: "npm:@typescript/typescript6@^6.0.2"