From f6ba50323c53ac700c686a0b98d1c13f5ed24720 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 05:43:05 +0200 Subject: [PATCH 1/7] Assert the title member in the empty-title spec shouldHaveValidationErrorFor matches a message fragment, so asserting 'title' passed only because the message happened to contain the word. Assert the member instead, in the sample spec and in Your first command, which also shows the generated-metadata line the real context uses. --- .../getting-started/your-first-command.md | 38 ++++++++++++++++--- .../when_validating/with_an_empty_title.ts | 2 +- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Documentation/getting-started/your-first-command.md b/Documentation/getting-started/your-first-command.md index 140e0949..baeb181b 100644 --- a/Documentation/getting-started/your-first-command.md +++ b/Documentation/getting-started/your-first-command.md @@ -3,7 +3,9 @@ title: Your first command description: Walk through the Tasks sample's concepts, command, validators, read model, bootstrap, and spec, and see what Arc does with each decorator. --- -The [Get started](index.md) page ran the Tasks sample from the outside. This page opens it up. You follow one task from the value types, through the command that registers it and the rules that guard it, to the read model that serves it and the spec that proves it. Every snippet is the sample's real code; the file links take you to the full source. +The [Get started](index.md) page ran the Tasks sample from the outside: a command stored a task, a rule refused an empty title, and a query served the result. None of that needed a route, a body parser, or an error mapper. This page opens the sample up so you can see which few lines produced each behavior. + +You follow one task from its value types, through the command that registers it and the rules that guard it, to the read model that serves it and the spec that proves it. Every snippet is the sample's real code; the file links take you to the full source. ## Name the values first @@ -24,7 +26,9 @@ import { ConceptAs } from '@cratis/fundamentals'; export class TaskTitle extends ConceptAs { static readonly valueType = String; } ``` -TypeScript erases the generic argument of `ConceptAs` at runtime, so `static readonly valueType` tells Arc what the wire value is. On the wire, a `TaskId` is a UUID string; inside your handler it is a `TaskId`. [Concepts](../concepts.md) covers the supported value types. +Why bother? A handler that takes `(id: string, title: string)` accepts the arguments in either order and compiles. A handler that takes a `TaskId` and a `TaskTitle` does not. + +TypeScript erases the generic argument of `ConceptAs` at runtime, so `static readonly valueType` tells Arc what the wire value is. On the wire, a `TaskId` is a UUID string; inside your handler it is a `TaskId`. Arc converts in both directions, and a string that is not a UUID is rejected as a malformed request before your code sees it. [Concepts](../concepts.md) covers the supported value types. ## Declare the command @@ -147,15 +151,21 @@ The sample tests the command through the real pipeline without starting a server import { CommandScenario } from '@cratis/arc.testing'; import { Tasks } from '../../../Tasks.js'; import { RegisterTask, RegisterTaskValidator } from '../../Registration.js'; +import { metadata } from '../../../../generatedMetadata.js'; export class a_task_registration { tasks = new Tasks(); scenario = CommandScenario.for(RegisterTask, RegisterTaskValidator); - constructor() { this.scenario.services.addSingleton(Tasks, this.tasks); } + constructor() { + this.scenario.extend(builder => builder.useGeneratedMetadata(metadata)); + this.scenario.services.addSingleton(Tasks, this.tasks); + } } ``` +The context builds the same kind of application `main.ts` builds, minus the listener: the generated metadata, the command and its validator, and a `Tasks` instance the spec can inspect afterward. + ```typescript title="Features/Tasks/Registration/for_RegisterTask/when_validating/with_an_empty_title.ts" import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import { TaskId } from '../../../TaskId.js'; @@ -169,19 +179,35 @@ describe('when validating a task with an empty title', given(a_task_registration }); afterAll(async () => { await context.scenario.dispose(); }); it('should report the authored rule for title', () => { - result.shouldHaveValidationErrors().shouldHaveValidationErrorFor('title'); + result.shouldHaveValidationErrors().shouldHaveValidationErrorForMember('title'); }); it('should not invoke the handler', () => { context.tasks.all().should.have.lengthOf(0); }); })); ``` -Run the sample's specs from the repository root with `yarn vitest run Samples/Tasks`. [Testing](../testing/index.md) covers commands, queries, and observable queries. +`validate()` sends the values through the same authorization and validation the `/validate` route runs, then stops. The first assertion proves that an authored rule failed for the `title` member; the second proves that `handle()` never stored anything. Together they pin the behavior you saw with `curl`: the rule, the field, and the untouched store. + +:::caution[Assert on the member, not a word in the message] +`shouldHaveValidationErrorFor(text)` matches a *message fragment*. `shouldHaveValidationErrorFor('title')` passes only because "A title is required" happens to contain the word, and it keeps passing if a different rule with "title" in its message fails instead. Use `shouldHaveValidationErrorForMember('title')` to assert the field, and pass the full message to `shouldHaveValidationErrorFor` when the wording matters. +::: + +Run the sample's specs from the repository root: + +```bash +yarn vitest run Samples/Tasks +``` + +Vitest runs the sample's command and query specs, and every test passes. [Testing](../testing/index.md) covers commands, queries, and observable queries. ## Recap A concept names a value, a command class carries the input and the work, validators hold the rules, a read model serves the data, and the builder wires them by convention. Arc owns the HTTP, the binding, the rule order, and the result envelope. -## Next steps +## Next step + +The server works; now give it a user interface. [Continue in the browser](continue-in-the-browser.md) generates a typed client from these same classes and calls the server from a small React page. + +When you want to go deeper: - [Commands](../commands/index.md) for command context, outcomes, and operations. - [Queries](../queries/index.md) for arguments, paging, and live queries. diff --git a/Samples/Tasks/Features/Tasks/Registration/for_RegisterTask/when_validating/with_an_empty_title.ts b/Samples/Tasks/Features/Tasks/Registration/for_RegisterTask/when_validating/with_an_empty_title.ts index b050493e..96bbe4f3 100644 --- a/Samples/Tasks/Features/Tasks/Registration/for_RegisterTask/when_validating/with_an_empty_title.ts +++ b/Samples/Tasks/Features/Tasks/Registration/for_RegisterTask/when_validating/with_an_empty_title.ts @@ -12,7 +12,7 @@ describe('when validating a task with an empty title', given(a_task_registration }); afterAll(async () => { await context.scenario.dispose(); }); it('should report the authored rule for title', () => { - result.shouldHaveValidationErrors().shouldHaveValidationErrorFor('title'); + result.shouldHaveValidationErrors().shouldHaveValidationErrorForMember('title'); }); it('should not invoke the handler', () => { context.tasks.all().should.have.lengthOf(0); }); })); From 91944f2aea3481c671dd6b40cc7f59ae0fc19fea Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 05:43:05 +0200 Subject: [PATCH 2/7] Rewrite getting started as a tour and add a browser continuation Open on the reader's scenario, show the full Tasks main.ts, and move the .NET comparison to a closing table. The new Continue in the browser lesson generates the Tasks proxies and calls the server from a React page with the published @cratis/arc.react hooks. --- .../continue-in-the-browser.md | 257 ++++++++++++++++++ Documentation/getting-started/index.md | 154 +++++++---- Documentation/getting-started/toc.yml | 2 + 3 files changed, 365 insertions(+), 48 deletions(-) create mode 100644 Documentation/getting-started/continue-in-the-browser.md diff --git a/Documentation/getting-started/continue-in-the-browser.md b/Documentation/getting-started/continue-in-the-browser.md new file mode 100644 index 00000000..ff3c58d3 --- /dev/null +++ b/Documentation/getting-started/continue-in-the-browser.md @@ -0,0 +1,257 @@ +--- +title: Continue in the browser +description: Generate typed proxies from the Tasks sample, call its command from a React form, and watch an observable query update the page without a reload. +--- + +The Tasks server answers `curl`. A real user needs a page, and the usual next step is a hand-written `fetch` for every endpoint: a URL string, a body type that mirrors the server's fields, and a copy of the validation rules so the form can complain before submitting. All of that drifts the first time someone renames a field on the server. + +Arc generates that client for you. The proxy generator reads the same classes the server serves and writes a typed TypeScript class per command and query, with the route, the fields, the client-safe validation rules, and a React hook. You write the page; the proxies keep it in step with the server. + +In this lesson you create a small Vite and React app beside your clone, generate the Tasks proxies into it, and build one page that registers tasks and shows a live list. By the end, a task you register appears in the list without a reload. + +## Before you start + +Finish [Get started](index.md) first, and keep the Tasks server running on `127.0.0.1:3000`. You also need `npm`. + +The browser side uses the published client packages `@cratis/arc` and `@cratis/arc.react` 22.19.1, the same versions the Library sample uses. They are the Arc frontend packages, released from the Arc repository; the generator in this repository targets them. + +## Create the web app + +From the folder that contains your `Arc.TypeScript` clone, create a sibling folder: + +```bash +mkdir -p tasks-web/src/generated +cd tasks-web +``` + +Create `package.json`: + +```json title="tasks-web/package.json" +{ + "name": "tasks-web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", + "@cratis/fundamentals": "7.19.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "reflect-metadata": "0.2.2", + "tsyringe": "^4.10.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^7.0.2", + "vite": "^8.2.2" + } +} +``` + +Create `tsconfig.json`. The generated models use legacy decorators, so `experimentalDecorators` and `emitDecoratorMetadata` are required: + +```json title="tasks-web/tsconfig.json" +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} +``` + +Create `vite.config.ts`. The dev server forwards API calls and the live-query connection to the Tasks server, so the browser talks to one origin: + +```typescript title="tasks-web/vite.config.ts" +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 5173, + strictPort: true, + proxy: { + '/api': { target: 'http://127.0.0.1:3000' }, + '/.cratis': { target: 'http://127.0.0.1:3000', ws: true } + } + } +}); +``` + +Create `index.html`: + +```html title="tasks-web/index.html" + + + + + Tasks + + +
+ + + +``` + +Then install: + +```bash +npm install +``` + +## Generate the proxies + +Go back to your clone and run the generator against the Tasks sample, writing into the web app's `src/generated` folder: + +```bash +cd ../Arc.TypeScript +node Source/Tools/ProxyGenerator/dist/cli.js \ + --project "$PWD/Samples/Tasks/tsconfig.json" \ + --artifacts "$PWD/Samples/Tasks/Features" \ + --output "$PWD/../tasks-web/src/generated" \ + --use-generated-metadata \ + --use-proxy-file-suffix +``` + +The generator prints `Generated 7 changed file(s)`. It also prints one note, `Server-only validator rule on value`, for the `TaskTitleValidator` rule: a `must(...)` callback is code, and code does not travel to the browser. You now have: + +```text +src/generated/Tasks/Listing/AllTasks.proxy.ts +src/generated/Tasks/Listing/ObserveAllTasks.proxy.ts +src/generated/Tasks/Listing/TaskById.proxy.ts +src/generated/Tasks/Listing/TaskItem.proxy.ts +src/generated/Tasks/Listing/index.ts +src/generated/Tasks/Registration/RegisterTask.proxy.ts +src/generated/Tasks/Registration/index.ts +``` + +The generator never imported or ran the server. It read the TypeScript source with the compiler API. Here is the heart of `RegisterTask.proxy.ts`: + +```typescript +export class RegisterTaskValidator extends CommandValidator { + constructor() { + super(); + this.ruleFor(c => c.title).notEmpty().withMessage('A title is required'); + this.ruleFor(c => c.title).maxLength(100).withMessage('A title can have at most 100 characters'); + } +} + +export class RegisterTask extends Command implements IRegisterTask { + readonly route: string = '/api/tasks/registration/register-task'; + readonly validation: CommandValidator = new RegisterTaskValidator(); +``` + +The route matches the server's. The `notEmpty` and `maxLength` rules came across from `RegisterTaskValidator`, word for word. The `TaskId` concept arrives as a `Guid` and `TaskTitle` as a `string`, because concepts travel as their underlying value. Do not edit these files; regenerate them when the server changes. + +## Build the page + +Create `src/TaskBoard.tsx`: + +```tsx title="tasks-web/src/TaskBoard.tsx" +import { useState, type FormEvent } from 'react'; +import { Guid } from '@cratis/fundamentals'; +import { RegisterTask } from './generated/Tasks/Registration/RegisterTask.proxy'; +import { ObserveAllTasks } from './generated/Tasks/Listing/ObserveAllTasks.proxy'; + +export function TaskBoard() { + const [registerTask, setValues] = RegisterTask.use(); + const [tasks] = ObserveAllTasks.use(); + const [title, setTitle] = useState(''); + const [message, setMessage] = useState(''); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setValues({ id: Guid.create(), title }); + const result = await registerTask.execute(); + if (result.isSuccess) { + setTitle(''); + setMessage('Task registered.'); + } else { + setMessage(result.validationResults.map(item => item.message).join(' ')); + } + }; + + return
+

Tasks

+
void submit(event)}> + + setTitle(event.target.value)} /> + +

{message}

+
+

{tasks.data.length} tasks

+
    {tasks.data.map(task =>
  • {task.title}
  • )}
+
; +} +``` + +Two hooks do the work: + +- `RegisterTask.use()` returns the command instance and a setter. `setValues` writes the fields onto the instance immediately, so the next line can `execute()` it. The result has the same `isSuccess` and `validationResults` you saw with `curl`. +- `ObserveAllTasks.use()` subscribes to the observable query and returns its current result. `tasks.data` starts as an empty array and re-renders the component every time the server's list changes. + +Create `src/main.tsx`: + +```tsx title="tasks-web/src/main.tsx" +import 'reflect-metadata'; +import { createRoot } from 'react-dom/client'; +import { Arc } from '@cratis/arc.react'; +import { QueryTransportMethod } from '@cratis/arc/queries'; +import { TaskBoard } from './TaskBoard'; + +createRoot(document.getElementById('root')!).render( + + + +); +``` + +`` gives every hook below it the same configuration: the API origin (here the page's own origin, which Vite forwards) and how live queries travel. + +:::caution[Choose the WebSocket hub for an anonymous server] +`` connects live queries through the server-sent events hub by default. On this server the SSE hub requires an authenticated caller, and the Tasks sample has no authentication, so the list would stay empty while the browser console reports `SSE hub connection error`. `queryTransportMethod={QueryTransportMethod.WebSocket}` uses the WebSocket hub at `/.cratis/queries/ws`, which accepts anonymous callers. An application with real sign-in can keep the default; see [Multiplexed observable queries](../queries/observable-query-demultiplexer.md). +::: + +## Run it + +In the `tasks-web` folder, check the types and start the dev server: + +```bash +npm run typecheck +npm run dev +``` + +Open . The list shows any tasks you registered with `curl` earlier. Now try three titles: + +| You type | The status line shows | What happened | +| --- | --- | --- | +| Nothing | `A title is required` | The proxy's copy of the rule failed in the browser; no request was sent | +| `!Loud` | `A title cannot begin with an exclamation mark` | The browser had no copy of this rule, so the server checked it and answered 400 | +| `Try the browser` | `Task registered.` | The command succeeded, and the list grows by one without a reload | + +The last row is the observable query at work. The WebSocket hub subscribed to `observeAllTasks` when the page loaded. When `handle()` called `tasks.register(...)`, the sample's `BehaviorSubject` emitted the new list, Arc pushed it over the open connection, and the hook re-rendered the page. Register a task with `curl` from another terminal and it appears in the browser too. + +## Recap + +You generated a typed client from the server's source, called a command from a form with `RegisterTask.use()`, and kept a list live with `ObserveAllTasks.use()`. Simple rules ran in the browser, rules written as code ran on the server, and both reported through the same result shape. When the server's command or query changes, run the generator again and TypeScript tells you which parts of the page no longer fit. + +## Next step + +- [Proxy generation](../proxy-generation/index.md) covers every generator option, including `--watch` for a development loop. +- [Observable queries](../queries/observable-queries.md) explains sources, snapshots, paging, and authorization for live queries. +- [Explore the Library sample](library-sample.md) shows a larger React app with paging, authorization, and Chronicle. +- The shared [Arc frontend documentation](/arc/frontend/) covers the client packages in depth. diff --git a/Documentation/getting-started/index.md b/Documentation/getting-started/index.md index 1f604111..cb994201 100644 --- a/Documentation/getting-started/index.md +++ b/Documentation/getting-started/index.md @@ -1,43 +1,21 @@ --- title: Get started with Arc for TypeScript -description: Build the model-bound Tasks sample, register a task with a command, and read it back with a query. +description: Run the Tasks sample, register a task with a command, watch Arc reject bad input, and read the task back with a query, all without writing an HTTP route. --- -You can build an Arc backend without writing an HTTP route. In this walkthrough you run the Tasks sample, register a task through a command, and read it back through a query. The backend is a standalone Node.js server: no event store or database is required. +Say you need a small task API: register a task, list the tasks, and refuse a task without a title. In a plain Node.js server that means a route per operation, body parsing, a validation response format, status-code mapping, and a client that has to know all of it. Every endpoint repeats the same plumbing, and every rename risks breaking the client. -By the end you have a server on `127.0.0.1:3000` that answers a command, rejects invalid input before your code runs, and serves the task you stored. +With Arc you write the parts that are yours: a command class that registers a task, a validator with the rules, and a read model with the queries. Arc serves them over HTTP, checks input before your code runs, and wraps every answer in the same result shape. + +In this walkthrough you run the Tasks sample from this repository and talk to it with `curl`. By the end you have a server on `127.0.0.1:3000` that accepts a command, rejects invalid input before the handler runs, and serves the tasks you stored. No event store or database is involved. :::caution[Source preview] -No Arc for TypeScript package is published to npm. Work inside a clone of this repository; the API may still change. Check the [capability reference](../reference/capabilities.md) before you use a feature in a larger application. +No Arc for TypeScript server package is published to npm yet. Work inside a clone of this repository; the API may still change. Check the [capability reference](../reference/capabilities.md) before you rely on a feature in a larger application. ::: -## Arc-only setup beside .NET - -TypeScript (the [Tasks entry point](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/main.ts)): - -```typescript -import { ArcApplication } from '@cratis/arc.core'; -const builder = ArcApplication.createBuilder(); -await builder.discover(new URL('./Features/', import.meta.url)); -const app = await builder.build(); -await app.run({ port: 3000 }); -``` - -C# ([Arc standalone builder](https://github.com/Cratis/Arc/blob/main/Documentation/backend/csharp/core/getting-started.md)): - -```csharp -var builder = ArcApplication.CreateBuilder(args); -builder.AddCratisArc(); -var app = builder.Build(); -app.UseCratisArc(); -await app.RunAsync(); -``` - -The Tasks sample sets `Cratis:Arc:Development` in [appsettings.json](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/appsettings.json); do not use this development setting on an exposed host. The standalone Node host starts and maps Arc in `app.run()`. In Express, Fastify, or Hono, the host-native adapter maps routes instead. Node setup reads optional `appsettings.json` (`Cratis:Arc`) and `Cratis__...` environment variables; see [Configuration](../configuration/index.md). +## Build and run the sample -## Build and run - -Use Node.js 22.19 or later, Git, Corepack, and `curl`. The sample keeps tasks in memory and listens on loopback, port 3000. A restart clears its data. +You need Node.js 22.19 or later, Git, Corepack, and `curl`. ```bash git clone https://github.com/Cratis/Arc.TypeScript.git @@ -48,7 +26,13 @@ yarn build yarn workspace @cratis/arc.core.sample.tasks start ``` -`app.run()` keeps the server running until Ctrl+C (SIGINT), SIGTERM, or an explicit `app.stop()`, then closes gracefully. Set `PORT` to use another port. Leave the server running, and in another terminal register a task with any valid UUID: +The server listens on `127.0.0.1:3000` and keeps its tasks in memory, so a restart clears them. Set `PORT` to use another port. It runs until you press Ctrl+C or send SIGTERM, then closes its connections gracefully. + +Leave it running and open a second terminal for the rest of this page. + +## Register a task + +Send the `RegisterTask` command with any UUID and a title: ```bash curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task \ @@ -56,46 +40,120 @@ curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task \ -d '{"id":"1a638f8e-4444-4444-8888-a0b10cdd9977","title":"Write a guide"}' ``` -The HTTP status is 200 and the command result looks like this (the correlation ID differs on every request): +The answer is HTTP 200 with a command result. The correlation ID differs on every request: ```json {"correlationId":"0c2d6872-c3cb-4a84-af93-1084caa4d22d","isAuthorized":true,"validationResults":[],"exceptionMessages":[],"exceptionStackTrace":"","authorizationFailureReason":"","isValid":true,"hasExceptions":false,"isSuccess":true,"response":"1a638f8e-4444-4444-8888-a0b10cdd9977"} ``` -Now leave out `title`. Arc answers 400 with a `malformedRequest` validation result, and the handler never runs. +Nobody wrote that route. Arc derived `/api/tasks/registration/register-task` from the folder the command lives in and its class name. It parsed the body against the command's declared fields, turned the ID string into a typed `TaskId`, ran the validators, and called the command's `handle()` method. The `response` is the ID that `handle()` returned, encoded back to a string. The flags (`isSuccess`, `isValid`, `isAuthorized`, `hasExceptions`) are the same on every command, so a client checks one shape everywhere. -## Read what you registered +## Break the rules + +Now send a task with an empty title: ```bash -curl http://127.0.0.1:3000/api/tasks/listing/all-tasks -curl 'http://127.0.0.1:3000/api/tasks/listing/task-by-id?id=1a638f8e-4444-4444-8888-a0b10cdd9977' +curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task \ + -H 'content-type: application/json' \ + -d '{"id":"2b638f8e-4444-4444-8888-a0b10cdd9977","title":""}' ``` -The first result's `data` is an array holding `{ "id": "1a638f8e-4444-4444-8888-a0b10cdd9977", "title": "Write a guide" }`. The second returns that object directly. `taskById` binds `id` by name, case-insensitively; a missing or invalid UUID produces a 400 `malformedRequest` result. +The answer is 400, and `validationResults` holds one entry: -## Check a rule without running the command +```json +{"severity":3,"message":"A title is required","members":["title"],"reason":"rule"} +``` + +That message comes from the sample's validator, and `members` tells a form which field to mark. The handler never ran, so nothing was stored. + +Leave `title` out of the body entirely and you get a different 400: one result with reason `malformedRequest` and no members. Arc separates a request with the wrong *shape* (a missing field, a wrong type, broken JSON) from a request that breaks a *rule* a user can fix. Only rules carry a message meant for a person. -Every command also has a validation route. `POST /validate` runs authorization and validation, then stops: +A frontend often wants to check a rule before the user presses Save. Every command has a second route for that: `POST /validate` runs authorization and validation, then stops: ```bash curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task/validate \ -H 'content-type: application/json' \ - -d '{"id":"1a638f8e-4444-4444-8888-a0b10cdd9977","title":""}' + -d '{"id":"2b638f8e-4444-4444-8888-a0b10cdd9977","title":"!Loud"}' +``` + +The answer is 400 with `A title cannot begin with an exclamation mark` for the member `title`. That rule belongs to the task title itself, wherever it appears. [Your first command](your-first-command.md) shows where each rule lives. + +## Read what you registered + +Queries are GET requests: + +```bash +curl http://127.0.0.1:3000/api/tasks/listing/all-tasks +curl 'http://127.0.0.1:3000/api/tasks/listing/task-by-id?id=1a638f8e-4444-4444-8888-a0b10cdd9977' +``` + +The first answer's `data` is an array holding `{ "id": "1a638f8e-4444-4444-8888-a0b10cdd9977", "title": "Write a guide" }`. The second answers with that object directly. Arc bound `id` from the query string by name and converted it to a `TaskId`; a missing or invalid UUID answers 400 with `malformedRequest`. An ID nobody registered answers 200 without a `data` property: absence is an answer, not an error. + +Add `?pageSize=1&sortBy=title` to the first URL and Arc pages and sorts the list for you, reporting the totals in `paging`. The query method itself only returns an array. + +## See what started the server + +The whole entry point is [`Samples/Tasks/main.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/main.ts): + +```typescript title="Samples/Tasks/main.ts" +import { ArcApplication } from '@cratis/arc.core'; +import { Tasks } from './Features/Tasks/Tasks.js'; +import { metadata } from './Features/generatedMetadata.js'; + +// The workspace command runs from Samples/Tasks and binds Development from appsettings.json. +const builder = ArcApplication.createBuilder(); +builder.useGeneratedMetadata(metadata); +builder.services.addSingleton(Tasks); +await builder.discover(new URL('./Features/', import.meta.url)); +export const app = await builder.build(); +await app.run({ port: Number(process.env.PORT ?? 3000) }); ``` -The answer is 400 with one result: `{"severity":3,"message":"A title is required","members":["title"],"reason":"rule"}`. That message comes from the sample's validator, not from Arc. +Each line has one job: + +| Line | What it does | +| --- | --- | +| `createBuilder()` | Starts an Arc application and reads optional `appsettings.json` (`Cratis:Arc`) and `Cratis__...` environment variables | +| `useGeneratedMetadata(metadata)` | Installs parameter and return-type information that the proxy generator extracted from the source, because TypeScript erases types at runtime | +| `services.addSingleton(Tasks)` | Registers the in-memory store that the command and queries receive | +| `discover(...)` | Imports the modules under `Features/` (skipping `for_*` spec folders) and picks up commands, read models, and validators by their decorators | +| `build()` | Checks the whole graph (every injected service registered, no lifetime mismatches, no misplaced decorators) before any listener opens | +| `run(...)` | Starts the standalone Node.js host and maps every route | + +If you forget to register `Tasks`, `build()` throws `Missing service: Tasks` at startup, instead of the first request failing in production. + +The sample's [`appsettings.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/appsettings.json) sets `Cratis:Arc:Development` to `true`. Development mode is for a local machine; do not enable it on an exposed host. [Configuration](../configuration/index.md) lists every setting. + +To serve the same artifacts from Express, Fastify, or Hono instead of the standalone host, you keep the builder and hand its routes to the framework's adapter; see the [hosting overview](../overview.md). ## Look at what the server describes -The running application describes itself. `GET /.cratis/commands` and `GET /.cratis/queries` list every operation with its route and the JSON Schema of its input, and `GET /openapi.json` returns an OpenAPI 3.1 document. See [Introspection](../introspection/index.md) and [OpenAPI](../open-api/index.md). +A running Arc application describes itself. `GET /.cratis/commands` and `GET /.cratis/queries` list every operation with its route and the JSON Schema of its input, and `GET /openapi.json` returns an OpenAPI 3.1 document: + +```bash +curl http://127.0.0.1:3000/.cratis/commands +``` + +The first entry names `RegisterTask`, its route, and a schema that requires `id` as a UUID and `title` as a string. [Introspection](../introspection/index.md) and [OpenAPI](../open-api/index.md) cover both. ## Recap -You started a server with no routing code, called a command and two queries over HTTP, and saw Arc reject bad input before the handler ran. All of that came from a few decorated classes. +You started a server with no routing code. A command registered a task, Arc refused an empty title with a message for the right field, a query returned the stored task, and paging and sorting came for free. The entry point is six statements, and `build()` checked the wiring before the server accepted a request. + +## If you know Arc on .NET + +The concepts carry over; the spelling is TypeScript: + +| Arc on .NET | Arc for TypeScript | +| --- | --- | +| `ArcApplication.CreateBuilder(args)` | `ArcApplication.createBuilder()` | +| `[Command]` record with `Handle()` | `@command()` class with `handle()` | +| `[ReadModel]` record with static query methods | `@readModel()` class with static `@query()` methods | +| Assembly discovery | `builder.discover(folderUrl)` or `builder.add(...)` | +| `app.UseCratisArc()` and `RunAsync()` | `app.run()` on the standalone host, or a framework adapter | + +[Coming from Express and NestJS](../coming-from-express-and-nestjs.md) compares Arc with the Node.js code you may write today. -## Next steps +## Next step -- [Your first command](your-first-command.md) reads the sample file by file and explains what each decorator does. -- [Vertical slices](../vertical-slices.md) shows how to keep a command, its validator, and any events in one file. -- [Hosting overview](../overview.md) helps you choose between the standalone host and Express, Fastify, or Hono. -- [Generate proxies](../proxy-generation/index.md) gives your frontend typed clients for these operations. +Open the sample and read it file by file in [Your first command](your-first-command.md). After that, [Continue in the browser](continue-in-the-browser.md) generates a typed client and calls this server from a React page. diff --git a/Documentation/getting-started/toc.yml b/Documentation/getting-started/toc.yml index d19de0aa..126cb812 100644 --- a/Documentation/getting-started/toc.yml +++ b/Documentation/getting-started/toc.yml @@ -2,5 +2,7 @@ href: index.md - name: Your first command href: your-first-command.md +- name: Continue in the browser + href: continue-in-the-browser.md - name: Explore the Library sample href: library-sample.md From 31252f0bbe3b37ad5a1d84875ed9ebb3238ec769 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 05:47:26 +0200 Subject: [PATCH 3/7] Teach where a command rejects, and state the filter limitation Command validation fixes the sample import, adds a rejection-phase table, and shows readModelForValidation for rules that need stored state. Command filters now says model-bound commands have no global filter and lists the per-command alternatives. --- Documentation/commands/command-filters.md | 19 +++- Documentation/commands/command-validation.md | 95 +++++++++++++++----- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/Documentation/commands/command-filters.md b/Documentation/commands/command-filters.md index c494f7dd..c249ab8c 100644 --- a/Documentation/commands/command-filters.md +++ b/Documentation/commands/command-filters.md @@ -3,7 +3,11 @@ title: Command filters description: Validate low-level defineCommand and defineQuery definitions with validate callbacks and shared filters, and keep Zod schemas for shape only. --- -Low-level definitions made with `defineCommand` and `defineQuery` do not use validator classes. They take a `validate` callback and a list of `filters`, which run in the same pipeline stage as model-bound validators. Use a filter when one rule applies to many operations. +Low-level definitions made with `defineCommand` and `defineQuery` do not use validator classes. They take a `validate` callback and a list of `filters`, which run in the same pipeline stage as model-bound validators. Use a filter when one rule applies to many low-level operations: you write it once and list it on each definition that needs it. + +:::caution[Filters apply only to low-level definitions] +A filter runs only for the `defineCommand` or `defineQuery` definitions that list it. There is no global filter that runs for every command, and `@command()` classes cannot take filters at all. This differs from Arc on .NET, where an `ICommandFilter` runs for every model-bound command. See [Cross-cutting rules for model-bound commands](#cross-cutting-rules-for-model-bound-commands) for what to use instead. +::: ## Validate and share a filter @@ -40,6 +44,19 @@ Arc converts every schema to JSON Schema at startup. Types without a JSON repres The caller gets 400 with one result: reason `validatorFailed`, message `Validation failed`, and no members. The exception text is never sent; the original error goes to the `logger` option. When the request was already cancelled, the failure is reported as an exception instead. +## Cross-cutting rules for model-bound commands + +When a rule should apply to many `@command()` classes, pick the mechanism by what the rule is about: + +| The rule is about | Use | Runs on `/validate` | +| --- | --- | --- | +| Who may call a group of commands | A named policy with `builder.addAuthorizationPolicy(name, policy)` and `@authorize({ policy: name })` on each command; see [Authorization policies](../core/authorization.md) | Yes | +| A value that appears in many commands | A [concept validator](../concepts.md#validate-a-concept-everywhere), which runs wherever the concept is a field | Yes | +| One command's input | A [`CommandValidator`](command-validation.md) per command | Yes | +| Wrapping every command's execution, such as ambient state or timing | `builder.addCommandExecutionRunner(...)`; see [Command execution scopes](command-execution-scopes.md) | No; it runs only after validation passes | + +Each of these is opted into per command or per value, except the execution runner, which runs for every validated command. Nothing runs a validation rule for every command automatically, so a new command is not covered by a rule you wrote for the others until you declare it. + ## Related - [Low-level definitions](low-level-definitions.md) diff --git a/Documentation/commands/command-validation.md b/Documentation/commands/command-validation.md index f18f0e0c..959a8e34 100644 --- a/Documentation/commands/command-validation.md +++ b/Documentation/commands/command-validation.md @@ -1,18 +1,18 @@ --- title: Command validation -description: Keep shape checks at the wire boundary and give callers field-specific messages with CommandValidator rules, services, and asynchronous checks. +description: Give callers field-specific messages with CommandValidator rules, choose the phase where each rejection belongs, and read stored state in a rule with readModelForValidation. --- -A task title arrives as a string, but it must not be blank. The wire type goes on the command field; the business rule gets its own validator. Arc runs the validator before `handle()`, and on the command's `/validate` route, so a frontend can check input before submitting it. +A task title arrives as a string, but it must not be blank, and a rename to the same title is pointless. If those checks live inside `handle()`, a form cannot ask about them before the user presses Save, and every handler grows its own error format. Arc gives rules a place of their own: a validator runs before `handle()`, answers with a message for the exact field, and also runs on the command's `/validate` route, so a frontend can check input without changing anything. ## Add a command rule -The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) declares a validator beside `RegisterTask`: +The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) keeps its validator in the same file as `RegisterTask`, so the imports it needs are the Arc ones: -```typescript -import { CommandValidator, validator } from '@cratis/arc.core'; -import { RegisterTask } from './RegisterTask.js'; +```typescript title="Features/Tasks/Registration/Registration.ts (excerpt)" +import { command, CommandValidator, validator } from '@cratis/arc.core'; +// RegisterTask is declared above in the same file. @validator(RegisterTask) export class RegisterTaskValidator extends CommandValidator { constructor() { @@ -31,16 +31,69 @@ Send `{ "id": "", "title": "" }` to `POST /api/tasks/registrati {"severity":3,"message":"A title is required","members":["title"],"reason":"rule"} ``` -`handle()` does not run. A type mismatch, missing required field, or malformed JSON fails earlier with `malformedRequest`, not a rule message. +Here is what happened. Arc bound the body to a `RegisterTask`, ran every validator for the command and for the concepts on its fields, and collected all their results. A failure does not stop the other rules, so a form can show every problem at once. Because a result was above the allowed severity, Arc answered 400 and never reached `handle()`. A type mismatch, a missing required field, or malformed JSON fails earlier with `malformedRequest` and no rule message. -## Shape or rule? +## Choose where to reject -| Put it on the field | Put it in a validator | -| --- | --- | -| Types, required and optional fields, defaults | Business rules a user can fix, with a message and the member it concerns | -| Anything where failure means the client sent the wrong shape | Rules that need services or asynchronous checks | +Validators are one of several places a command can say no. Each place sees different information and runs at a different moment, so the right one depends on what the decision needs: + +| The decision depends on | Put it in | Runs on `/validate` | Caller sees | +| --- | --- | --- | --- | +| The request's shape: types, required and optional fields | `@field` declarations | Yes | 400 `malformedRequest`, no message or members | +| Who is calling | `@roles`, `@authorize`, or a policy | Yes | 401 or 403 | +| One value, wherever it appears (a title format) | A [concept validator](../concepts.md#validate-a-concept-everywhere) | Yes | 400 with your message and member | +| The command's own fields, or a service | A `CommandValidator` rule | Yes | 400 with your message and member | +| Stored state for the entity the command is about | A `CommandValidator` rule that calls `readModelForValidation` | Yes | 400 with your message and member | +| Data you load anyway to do the work (the task must exist, the caller must own it) | `provide()` returning `rejected(...)` or `denied(...)` | No | 400 or 403 | +| A decision only the handler can make | `handle()` returning `rejected(...)` or `denied(...)` | No | 400 or 403 | + +Two rules of thumb pick the row: + +- **Reject as early as the information allows.** Everything down to the readModelForValidation row runs on `/validate`, so a form gets the message before submitting. `provide()` and `handle()` run only on execution. +- **Keep access control out of validators.** A trusted direct caller can lower the blocking severity, which lets validation results through; nothing lowers authorization or `denied(...)`. See [Authorizing commands and queries](../authorizing-commands-and-queries.md). + +A check against stored state tells you what was true when it ran. Another request can change that state before `handle()` runs, so enforce a rule that must hold under concurrency at the storage boundary that performs the change, not only in a validator. [Command outcomes](command-outcomes.md) covers `rejected` and `denied`; [Model-bound commands](model-bound/index.md#prepare-data-in-provide) shows `provide()`. + +## Read stored state in a rule + +"The task already has this title" needs the stored task. A rule can read the read model that belongs to the command's key with `readModelForValidation(Type)` from `@cratis/arc.core`: + +```typescript title="RenameTask.ts" +import { field } from '@cratis/fundamentals'; +import { command, CommandValidator, key, readModelForValidation, validator } from '@cratis/arc.core'; +import { TaskView } from './TaskView.js'; + +@command() +export class RenameTask { + @field(String) @key() id!: string; + @field(String) title!: string; + + handle(): void { + // Rename the task in your storage. + } +} -A shape failure produces one result with reason `malformedRequest`, no message a user can act on, and no members. A rule that applies to a value wherever it appears, such as a title format, belongs in a [concept validator](../concepts.md#validate-a-concept-everywhere). +@validator(RenameTask) +export class RenameTaskValidator extends CommandValidator { + constructor() { + super(); + this.ruleFor(command => command.title).mustAsync(async title => { + const current = await readModelForValidation(TaskView, { optional: true }); + return current === null || current.title !== title; + }).withMessage('The task already has this title'); + } +} +``` + +`TaskView` is your read model, with at least a string `title` field. Arc does not load it itself; a registered read-model resolver does. The [MongoDB](../mongodb/index.md) and experimental [Chronicle](../chronicle/read-models/index.md) integrations register resolvers for the models you configure, and `builder.addReadModelForCommandResolver(token)` adds your own, as described in [Command context](command-context.md#load-a-read-model-by-key). + +What happens when the rule runs: + +- Arc resolves the command key from the `@key()` field and asks the resolver that owns `TaskView` for that key. The resolver also receives the command context, with the caller's tenant, so it can scope the lookup. +- `{ optional: true }` returns `null` when nothing is stored, so the rule decides what absence means. Here, a task that does not exist yet has no title to repeat. Without `optional`, a missing model makes the rule throw, and the caller gets reason `validatorFailed` instead of your message. +- Renaming task `t-1` from `Old` to `Old` answers 400 with `The task already has this title` for `title`, on both the execute and `/validate` routes. Renaming it to `New` succeeds. + +`readModelForValidation` works only inside a validator of a model-bound command, during validation. Called anywhere else, it throws. Validators do not receive read models through their constructors. ## Rule vocabulary @@ -62,16 +115,16 @@ A shape failure produces one result with reason `malformedRequest`, no message a A validator may declare constructor dependencies with `@injectable(Service)` or `static inject = [Service] as const`. Register the service with `builder.services`. Arc preflights the dependencies and constructs each validator once during build, which catches invalid selectors before any request. It then resolves fresh validators and services in each execution scope. -Read models loaded by command key are not injected into validators. Make an explicit, tenant-scoped lookup in a rule when validation needs stored state. +A validator that throws, or whose dependency cannot be resolved, never reports success. The caller gets 400 with reason `validatorFailed` or `dependencyUnavailable`, no exception text, and the error goes to the configured logger. + +## Low-level definitions -## What validation is not +`defineCommand` and `defineQuery` do not use validator classes; they keep their `validate` and `filters` callbacks, which run in the same pipeline stage. See [Command filters](command-filters.md). -Authentication and authorization run before any rule, and a trusted direct caller can lower the blocking severity. Never put access control in a validator; see [Authorizing commands and queries](../authorizing-commands-and-queries.md). Which severities block is covered in [Validation severity filtering](validation-severity-filtering.md). +## Recap -Low-level `defineCommand` and `defineQuery` definitions keep their `validate` and `filters` callbacks; see [Command filters](command-filters.md). +Shape belongs on `@field`, a value's own rules on its concept, a command's rules in a `CommandValidator`, and access control in authorization. A rule that needs stored state reads it with `readModelForValidation`; a decision that needs the loaded data belongs in `provide()`. Everything up to validation also answers on `/validate`, which is what lets a form speak up before the user submits. -## Related +## Next step -- [Query validation](../queries/validation.md) -- [Concepts](../concepts.md) -- [Testing commands](../testing/commands.md) +[Validation severity filtering](validation-severity-filtering.md) explains which severities block and how a caller can let warnings through. To prove a rule in a spec, see [Testing commands](../testing/commands.md). From 8be619eeb16d992a493aa151fad86abf4f601941 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 05:50:45 +0200 Subject: [PATCH 4/7] Cover query return types, absence, and live-query paging and access Model-bound queries now explain the discovery contract, parameter binding with and without generated metadata, async methods, and what absence and failure return. Observable queries fix the decorator claim and add sections on missing documents, subscription lifetime, paging, and authorization. --- Documentation/queries/model-bound/index.md | 72 +++++++++++--- Documentation/queries/observable-queries.md | 105 ++++++++++++++++++-- 2 files changed, 151 insertions(+), 26 deletions(-) diff --git a/Documentation/queries/model-bound/index.md b/Documentation/queries/model-bound/index.md index b2c683b7..efc58c9a 100644 --- a/Documentation/queries/model-bound/index.md +++ b/Documentation/queries/model-bound/index.md @@ -1,15 +1,15 @@ --- title: Model-bound queries -description: Put static query methods on a read-model class, list their arguments and services in order, and declare observable queries. +description: Put static query methods on a read-model class, bind their arguments and services, and know which return types Arc accepts, how it awaits them, and what absence looks like. --- -Put related read operations on a `@readModel()` class as static methods. Each `@query(...)` method becomes a route, and its parameters are bound from the request or resolved as services. +A task list, a lookup by ID, and a live board all read the same kind of data. Written as separate routes, each needs its own argument parsing, its own "not found" convention, and its own response shape. In Arc you put these reads on the read model they return, as static methods. Each `@query()` method becomes a route, its parameters are bound from the request or resolved as services, and every answer uses the same `QueryResult` envelope. ## Declare a read model and its queries The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/Listing.ts) exposes a list, a lookup, and a live list: -```typescript +```typescript title="Features/Tasks/Listing/Listing.ts" import { field } from '@cratis/fundamentals'; import { query, readModel, service } from '@cratis/arc.core'; import type { BehaviorSubject } from 'rxjs'; @@ -33,11 +33,21 @@ export class TaskItem { } ``` -The `@field` declarations describe the shape the query returns, and the [proxy generator](../../proxy-generation/index.md) uses them for the frontend model. +With the sample running, `GET /api/tasks/listing/all-tasks` returns the list, and `GET /api/tasks/listing/task-by-id?id=` returns one task. The `@field` declarations describe the shape each query returns; Arc encodes it on the way out, and the [proxy generator](../../proxy-generation/index.md) uses the same declarations for the frontend model. -## Describe every parameter, in order +## What makes a method a query -Each parameter gets one descriptor, in the **same order as the method signature**: +Arc serves a method when all three hold: + +- the class is marked `@readModel()`, +- the method is `static`, +- the method is marked `@query(...)`. + +Any other static method on the class is an ordinary helper and gets no route. That lets you keep shared filtering or mapping code next to the queries without exposing it. Arc rejects the declarations that would otherwise be silently ignored. `@query()` on an instance or private method throws as soon as the class is loaded, and `@roles`, `@authorize`, `@allowAnonymous`, or `@path` on a static method without `@query()` fails at `build()`. + +## Bind every parameter + +Each parameter is one of three kinds: | Descriptor | Binds | | --- | --- | @@ -45,15 +55,45 @@ Each parameter gets one descriptor, in the **same order as the method signature* | `service(Token)` | A service from the execution scope; see [Dependency injection](../../dependency-injection.md) | | `queryOptions()` | The request's paging and sorting; see [Paging and sorting](paging.md) | -With [generated artifact metadata](../../proxy-generation/generated-artifact-metadata.md) installed, Arc infers argument names, types, concrete services, and observable returns from these declarations. Without it, standard decorators cannot see parameter types: use `@query(argument('id', TaskId), service(Tasks))` and declare `{ observable: true }` on observable methods. Legacy `experimentalDecorators` and `emitDecoratorMetadata` can infer class-valued services; explicit descriptors always win. TypeScript error TS1241 on a `@query(...)` usually means the descriptors do not match the parameters; see [Troubleshooting](../../troubleshooting.md#ts1241-unable-to-resolve-signature-of-method-decorator). +Standard decorators cannot see parameter types, so Arc needs to learn them somewhere: + +- **With [generated artifact metadata](../../proxy-generation/generated-artifact-metadata.md)**, as the sample uses, `@query()` is enough. The generator reads the source: a primitive, concept, or array of them becomes a named argument, and a concrete class becomes a service. That is how `taskById(id: TaskId, tasks: Tasks)` binds `id` from the query string and `tasks` from the scope. +- **Without it**, list one descriptor per parameter, in the same order as the method signature: `@query(argument('id', TaskId), service(Tasks))`. `allTasks` shows this form; explicit descriptors always win over generated ones. + +A mismatch fails early. TypeScript error TS1241 on a `@query(...)` usually means the descriptors do not match the parameters, and a parameter nobody describes fails at `build()` with `Unbound parameters`. See [Troubleshooting](../../troubleshooting.md#ts1241-unable-to-resolve-signature-of-method-decorator). + +Services resolve from a fresh scope per request, or per subscription for an observable query. A scoped service is never shared between two callers. + +## Return what the caller should see + +A query method returns data, and Arc wraps it: -## Return a value +| The method returns | The caller gets | +| --- | --- | +| An array of the model | `data` is the array. Arc [pages and sorts it in memory](paging.md) when the request asks | +| One model | `data` is the object | +| `undefined` or `null` | A successful result with no `data` property | +| `queryPage(items, totalItems)` | `data` is `items`, and `paging` reports your totals; see [Paging and sorting](paging.md#return-a-page-your-data-source-cut) | +| A value a registered [renderer](../renderers.md) accepts | Whatever the renderer produces, such as a database-side page | +| An observable source | A live query; see [Declare observable queries](#declare-observable-queries) | + +A method can be `async` or return a promise of any of these. Arc awaits it before rendering, so `static async byId(...): Promise` behaves exactly like its synchronous version. The same holds for an observable query: an `async` method that awaits setup work and then returns a source is fine. + +Decorated models and concepts are encoded to their wire shape, so a `TaskId` goes out as a UUID string. + +## Absence is an answer, not an error + +`taskById` returns `undefined` when no task has that ID. The caller gets HTTP 200, `isSuccess: true`, and no `data` property. An empty array is a successful empty list. Arc does not turn absence into a 404, because a query that found nothing did its job. -A query method can return a value or a promise of one: an array, a single model, `undefined`, a [`queryPage`](paging.md#return-a-page-your-data-source-cut), or a value a [renderer](../renderers.md) understands. Arc encodes decorated models and concepts to their wire shape. +A thrown error is different. It becomes a failed result with `hasExceptions: true` and status 500, and the message is replaced unless you enable exception details. Let storage failures throw; never catch them and return `undefined` or `[]`, or the caller cannot tell "no task" from "the database is down". + +With generated metadata, Arc also checks the value against the declared return type. A method declared as `TaskItem` that returns `undefined`, or one declared as `TaskItem[]` that returns a single object, fails with an exception instead of sending the caller a shape its generated client does not expect. Declare `TaskItem | undefined` when absence is possible. ## Declare observable queries -A query that returns a live source must declare `{ observable: true }` without generated metadata; generated metadata infers it from the return type before registration, so snapshots, server-sent events, WebSocket admission, introspection, and generated clients know its contract before it runs. Use an RxJS `BehaviorSubject` for an immediate snapshot, or `Subject`/`Observable` when no current value exists. Async iterables and structural subscribables remain supported; `CurrentValueSubject` is deprecated. See [Observable queries](../observable-queries.md). +A query that returns a live source serves a snapshot on GET and streams changes to subscribers. With generated metadata, Arc infers this from the declared return type, as it does for `observeAllTasks`. Without it, add `{ observable: true }`: `@query({ observable: true }, service(Tasks))`. Arc needs to know before registration so snapshots, server-sent events, WebSocket admission, introspection, and generated clients agree on the contract. A snapshot query that returns a live source anyway fails at run time. + +Use an RxJS `BehaviorSubject` when there is always a current value, and `Subject` or `Observable` when there may not be one yet. [Observable queries](../observable-queries.md) covers sources, transports, paging, and authorization. ## Routes and identity @@ -61,10 +101,12 @@ By default the route is `/api//`: `TaskItem.al ## Authorization -`@roles`, `@authorize`, and `@allowAnonymous` work on the read-model class and on query methods. A method's declaration replaces the class declaration. Authorization or a path on a static method without `@query()` is rejected at build instead of being silently ignored. See [Authorizing commands and queries](../../authorizing-commands-and-queries.md). +`@roles`, `@authorize`, and `@allowAnonymous` work on the read-model class and on query methods. A method's declaration replaces the class declaration. A denied caller never reaches the method and gets `isAuthorized: false`. A role says who may call the query, not which rows they may see; see [Authorizing commands and queries](../../authorizing-commands-and-queries.md#queries-roles-and-ownership). + +## Recap + +A read model owns its queries as static `@query()` methods. Generated metadata, or explicit descriptors, tell Arc which parameters are arguments and which are services. Return the data, sync or async; return nothing for absence and throw for failure; return a source for a live query. Arc supplies the route, the envelope, and paging. -## Related +## Next step -- [Query arguments](query-arguments.md) -- [Paging and sorting](paging.md) -- [Testing queries](../../testing/queries.md) +[Query arguments](query-arguments.md) covers optional, array, and concept arguments. Then [Paging and sorting](paging.md) shows how to cut a page in your data source instead of in memory. diff --git a/Documentation/queries/observable-queries.md b/Documentation/queries/observable-queries.md index 37182952..aa4f3405 100644 --- a/Documentation/queries/observable-queries.md +++ b/Documentation/queries/observable-queries.md @@ -7,14 +7,21 @@ A task board should update when someone adds a task, without the browser polling ## Declare an observable query -The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/Listing.ts) marks its live list with `{ observable: true }` and returns a source: +The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/Listing.ts) declares its live list as an ordinary query that returns a source: ```typescript -@query({ observable: true }, service(Tasks)) +@query() static observeAllTasks(tasks: Tasks): BehaviorSubject { return tasks.observeAll(); } ``` -The sample's `Tasks` service keeps a `new BehaviorSubject([])` from RxJS and calls `next(...)` whenever a task is registered. The `{ observable: true }` flag is required: it tells snapshots, server-sent events, WebSocket admission, introspection, and generated clients the query's contract before it runs. +The sample's `Tasks` service keeps a `new BehaviorSubject([])` from RxJS and calls `next(...)` whenever a task is registered. + +Arc has to know that a query is observable before it runs: snapshots, server-sent events, WebSocket admission, introspection, and generated clients all depend on it. The sample installs [generated artifact metadata](../proxy-generation/generated-artifact-metadata.md), which reads the declared return type, so bare `@query()` is enough. Without generated metadata, say so explicitly and list the parameters: + +```typescript +@query({ observable: true }, service(Tasks)) +static observeAllTasks(tasks: Tasks): BehaviorSubject { return tasks.observeAll(); } +``` ## Choose a source @@ -26,7 +33,28 @@ The sample's `Tasks` service keeps a `new BehaviorSubject([])` from | `AsyncIterable` | No current value until the first item | | `CurrentValueSubject` (deprecated) | Legacy current/pending source; use RxJS `BehaviorSubject` or `Subject` instead | -A `BehaviorSubject` exposes its current value, including `undefined`; `Subject` and `ReplaySubject` do not. The core accepts structural subscribables and async iterables without loading RxJS at runtime, so RxJS is an optional peer dependency for consumers using only async iterables. A completed source before its first emission returns an error to a waiting GET; an errored source reports a query failure. Disconnecting cancels the subscription. The query method runs after authorization and validation, and its `context.signal` aborts when the subscription ends. Each subscription owns its own service scope; do not share scoped service instances across subscriptions. +A `BehaviorSubject` exposes its current value, including `undefined`; `Subject` and `ReplaySubject` do not. The core accepts structural subscribables and async iterables without loading RxJS at runtime, so RxJS is an optional peer dependency for consumers using only async iterables. + +## When there is nothing to show + +A live lookup, such as "the task with this ID", may have nothing to show yet. Two different situations look alike from the outside: + +| The source | A snapshot GET answers | A subscriber receives | +| --- | --- | --- | +| Has no current value yet (`Subject`, `Observable`, an empty async iterable) | 202 with `isReady: false` | Nothing until the first emission | +| Emits `undefined` or `null` (a `BehaviorSubject` with nothing stored) | 200, `isReady: true`, no `data` property | A ready result without `data` | + +In both cases the subscription stays open. When the document appears and the source emits it, the subscriber receives it like any other update, and when it disappears again the source can emit `undefined`. Emit `undefined` for "we looked and nothing is there", and leave a source silent only when you genuinely do not know yet; a client can tell the two apart by `isReady`. + +An error is neither. A source that errors ends the subscription with a failed result, and a source that completes before its first value returns an error to a waiting GET. Do not turn a storage failure into an `undefined` emission. + +## Subscription lifetime + +Each subscription runs your query method once, after authorization and validation, and then follows the source it returned: + +- The subscription owns its own service scope for its whole life. Scoped services are not shared between subscribers, and they are disposed when the subscription ends. +- The method can read the caller with `currentContext()` from `@cratis/arc.core`, which returns the subscription's execution context, and its `signal` aborts when the subscription ends. Pass it to anything that must stop with the subscriber. +- The subscription ends when the client disconnects or unsubscribes, when the source completes or errors, or when an [emission guard](observable-query-emission-guards.md) denies an emission. Arc then cancels the source and disposes the scope. ## Read the snapshot and subscribe from the terminal @@ -39,6 +67,51 @@ curl -N -H 'Accept: text/event-stream' http://127.0.0.1:3000/api/tasks/listing/o The first answers 200 with the current tasks in `data`. The second keeps the connection open and prints a `data: ` frame now, and another whenever you register a task. [Using observable queries with curl](using-observable-queries-with-curl.md) covers waiting for a first result and the error codes. +## Page and sort a live list + +The paging and sorting parameters work on an observable query exactly as on a snapshot query, and they apply to **every** emission: + +```bash +curl -N -H 'Accept: text/event-stream' \ + 'http://127.0.0.1:3000/api/tasks/listing/observe-all-tasks?pageSize=1&page=1&sortBy=title&sortDirection=desc' +``` + +With two tasks registered, each frame holds the second task in descending title order, and `paging` reports `{"page":1,"size":1,"totalItems":2,"totalPages":2}`. When a third task arrives, the next frame is sorted and cut again, and `totalItems` follows the whole list. A generated client's `useWithPaging(pageSize)` hook sends the same parameters. + +Arc pages the arrays your source emits, in memory. An observable query cannot return a `queryPage`; generated metadata rejects that declaration at build. For a collection too large to emit whole, narrow what the source emits with query arguments, or use a database integration that observes a query, such as [MongoDB change streams](../mongodb/observing-collections.md). The [paging rules](model-bound/paging.md#request-parameters) for invalid sizes and sort fields are the same as for snapshots. + +## Authorize a live query + +An observable query takes the same `@roles`, `@authorize`, and `@allowAnonymous` declarations as any query, and Arc checks them, with validation, when a subscription opens. A denied snapshot GET answers 401 or 403 with `isAuthorized: false`, following the [status code rules](../authorizing-commands-and-queries.md#status-codes); a denied hub subscription receives an `Unauthorized` frame. The query method never runs for a denied caller. + +That check happens **once**. Arc keeps a copy of the caller's identity for the life of the subscription and does not re-run authorization on each emission, so a role revoked or a session that expires after the subscription opened does not close it. When access must be re-checked while the stream runs, add an [emission guard](observable-query-emission-guards.md), which sees every result before delivery and can end the subscription. + +A role decides who may subscribe, not which rows they see. For a query like "my tasks", filter inside the source by the caller's identity: + +```typescript title="MyTasks.ts" +import { field } from '@cratis/fundamentals'; +import { authorize, currentContext, query, readModel } from '@cratis/arc.core'; +import { BehaviorSubject, map, type Observable } from 'rxjs'; + +const tasks = new BehaviorSubject([]); + +@readModel() +@authorize() +export class OwnedTask { + @field(String) id!: string; + @field(String) title!: string; + @field(String) owner!: string; + + @query({ observable: true }) + static myTasks(): Observable { + const caller = currentContext()?.principal?.id; + return tasks.pipe(map(all => all.filter(task => task.owner === caller))); + } +} +``` + +`@authorize()` turns anonymous callers away before `myTasks` runs, so `caller` is always an authenticated ID. Each subscriber gets their own filtered stream: when a task owned by `ada` is added, only Ada's subscription emits a new list with it. The filter runs in the producer, so rows the caller must not see never enter the result at all. Never leave that filtering to the client. + ## Subscribe over direct server-sent events In a browser on the same origin: @@ -68,7 +141,9 @@ Direct WebSocket frames are `{"type":"Data","data":}`; a `Ping` re ## Use the installed client -The published `@cratis/arc` client subscribes through generated `ObservableQueryFor` proxies. Its default is the [multiplexed WebSocket hub](observable-query-demultiplexer.md). For the direct transports above, set these before subscribing: +The published `@cratis/arc` client subscribes through generated `ObservableQueryFor` proxies over the [multiplexed hub](observable-query-demultiplexer.md). The plain client defaults to the WebSocket hub. The `` provider from `@cratis/arc.react` defaults to the SSE hub instead, and on this server the SSE hub requires an authenticated caller. For an application without sign-in, set ``, as [Continue in the browser](../getting-started/continue-in-the-browser.md) does. + +For the direct transports above, set these before subscribing: ```typescript import { Globals } from '@cratis/arc'; @@ -102,22 +177,30 @@ const app = express(); const adapter = cratisArc(server); app.use(adapter); const listener = app.listen(3000, '127.0.0.1'); -const disposeSockets = adapter.injectWebSocket(listener); +adapter.injectWebSocket(listener); let nextNumber = 2; const timer = setInterval(() => numbers.next([nextNumber++]), 1000); process.once('SIGINT', () => { clearInterval(timer); - void disposeSockets().then(() => server.dispose()).then(() => listener.close(), error => { - console.error(error); - process.exitCode = 1; - listener.close(); - }); + void (async () => { + try { + await adapter.close(listener); // Drain WebSockets and SSE, then close the listener. + } finally { await server.dispose(); } + })(); }); ``` `curl http://127.0.0.1:3000/api/numbers` answers 200 with `data: [1]` or a later number. `observe` may resolve services through `currentServices()`. +## Recap + +One route serves a snapshot and a stream. Return a source that has a current value when you have one, emit `undefined` for "nothing there", and let errors be errors. Paging and sorting apply to every emission; authorization applies once, when the subscription opens, and emission guards cover what changes after that. Filter rows by the caller inside the source. + +## Next step + +[Multiplexed observable queries](observable-query-demultiplexer.md) explains the hubs that generated clients use, and [Testing observable queries](../testing/observable-queries.md) shows how to collect emissions in a spec. + ## Related - [Multiplexed observable queries](observable-query-demultiplexer.md) From c43fc5951f48cab9108b204ca23c8fe1c3c3a032 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 05:50:45 +0200 Subject: [PATCH 5/7] Add query ownership, live-query authorization, and testing to authorization --- Documentation/authorizing-commands-and-queries.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Documentation/authorizing-commands-and-queries.md b/Documentation/authorizing-commands-and-queries.md index 51f03706..d3c18d32 100644 --- a/Documentation/authorizing-commands-and-queries.md +++ b/Documentation/authorizing-commands-and-queries.md @@ -97,10 +97,22 @@ The full order of stages is on [Command pipeline](commands/command-pipeline.md); - 403 answers a known caller who does not meet a declaration, a per-request `authorize` that returned `false`, or `denied(...)`. - Unparseable JSON is rejected with 400 before the role check, so an authenticated caller without the role gets 400 for a malformed body and 403 for a well-formed one. +## Queries: roles and ownership + +A model-bound query takes the same decorators on its read-model class or on a `@query()` method, and a method's declaration replaces the class's. A denied caller never reaches the query method and gets `isAuthorized: false`. + +A role answers "may this caller use the query at all", not "which rows may they see". `@roles('Planner')` on `allTasks` lets every planner read every task. When a read is owner-scoped, make ownership part of the query itself: read the caller's identity with `currentContext()` from `@cratis/arc.core` and put it in the data source's filter, next to the requested ID. When the caller has no identity, deny the query; never drop the owner filter to make it work. [Observable queries](queries/observable-queries.md#authorize-a-live-query) shows an owner-filtered live query. + +For a live query, authorization runs once, when the subscription opens. A role removed later does not close a running subscription; use an [emission guard](queries/observable-query-emission-guards.md) when access must be re-checked on every emission. + ## Keep security out of validators Put every security and tenant check in authorization, never in a validator: a trusted direct caller can lower the blocking severity, but nothing lowers authorization. +## Test who may call + +Authorization is easy to break silently: a moved decorator or a new command without one leaves an operation open, and nothing fails. Specify it like any other behavior. A scenario's `withContext({ principal })` sets the caller, and `shouldNotBeAuthorized()` and `shouldBeAuthorized()` assert the verdict. [Testing commands](testing/commands.md#test-authorization) shows the specs for an anonymous caller, a caller without the role, and a caller with it. + ## Related - [Authentication](core/authentication.md) From dda795431fa6e2e30a83efffcda9beaea93de0fc Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 05:54:01 +0200 Subject: [PATCH 6/7] Add decision and operation testing lessons and authorization specs Two lessons modeled on the .NET testing tutorials: test a command's decision directly and its pipeline through CommandScenario, and test operations, failure, and reverse-order compensation. Testing commands gains a Test authorization section, and the overview and query pages show the sample contexts as they are, with generated metadata. --- Documentation/testing/command-decisions.md | 178 ++++++++++++++ Documentation/testing/command-operations.md | 256 ++++++++++++++++++++ Documentation/testing/commands.md | 60 ++++- Documentation/testing/index.md | 48 +++- Documentation/testing/queries.md | 5 +- Documentation/testing/toc.yml | 4 + 6 files changed, 540 insertions(+), 11 deletions(-) create mode 100644 Documentation/testing/command-decisions.md create mode 100644 Documentation/testing/command-operations.md diff --git a/Documentation/testing/command-decisions.md b/Documentation/testing/command-decisions.md new file mode 100644 index 00000000..e56968cc --- /dev/null +++ b/Documentation/testing/command-decisions.md @@ -0,0 +1,178 @@ +--- +title: Test a command's decision and its pipeline +description: Learn when to call handle() directly and when to run CommandScenario, using one shipping-quote command that separates acquiring data from deciding. +--- + +A shipping quote has two different things to prove: **the calculation is right**, and **Arc validates the input and fetches the rate before calculating**. You need no server for either, but they are different tests. A spec that only runs the whole pipeline is slow to write for every tariff edge case; a spec that only calls the method never notices when a validator stops running. + +In this lesson you write one fast spec that calls `handle()` directly, then two `CommandScenario` specs that run the real pipeline. None of them needs a database, Chronicle, or an HTTP server. + +## Set up the lesson + +Arc for TypeScript is a source preview, so work inside your clone of this repository, where `@cratis/arc.core`, `@cratis/arc.testing`, Vitest, Chai's `should`, and Sinon are already configured. Create the lesson folder under the Tasks sample, which the repository's Vitest projects already cover: + +```bash +mkdir -p Samples/Tasks/Lessons/Shipping/for_QuoteShipping/given +``` + +The Tasks server only discovers `Samples/Tasks/Features/`, so these files never become routes. Delete the `Lessons` folder when you are done. + +## Keep acquiring data separate from deciding + +Create the command, its value types, and a rule for the weight: + +```typescript title="Samples/Tasks/Lessons/Shipping/QuoteShipping.ts" +import { ConceptAs, field } from '@cratis/fundamentals'; +import { command, ConceptValidator, inject, serviceToken, validator } from '@cratis/arc.core'; + +export class ParcelWeight extends ConceptAs { static readonly valueType = Number; } +export class RatePerKilogram extends ConceptAs { static readonly valueType = Number; } +export class ShippingCost extends ConceptAs { static readonly valueType = Number; } + +export interface RateCard { + currentRate(): Promise; +} +export const rateCard = serviceToken('rateCard'); + +@command() +export class QuoteShipping { + @field(ParcelWeight) weight!: ParcelWeight; + + @inject(rateCard) + provide(rates: RateCard): Promise { + return rates.currentRate(); + } + + handle(rate: RatePerKilogram): ShippingCost { + return new ShippingCost(this.weight.value * rate.value); + } +} + +@validator(ParcelWeight) +export class ParcelWeightValidator extends ConceptValidator { + constructor() { + super(); + this.ruleFor(weight => weight.value).greaterThan(0).withMessage('Parcel weight must be positive'); + } +} +``` + +The weight is in kilograms, and the rate and cost share one currency. They are separate concepts so a weight, a rate, and a cost cannot trade places without the compiler noticing. + +`provide()` acquires the rate through the `RateCard` service, and its result becomes the first argument of `handle()`. `handle()` only multiplies: the same weight and rate always produce the same cost, with no I/O, clock, or randomness. That makes it a **pure function**. Arc does not require pure handlers; the split pays off when fetching data would otherwise hide the decision. + +`ParcelWeightValidator` belongs to the value, not to this command, so it runs for every command that carries a `ParcelWeight`. Calling `handle()` yourself runs neither the validator nor `provide()`. + +## Specify the decision directly + +```typescript title="Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_directly.ts" +import { ParcelWeight, QuoteShipping, RatePerKilogram, ShippingCost } from '../QuoteShipping.js'; + +describe('when quoting shipping directly', () => { + let cost: ShippingCost; + + beforeEach(() => { + const quote = Object.assign(new QuoteShipping(), { weight: new ParcelWeight(2.5) }); + cost = quote.handle(new RatePerKilogram(4)); + }); + + it('should quote ten currency units', () => { cost.value.should.equal(10); }); +}); +``` + +Run it: + +```bash +yarn vitest run Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_directly.ts +``` + +The spec passes when 2.5 kilograms at 4 per kilogram costs 10. There is no service container, no fake rate card, and no Arc in this test. Add cases here when the calculation grows thresholds, rounding, or tariffs: each one costs a few lines and runs in milliseconds. + +## Prove that Arc connects the pieces + +The direct spec cannot tell you whether Arc still fetches the rate, or whether the weight rule runs. That is the pipeline's job, so test it through the pipeline. Start with a context both scenario specs share: + +```typescript title="Samples/Tasks/Lessons/Shipping/for_QuoteShipping/given/a_quote_scenario.ts" +import { CommandScenario } from '@cratis/arc.testing'; +import sinon from 'sinon'; +import { ParcelWeightValidator, QuoteShipping, rateCard, RatePerKilogram } from '../../QuoteShipping.js'; + +export class a_quote_scenario { + currentRate = sinon.stub().resolves(new RatePerKilogram(4)); + scenario = CommandScenario.for(QuoteShipping, ParcelWeightValidator); + + constructor() { + this.scenario.services.addSingleton(rateCard, { currentRate: this.currentRate }); + } +} +``` + +`CommandScenario.for` takes the command and the other decorated artifacts it needs; it cannot find a validator you never imported. Register the fake rate card before the first call, because the scenario builds its application lazily on first use. + +```typescript title="Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_through_arc.ts" +import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; +import { ParcelWeight } from '../QuoteShipping.js'; +import { a_quote_scenario } from './given/a_quote_scenario.js'; + +describe('when quoting shipping through Arc', given(a_quote_scenario, context => { + let result: ScenarioCommandResult; + + beforeAll(async () => { + result = await context.scenario.execute({ weight: new ParcelWeight(2.5) }); + }); + afterAll(async () => { await context.scenario.dispose(); }); + + it('should succeed', () => { result.shouldBeSuccessful(); }); + it('should acquire the rate once', () => { context.currentRate.callCount.should.equal(1); }); + it('should return the calculated cost', () => { (result.response as number).should.equal(10); }); +})); +``` + +This time the boundary is the point. `execute()` encodes the input to the wire shape, runs the concept validator, calls `provide()` with the registered rate card, hands the rate to `handle()`, and encodes the response. The cost comes back as the number `10`, not a `ShippingCost`, because the result is what a client would receive. + +`given(...)` creates one context for the whole `describe`, so the action runs once in `beforeAll` and the scenario is disposed in `afterAll`. + +## Prove rejected input never reaches the rate card + +```typescript title="Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_an_unset_weight.ts" +import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; +import { ParcelWeight } from '../QuoteShipping.js'; +import { a_quote_scenario } from './given/a_quote_scenario.js'; + +describe('when quoting an unset weight', given(a_quote_scenario, context => { + let result: ScenarioCommandResult; + + beforeAll(async () => { + result = await context.scenario.execute({ weight: new ParcelWeight(0) }); + }); + afterAll(async () => { await context.scenario.dispose(); }); + + it('should reject the weight', () => { + result.shouldHaveValidationErrorForMember('weight').shouldHaveValidationErrorFor('Parcel weight must be positive'); + }); + it('should not acquire a rate', () => { context.currentRate.called.should.equal(false); }); +})); +``` + +Run all three specs: + +```bash +yarn vitest run Samples/Tasks/Lessons/Shipping +``` + +Six tests pass. They answer three different questions: is the calculation right, does Arc compose the pieces, and does bad input stop before any work. Asserting both the member and the message, and that the rate card was never called, keeps an unrelated failure, such as a missing service, from passing as the expected rejection. + +## What you proved, and what you did not + +You have fast specs for the decision and focused specs for Arc's composition. You have **not** tested an HTTP route, an authentication handler, or a real rate-card implementation. Test those where they are introduced, rather than adding infrastructure to every arithmetic case: + +| What you need to prove | Start with | It does not prove | +| --- | --- | --- | +| A calculation or decision | A direct `handle()` spec with explicit inputs | Validation, authorization, `provide()`, or services | +| Validation, authorization, `provide()`, services, and the response | `CommandScenario` | HTTP routing, authentication handlers, or real infrastructure | +| Operations executing and compensating in order | `CommandScenario` with fake providers | That a real provider undid anything | +| The route, the host, and authentication | `ArcScenario` with HTTP requests; see [Low-level definitions and HTTP](low-level-definitions.md) | Business edge cases you did not send | + +## Next step + +A handler that performs side effects can return them as [command operations](../commands/operations/index.md) instead of calling services directly. [Test operations and compensation](command-operations.md) extends this lesson to prove that Arc runs them, stops after a failure, and undoes the work it started. diff --git a/Documentation/testing/command-operations.md b/Documentation/testing/command-operations.md new file mode 100644 index 00000000..fe43d9f8 --- /dev/null +++ b/Documentation/testing/command-operations.md @@ -0,0 +1,256 @@ +--- +title: Test operations and compensation +description: Specify a command's declared operations directly, then run real execution, failure, and reverse-order compensation through CommandScenario with a fake provider. +--- + +A booking test has to answer more than "did the command return a reservation?" You also need to know that Arc calls the provider, stops after a failure, and cancels the reservations it already made. Written by hand, that is a rollback stack in the handler and a pile of mocks in the spec. With [command operations](../commands/operations/index.md), the handler only declares the work, and Arc runs it and compensates. This lesson shows how to prove each part without a server. + +You first inspect the command's decision directly. Then you run the real operation pipeline through `CommandScenario`, including a provider failure halfway through a batch. + +## Set up the lesson + +Work inside your clone, as in [Test a command's decision](command-decisions.md), and create a folder the Tasks sample's Vitest project covers: + +```bash +mkdir -p Samples/Tasks/Lessons/SeatBooking/for_BookSeat/given \ + Samples/Tasks/Lessons/SeatBooking/for_BookSeats/given \ + Samples/Tasks/Lessons/SeatBooking/for_ReserveSeat +``` + +The reservation provider is an application interface. The specs supply a Sinon fake, so nothing leaves the process. + +## Declare the operation and the commands + +```typescript title="Samples/Tasks/Lessons/SeatBooking/SeatBooking.ts" +import { field } from '@cratis/fundamentals'; +import { command, CommandOperation, operations, serviceToken, tuple } from '@cratis/arc.core'; + +export interface SeatReservations { + reserve(reservation: string, seat: string, signal: AbortSignal): Promise; + cancel(reservation: string, signal: AbortSignal): Promise; +} +export const seatReservations = serviceToken('seatReservations'); + +export class ReserveSeat extends CommandOperation { + readonly executeDependencies = [seatReservations] as const; + readonly compensateDependencies = [seatReservations] as const; + + constructor(readonly reservation: string, readonly seat: string) { super(); } + + execute(signal: AbortSignal, reservations: SeatReservations): Promise { + return reservations.reserve(this.reservation, this.seat, signal); + } + + compensate(_failure: unknown, signal: AbortSignal, reservations: SeatReservations): Promise { + return reservations.cancel(this.reservation, signal); + } +} + +@command() +export class BookSeat { + @field(String) reservation!: string; + @field(String) seat!: string; + + handle() { + return tuple(this.reservation, new ReserveSeat(this.reservation, this.seat)); + } +} + +export class SeatRequest { + @field(String) reservation!: string; + @field(String) seat!: string; +} + +@command() +export class BookSeats { + @field(Array, { genericArguments: [SeatRequest] }) requests!: SeatRequest[]; + + handle() { + return operations(...this.requests.map(request => new ReserveSeat(request.reservation, request.seat))); + } +} +``` + +`BookSeat.handle()` returns two values: the reservation ID for the caller, and a `ReserveSeat` operation that Arc runs on the server. `BookSeats` returns an explicit batch of operations. Neither handler calls the provider. `ReserveSeat` names its dependencies with a service token, and Arc resolves them before it starts the first operation. + +## Specify the decision without infrastructure + +```typescript title="Samples/Tasks/Lessons/SeatBooking/for_BookSeat/when_deciding_to_book.ts" +import { BookSeat, ReserveSeat } from '../SeatBooking.js'; + +describe('when deciding to book a seat', () => { + let decision: ReturnType; + + beforeEach(() => { + decision = Object.assign(new BookSeat(), { reservation: 'r-1', seat: 'A-12' }).handle(); + }); + + it('should return the reservation as the response', () => { decision.values[0].should.equal('r-1'); }); + it('should declare the requested reservation', () => { + decision.values[1].should.be.instanceOf(ReserveSeat); + decision.values[1].should.include({ reservation: 'r-1', seat: 'A-12' }); + }); +}); +``` + +Calling `handle()` only returns the declaration. No provider exists in this spec, and nothing was reserved: the operation is data you can inspect. Add decision branches here when booking becomes conditional. + +## Specify the provider adapter directly + +The operation's own `execute()` maps its data to the provider call. Test that mapping on its own: + +```typescript title="Samples/Tasks/Lessons/SeatBooking/for_ReserveSeat/when_reserving_directly.ts" +import sinon from 'sinon'; +import { ReserveSeat, type SeatReservations } from '../SeatBooking.js'; + +describe('when reserving a seat directly', () => { + const signal = new AbortController().signal; + let reservations: { reserve: sinon.SinonStub; cancel: sinon.SinonStub }; + + beforeEach(async () => { + reservations = { reserve: sinon.stub().resolves(), cancel: sinon.stub().resolves() }; + await new ReserveSeat('r-1', 'A-12').execute(signal, reservations as SeatReservations); + }); + + it('should reserve the requested seat', () => { reservations.reserve.should.have.been.calledOnceWith('r-1', 'A-12', signal); }); + it('should not cancel the reservation', () => { reservations.cancel.called.should.equal(false); }); +}); +``` + +This tests your adapter, not Arc's orchestration. Calling `execute()` yourself never compensates on failure. The next specs cross that boundary on purpose. + +## Execute through CommandScenario + +```typescript title="Samples/Tasks/Lessons/SeatBooking/for_BookSeat/given/a_booking.ts" +import { CommandScenario } from '@cratis/arc.testing'; +import sinon from 'sinon'; +import { BookSeat, seatReservations } from '../../SeatBooking.js'; + +export class a_booking { + reservations = { reserve: sinon.stub().resolves(), cancel: sinon.stub().resolves() }; + scenario = CommandScenario.for(BookSeat); + + constructor() { this.scenario.services.addSingleton(seatReservations, this.reservations); } +} +``` + +```typescript title="Samples/Tasks/Lessons/SeatBooking/for_BookSeat/when_booking_through_arc.ts" +import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; +import { ReserveSeat } from '../SeatBooking.js'; +import { a_booking } from './given/a_booking.js'; + +describe('when booking a seat through Arc', given(a_booking, context => { + let result: ScenarioCommandResult; + + beforeAll(async () => { result = await context.scenario.execute({ reservation: 'r-1', seat: 'A-12' }); }); + afterAll(async () => { await context.scenario.dispose(); }); + + it('should succeed', () => { result.shouldBeSuccessful(); }); + it('should call the provider', () => { context.reservations.reserve.should.have.been.calledOnceWith('r-1', 'A-12'); }); + it('should observe the completed execution', () => { result.shouldHaveExecutedOperation(ReserveSeat); }); + it('should not need recovery', () => { result.recovery!.status.should.equal('NotNeeded'); }); + it('should return only the caller response', () => { (result.response as string).should.equal('r-1'); }); +})); +``` + +Now Arc does the work. It resolves `seatReservations`, calls `ReserveSeat.execute()`, and sends back only the reservation ID; the operation never reaches the caller. `shouldHaveExecutedOperation` and `result.recovery` report what the pipeline actually observed, not what a stub pretended. `recovery` and `operationOutcomes` exist on results from direct calls and scenarios only; they are never serialized to HTTP. + +A validation-only call must not touch the provider at all: + +```typescript title="Samples/Tasks/Lessons/SeatBooking/for_BookSeat/when_validating_a_booking.ts" +import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; +import { a_booking } from './given/a_booking.js'; + +describe('when validating a booking', given(a_booking, context => { + let result: ScenarioCommandResult; + + beforeAll(async () => { result = await context.scenario.validate({ reservation: 'r-1', seat: 'A-12' }); }); + afterAll(async () => { await context.scenario.dispose(); }); + + it('should be valid', () => { result.shouldBeSuccessful(); }); + it('should not enter any operation', () => { result.shouldHaveNoOperationInvocations(); }); + it('should not call the provider', () => { context.reservations.reserve.called.should.equal(false); }); +})); +``` + +## Prove compensation after a partial failure + +Now make the second of three reservations fail. The first two operations enter `execute()`; the third must never start. Arc should cancel the second reservation first, because the provider may have accepted it before reporting the failure, and then the first. + +```typescript title="Samples/Tasks/Lessons/SeatBooking/for_BookSeats/given/three_seat_requests.ts" +import { CommandScenario } from '@cratis/arc.testing'; +import sinon from 'sinon'; +import { BookSeats, seatReservations } from '../../SeatBooking.js'; + +export class three_seat_requests { + reservations = { reserve: sinon.stub().resolves(), cancel: sinon.stub().resolves() }; + scenario = CommandScenario.for(BookSeats); + requests = [ + { reservation: 'r-1', seat: 'A-12' }, + { reservation: 'r-2', seat: 'A-13' }, + { reservation: 'r-3', seat: 'A-14' } + ]; + + constructor() { this.scenario.services.addSingleton(seatReservations, this.reservations); } +} +``` + +```typescript title="Samples/Tasks/Lessons/SeatBooking/for_BookSeats/when_the_second_reservation_fails.ts" +import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; +import sinon from 'sinon'; +import { ReserveSeat } from '../SeatBooking.js'; +import { three_seat_requests } from './given/three_seat_requests.js'; + +describe('when the second reservation fails', given(three_seat_requests, context => { + let result: ScenarioCommandResult; + + beforeAll(async () => { + context.reservations.reserve.withArgs('r-2').rejects(new Error('Reservation provider unavailable')); + result = await context.scenario.execute({ requests: context.requests }); + }); + afterAll(async () => { await context.scenario.dispose(); }); + + it('should not succeed', () => { result.shouldNotBeSuccessful(); }); + it('should keep the original error', () => { + result.exceptionMessages.should.include('Error: Reservation provider unavailable'); + }); + it('should enter only the first two operations', () => { result.recovery!.startedCount.should.equal(2); }); + it('should not start the third reservation', () => { + context.reservations.reserve.should.not.have.been.calledWith('r-3'); + }); + it('should observe the first execution completing', () => { result.operationOutcomes![0]!.executionCompleted.should.equal(true); }); + it('should observe the second execution failing', () => { result.operationOutcomes![1]!.executionCompleted.should.equal(false); }); + it('should compensate in reverse order', () => { + sinon.assert.callOrder(context.reservations.cancel.withArgs('r-2'), context.reservations.cancel.withArgs('r-1')); + }); + it('should report both compensations', () => { + result.shouldHaveCompensatedOperation(ReserveSeat); + result.recovery!.compensatedCount.should.equal(2); + }); + it('should report completed recovery', () => { result.recovery!.status.should.equal('Completed'); }); +})); +``` + +Each assertion proves something different. A lone `shouldNotBeSuccessful()` would also pass if a service were missing before any operation ran; the provider calls, the skipped third reservation, and the recovery counts rule that out. Neither the command nor the spec contains a rollback stack: Arc tracked what it started and compensated it in reverse. + +`Completed` means both compensation callbacks returned. A fake cannot prove that a real provider made the cancellation durable, or that a slow reservation will not appear later. Test those guarantees against the provider itself. + +## Run the lesson + +```bash +yarn vitest run Samples/Tasks/Lessons/SeatBooking +``` + +Twenty-one tests pass across five spec files. Delete `Samples/Tasks/Lessons` when you are done. + +## What you proved, and what comes next + +You have specified the declared decision, the provider adapter, successful composition through Arc, a validation call that touches nothing, and reverse-order recovery after a failure in the middle of a batch. + +Some cases need more than a fake: + +- **Cancellation.** Compensation receives its own signal, not the request's aborted one, with a shared budget set by `commandCompensationTimeoutMs`; see [Implementing operations](../commands/operations/implementing.md). +- **Commit facts.** When an execution scope commits business changes, its reported disposition decides whether Arc compensates at all. `shouldHaveIndeterminateRecovery()` asserts the `Unknown` and `Mixed` cases; see the [operations reference](../commands/operations/reference.md). +- **The real provider.** Idempotency, key reuse, and delayed creation after a cancellation are the provider's guarantees. Only integration tests against it can establish them. + +Return to [Testing](index.md) to choose the next boundary, or read [Testing commands](commands.md) for every scenario assertion. diff --git a/Documentation/testing/commands.md b/Documentation/testing/commands.md index 0f3b5c3b..a6a3a6cf 100644 --- a/Documentation/testing/commands.md +++ b/Documentation/testing/commands.md @@ -41,6 +41,60 @@ The context class `a_task_registration` is shown in [Testing](index.md#share-a-c `scenario.withContext({ principal, tenantId, correlationId, signal })` sets the identity a trusted direct caller would pass. `withAllowedValidationSeverity(Severity.Error)` lets error-severity results pass, which only trusted callers can do; see [Validation severity filtering](../commands/validation-severity-filtering.md). +## Test authorization + +A decorator moved to the wrong class, or a new command without one, leaves an operation open, and no other spec notices. Specify who may call as deliberately as what the command does. This command requires the `Planner` role: + +```typescript title="ArchiveTask.ts" +import { field } from '@cratis/fundamentals'; +import { command, roles } from '@cratis/arc.core'; + +export const archived: string[] = []; + +@command() +@roles('Planner') +export class ArchiveTask { + @field(String) id!: string; + + handle(): void { + archived.push(this.id); + } +} +``` + +Cover the three callers that matter: nobody, somebody without the role, and somebody with it: + +```typescript title="for_ArchiveTask/when_archiving.ts" +import { CommandScenario } from '@cratis/arc.testing'; +import { ArchiveTask } from '../ArchiveTask.js'; + +const planner = { id: 'ada', roles: ['Planner'], isAuthenticated: true }; +const viewer = { id: 'grace', roles: ['Viewer'], isAuthenticated: true }; + +describe('when archiving a task', () => { + let scenario: CommandScenario; + beforeEach(() => { scenario = CommandScenario.for(ArchiveTask); }); + afterEach(async () => { await scenario.dispose(); }); + + it('should deny an anonymous caller', async () => { + (await scenario.execute({ id: 't-1' })).shouldNotBeAuthorized(); + }); + it('should deny a caller without the role', async () => { + (await scenario.withContext({ principal: viewer }).execute({ id: 't-1' })).shouldNotBeAuthorized(); + }); + it('should allow a planner', async () => { + (await scenario.withContext({ principal: planner }).execute({ id: 't-1' })).shouldBeSuccessful(); + }); + it('should deny on the validation route too', async () => { + (await scenario.withContext({ principal: viewer }).validate({ id: 't-1' })).shouldNotBeAuthorized(); + }); +}); +``` + +The principal you pass is what an authentication handler would have produced: an `id`, `roles`, and `isAuthenticated`. The scenario runs Arc's real authorization stage, so the specs fail if the decorator disappears. They do not exercise your authentication handler or the HTTP status code; test those through [`ArcScenario`](low-level-definitions.md) requests. + +A decision that needs loaded data, such as "only the owner may rename", returns `denied(reason)` from `provide()`. The same assertion covers it, and the reason is on the result: `result.shouldNotBeAuthorized()` and `result.authorizationFailureReason.should.equal('Only the owner can rename a task')`. For queries, `QueryScenario` and `ObservableQueryScenario` take the same `withContext({ principal })`, and a denied query reports `isAuthorized: false`, on the result for `QueryScenario` and on `rejection` for `ObservableQueryScenario`. + ## Assertions The result is the actual `CommandResult` with chainable assertions: @@ -50,12 +104,14 @@ The result is the actual `CommandResult` with chainable assertions: | `shouldBeSuccessful()` / `shouldNotBeSuccessful()` | `isSuccess` is true / false | | `shouldBeValid()` | No blocking validation result | | `shouldHaveValidationErrors()` | An authored rule failed | -| `shouldHaveValidationErrorFor(message)` | A result has this message | +| `shouldHaveValidationErrorFor(text)` | A result's message **contains** this text (case-sensitive) | | `shouldHaveValidationErrorForMember(member)` | A result concerns this member | | `shouldHaveValidationErrorBecauseOf(reason)` | A result has this reason, such as `validatorFailed` | | `shouldBeAuthorized()` / `shouldNotBeAuthorized()` | `isAuthorized` is true / false | | `shouldHaveExceptions()` / `shouldNotHaveExceptions()` | `hasExceptions` is true / false | +Assert the field with `shouldHaveValidationErrorForMember('title')`, not `shouldHaveValidationErrorFor('title')`: the second passes for any message that happens to contain the word. Pass the full message to `shouldHaveValidationErrorFor` when the wording matters. + A dependency or validator construction failure alone cannot satisfy the generic or member validation assertions: it means no authored rule was established. Unlike .NET, `validatorFailed` alone does not satisfy `shouldHaveValidationErrors()`; assert it explicitly with `shouldHaveValidationErrorBecauseOf('validatorFailed')`. ## Assert operations @@ -67,7 +123,7 @@ A dependency or validator construction failure alone cannot satisfy the generic | `shouldHaveNoOperationInvocations()` | No operation was entered, not even partially | | `shouldHaveIndeterminateRecovery()` | The recovery status is `Indeterminate` | -These inspect real pipeline observations, not simulated work. See [Command operations](../commands/operations/index.md). +These inspect real pipeline observations, not simulated work. [Test operations and compensation](command-operations.md) walks through a full example, including a failure in the middle of a batch. See also [Command operations](../commands/operations/index.md). ## Related diff --git a/Documentation/testing/index.md b/Documentation/testing/index.md index 2ccb33b1..8be1358c 100644 --- a/Documentation/testing/index.md +++ b/Documentation/testing/index.md @@ -1,15 +1,38 @@ --- title: Testing -description: Test commands, queries, and observable queries through Arc's real pipelines without starting a server, with scenarios from @cratis/arc.testing. +description: Test commands, queries, and observable queries through Arc's real pipelines without starting a server, and choose the boundary that can catch the bug you care about. --- -A spec that calls `handle()` directly skips everything Arc does around it: binding, authorization, validators, services, and the result envelope. A spec that starts an HTTP server is slow and fragile. `@cratis/arc.testing` runs your artifacts through the **real** pipelines in-process, so a passing spec means the behavior a client sees. +A spec that calls `handle()` directly skips everything Arc does around it: binding, authorization, validators, services, and the result envelope. A spec that starts an HTTP server is slow and fragile. `@cratis/arc.testing` runs your artifacts through the **real** pipelines in-process, so a passing spec means the behavior a client sees, without a listener or a port. + +A useful spec still answers one precise question. Is the decision right? Did Arc enforce the rule? Did the operation get undone? Each question has a boundary that answers it cheaply. + +## Choose the boundary that can catch the bug + +| What you need to prove | Start with | It does not prove | +| --- | --- | --- | +| A calculation or decision | A direct `handle()` spec with explicit inputs | Validation, authorization, `provide()`, or services | +| Validation, authorization, `provide()`, services, and the response | `CommandScenario` | HTTP routing, authentication handlers, or real infrastructure | +| Operations executing and compensating in order | `CommandScenario` with fake providers | That a real provider undid anything | +| A query's arguments, paging, sorting, and result shape | `QueryScenario` | The database's own query behavior | +| A live query's emissions | `ObservableQueryScenario` | A transport or a browser client | +| The route, the host, and authentication | `ArcScenario` with HTTP requests | Business edge cases you did not send | +| Events a command returns for Chronicle | `ChronicleCommandScenario` | A running kernel and its projections | + +Combine boundaries rather than pushing every case through the widest one. Cover decision branches with fast direct specs, add scenario specs for the Arc contracts that matter, and keep a smaller set of HTTP or integration tests for real composition. + +## Learn by doing + +Two lessons build the habit step by step, each with runnable specs: + +- [Test a command's decision and its pipeline](command-decisions.md) tests one shipping-quote command both ways: directly for the arithmetic, through `CommandScenario` for validation and the service call. +- [Test operations and compensation](command-operations.md) proves that Arc runs a command's operations, stops after a provider failure, and compensates in reverse order. ## Pick a scenario | Scenario | Use it for | Page | | --- | --- | --- | -| `CommandScenario` | A decorated command, its validators, services, and operations | [Commands](commands.md) | +| `CommandScenario` | A decorated command, its validators, services, authorization, and operations | [Commands](commands.md) | | `QueryScenario` | A decorated static query, with arguments, paging, and sorting | [Queries](queries.md) | | `ObservableQueryScenario` | A decorated observable query, collecting emissions with a deadline | [Observable queries](observable-queries.md) | | `ArcScenario` | Low-level definitions and full HTTP requests | [Low-level definitions](low-level-definitions.md) | @@ -21,25 +44,34 @@ Every scenario builds its application lazily on the first call, lets you registe The Tasks sample's specs use `given(Context, context => { ... })` from `@cratis/arc.testing` to create one context per spec suite, without depending on Mocha types: -```typescript title="for_RegisterTask/given/a_task_registration.ts" +```typescript title="Features/Tasks/Registration/for_RegisterTask/given/a_task_registration.ts" import { CommandScenario } from '@cratis/arc.testing'; import { Tasks } from '../../../Tasks.js'; -import { RegisterTask } from '../../RegisterTask.js'; -import { RegisterTaskValidator } from '../../RegisterTaskValidator.js'; +import { RegisterTask, RegisterTaskValidator } from '../../Registration.js'; +import { metadata } from '../../../../generatedMetadata.js'; export class a_task_registration { tasks = new Tasks(); scenario = CommandScenario.for(RegisterTask, RegisterTaskValidator); - constructor() { this.scenario.services.addSingleton(Tasks, this.tasks); } + constructor() { + this.scenario.extend(builder => builder.useGeneratedMetadata(metadata)); + this.scenario.services.addSingleton(Tasks, this.tasks); + } } ``` +`extend(...)` installs anything the application's builder needs before the scenario builds it; here, the generated metadata that binds `handle(tasks: Tasks)` without `@inject`. Because `given(...)` creates **one** context for the whole `describe`, run the action in `beforeAll` and dispose in `afterAll`. A scenario disposed after the first test cannot run again. + The `for_/when_/.ts` layout follows the Cratis specification conventions; each spec file reads as a sentence. Run the sample's specs with `yarn vitest run Samples/Tasks`. ## Wire round trips -Scenario inputs are always encoded to Arc's wire representation before the pipeline decodes them, exactly as over HTTP. By default inputs and returned data also pass through JSON `stringify` and `parse`. `withSerializationRoundTrip(false)` skips only that JSON step and keeps wire encoding. +Scenario inputs are always encoded to Arc's wire representation before the pipeline decodes them, exactly as over HTTP. By default inputs and returned data also pass through JSON `stringify` and `parse`, so a concept in a response arrives as its primitive value. `withSerializationRoundTrip(false)` skips only that JSON step and keeps wire encoding. + +## Next step + +Start with [Test a command's decision and its pipeline](command-decisions.md), or go straight to [Testing commands](commands.md) for every assertion. ## Related diff --git a/Documentation/testing/queries.md b/Documentation/testing/queries.md index 20987a02..7d6d4a0f 100644 --- a/Documentation/testing/queries.md +++ b/Documentation/testing/queries.md @@ -38,7 +38,8 @@ Its context creates the scenarios: ```typescript title="for_TaskItem/given/a_task_listing.ts" import { ObservableQueryScenario, QueryScenario } from '@cratis/arc.testing'; import { Tasks } from '../../../Tasks.js'; -import { TaskItem } from '../../TaskItem.js'; +import { TaskItem } from '../../Listing.js'; +import { metadata } from '../../../../generatedMetadata.js'; export class a_task_listing { tasks = new Tasks(); @@ -46,6 +47,8 @@ export class a_task_listing { observable = ObservableQueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'observeAllTasks'); constructor() { + this.query.extend(builder => builder.useGeneratedMetadata(metadata)); + this.observable.extend(builder => builder.useGeneratedMetadata(metadata)); this.query.services.addSingleton(Tasks, this.tasks); this.observable.services.addSingleton(Tasks, this.tasks); } diff --git a/Documentation/testing/toc.yml b/Documentation/testing/toc.yml index e2c8beba..650b0f9a 100644 --- a/Documentation/testing/toc.yml +++ b/Documentation/testing/toc.yml @@ -1,5 +1,9 @@ - name: Overview href: index.md +- name: Test a command's decision + href: command-decisions.md +- name: Test operations and compensation + href: command-operations.md - name: Commands href: commands.md - name: Queries From 8490e24d3da3b4f58c8b0a2b64c5f86188470554 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 25 Sep 2026 05:54:17 +0200 Subject: [PATCH 7/7] Note the React provider's SSE hub default for anonymous callers --- Documentation/queries/observable-query-demultiplexer.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Documentation/queries/observable-query-demultiplexer.md b/Documentation/queries/observable-query-demultiplexer.md index ee9c649f..c3d19e95 100644 --- a/Documentation/queries/observable-query-demultiplexer.md +++ b/Documentation/queries/observable-query-demultiplexer.md @@ -5,6 +5,8 @@ description: Carry many observable-query subscriptions over one WebSocket or ser A dashboard with ten live widgets should not open ten sockets. The multiplexed hub carries every subscription from one client over a single connection, and it is the default transport of the published `@cratis/arc` client. +The plain `@cratis/arc` client uses the WebSocket hub by default. The `` provider from `@cratis/arc.react` uses the SSE hub by default, which on this server requires an authenticated caller; set `` when your callers are anonymous. + ## The WebSocket hub The hub lives at `/.cratis/queries/ws`. After `Connected`, the client subscribes by query name: