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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ContractTests/Client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.core-client-contract",
"version": "0.38.0",
"version": "0.39.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
68 changes: 68 additions & 0 deletions ContractTests/DotNET/MultiOutcomeCases.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Arc.Authorization;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Validation;
using Cratis.Monads;
using OneOf;

namespace HttpFixture;

/// <summary>
/// A business failure is still an ordinary response unless a value handler consumes it.
/// </summary>
/// <param name="Code">The failure code.</param>
public record OutcomeError(string Code);

/// <summary>
/// Selects either a DTO response or a validation failure.
/// </summary>
[Command]
[AllowAnonymous]
public record OutcomeDto(bool Fail)
{
/// <summary>Returns the selected branch.</summary>
public OneOf<EchoReply, ValidationResult> Handle() => Fail
? OneOf<EchoReply, ValidationResult>.FromT1(ValidationResult.Error("Outcome rejected", ["fail"]))
: OneOf<EchoReply, ValidationResult>.FromT0(new EchoReply("created"));
}

/// <summary>
/// Selects either a primitive response or an authorization denial.
/// </summary>
[Command]
[AllowAnonymous]
public record OutcomePrimitive(bool Fail)
{
/// <summary>Returns the selected branch.</summary>
public OneOf<int, AuthorizationResult> Handle() => Fail
? OneOf<int, AuthorizationResult>.FromT1(AuthorizationResult.Failure("Outcome denied"))
: OneOf<int, AuthorizationResult>.FromT0(42);
}

/// <summary>
/// A Result error DTO is not a validation or authorization failure.
/// </summary>
[Command]
[AllowAnonymous]
public record OutcomeErrorCase(bool Fail)
{
/// <summary>Returns the selected branch.</summary>
public Result<EchoReply, OutcomeError> Handle() => Fail
? Result<EchoReply, OutcomeError>.Failed(new OutcomeError("already-exists"))
: Result<EchoReply, OutcomeError>.Success(new EchoReply("created"));
}

/// <summary>
/// OneOf alternatives can contain simultaneous tuple values.
/// </summary>
[Command]
[AllowAnonymous]
public record OutcomeTuple(bool Fail)
{
/// <summary>Returns the selected branch.</summary>
public OneOf<EchoReply, (EchoReply, ValidationResult)> Handle() => Fail
? OneOf<EchoReply, (EchoReply, ValidationResult)>.FromT1((new EchoReply("ignored"), ValidationResult.Error("Tuple rejected", ["fail"])))
: OneOf<EchoReply, (EchoReply, ValidationResult)>.FromT0(new EchoReply("created"));
}
18 changes: 18 additions & 0 deletions ContractTests/Http/conformance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,24 @@ test('published .NET and built TypeScript HTTP contract', async t => {
});
} finally { if (adapter !== 'express') await host.stop(); }
}
for (const [name, path, expected] of [
['OneOf DTO response', 'outcome-dto', { response: { value: 'created' } }],
['OneOf primitive response', 'outcome-primitive', { response: 42 }],
['Result success DTO', 'outcome-error-case', { response: { value: 'created' } }],
['OneOf tuple alternative response', 'outcome-tuple', { response: { value: 'created' } }]
]) await parity(name, 'POST', `/api/${path}`, { fail: false }, { status: 200, body: command(200, expected) });
await parity('OneOf validation branch', 'POST', '/api/outcome-dto', { fail: true }, {
status: 400, body: command(400, { validationResults: [{ severity: 3, message: 'Outcome rejected', members: ['fail'], reason: 'rule' }] })
});
await parity('OneOf authorization branch', 'POST', '/api/outcome-primitive', { fail: true }, {
status: 403, body: command(403, { authorizationFailureReason: 'Outcome denied' })
});
await parity('Result arbitrary error DTO is a successful response', 'POST', '/api/outcome-error-case', { fail: true }, {
status: 200, body: command(200, { response: { code: 'already-exists' } })
});
await parity('OneOf tuple validation consumes the accompanying response', 'POST', '/api/outcome-tuple', { fail: true }, {
status: 400, body: command(400, { validationResults: [{ severity: 3, message: 'Tuple rejected', members: ['fail'], reason: 'rule' }] })
});
await parity('model-bound command materializes and returns a string', 'POST', '/api/model-bound-command', { title: 'readable' }, {
status: 200, body: command(200, { response: 'readable' })
});
Expand Down
2 changes: 2 additions & 0 deletions ContractTests/Http/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { z } from 'zod';
import { ArcApplication, AuthenticationStatus, CurrentValueSubject, currentContext, defineCommand, defineObservableQuery,
defineQuery, rejected, tuple, validation } from '@cratis/arc.core';
import { ModelBoundCommand } from './modelBound/dist/ModelBoundCommand.js';
import { OutcomeDto, OutcomePrimitive, incompatibleOutcomeCommand, OutcomeTuple } from './modelBound/dist/MultiOutcomeCases.js';
import { FilterParityCommand } from './modelBound/dist/FilterParityCommand.js';
import { FilterParityCommandValidator } from './modelBound/dist/FilterParityCommandValidator.js';
import { FilterParityAuthorizationFilter } from './modelBound/dist/FilterParityAuthorizationFilter.js';
Expand Down Expand Up @@ -194,6 +195,7 @@ builder.addCommandPipelineFilter(FilterParityOrdinaryCommandFilter)
builder.add(FilterParityCommand, FilterParityCommandValidator, FilterParityAuthorizationFilter,
FilterParityQueryAuthorizationFilter,
ModelBoundCommand, ModelBoundCommandValidator, ModelBoundTitle, ModelBoundLookup,
OutcomeDto, OutcomePrimitive, incompatibleOutcomeCommand(), OutcomeTuple,
ValidationGraphCommand, FixtureRateValidator, GuidCommand, GuidCommandValidator, HttpMetric,
PolicyItems, RateLookup, AnonymousClassCases, AuthorizationOverride, MethodRoleCases, RoleCases);
builder.addAuthorizationPolicy('FixtureAdmin', principal => principal.roles.includes('Admin'));
Expand Down
43 changes: 43 additions & 0 deletions ContractTests/Http/modelBound/MultiOutcomeCases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
import { field } from '@cratis/fundamentals';
import { allowAnonymous, command, denied, rejected, response, tuple, validation } from '@cratis/arc.core';
import type { Outcome } from '@cratis/arc.core';

class OutcomeReply { @field(String) value!: string; constructor(value: string) { this.value = value; } }
class OutcomeError { @field(String) code!: string; constructor(code: string) { this.code = code; } }

@command({ namespace: 'HttpFixture' })
@allowAnonymous()
export class OutcomeDto {
@field(Boolean) fail!: boolean;
handle(): Outcome<OutcomeReply> { return this.fail ? rejected(validation('Outcome rejected', ['fail'])) : response(new OutcomeReply('created')); }
}

@command({ namespace: 'HttpFixture' })
@allowAnonymous()
export class OutcomePrimitive {
@field(Boolean) fail!: boolean;
handle(): Outcome<number> { return this.fail ? denied('Outcome denied') : response(42); }
}

@command({ namespace: 'HttpFixture' })
@allowAnonymous()
class OutcomeErrorCase {
@field(Boolean) fail!: boolean;
handle(): Outcome<OutcomeReply | OutcomeError> {
return this.fail ? response(new OutcomeError('already-exists')) : response(new OutcomeReply('created'));
}
}

// This runtime-only fixture has two incompatible client DTO constructors. The generator must reject it,
// so do not export the class as a discoverable proxy artifact.
export const incompatibleOutcomeCommand = () => OutcomeErrorCase;

@command({ namespace: 'HttpFixture' })
@allowAnonymous()
export class OutcomeTuple {
@field(Boolean) fail!: boolean;
handle() { return this.fail ? tuple(new OutcomeReply('ignored'), rejected(validation('Tuple rejected', ['fail']))) :
response(new OutcomeReply('created')); }
}
31 changes: 30 additions & 1 deletion Documentation/commands/command-outcomes.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,38 @@ Results passed to `rejected(...)` go through the same [severity filter](validati
- from `provide()`, `handle()` runs and receives `undefined` as the provided value;
- from `handle()`, the command succeeds without a `response`.

## Model several business outcomes

When a caller needs to distinguish, for example, a created task from one that already existed, return **one response DTO** with an application-owned status field. Both cases then have the same generated client type and decoder:

```typescript title="Features/Tasks/RegisterTask.ts"
import { field } from '@cratis/fundamentals';
import { command, response, type Outcome } from '@cratis/arc.core';

class RegistrationReply {
@field(String) status!: string;
constructor(status: string) { this.status = status; }
}

@command({ namespace: 'Tasks' })
export class RegisterTask {
@field(String) taskId!: string;

handle(): Outcome<RegistrationReply> {
return response(new RegistrationReply(this.taskId === 'existing' ? 'alreadyExists' : 'created'));
}
}
```

This illustration uses a fixture-like condition to select the status; replace it with your actual business decision. The HTTP envelope has a `response` such as `{ "status": "alreadyExists" }` and status 200 in either case. The status is your application data, not an Arc branch tag.

You can also return the DTO directly instead of calling `response()`. `Outcome<T>` lets you choose `response(value)`, `rejected(...)`, or `denied(...)` on different paths; it is **not** serialized as a discriminated union. A rejection produces a 400 validation envelope without a response, and a denial produces a 403 authorization envelope without a response. A business error DTO passed to `response()` is an ordinary **200 success response**, not a rejection. No branch index, `kind`, or other discriminator is added to the wire format.

The proxy generator accepts alternative paths through aliases, promises, and outcomes only if each has the same client-visible representation. Boolean, enum, and literal-union responses retain their full client type and primitive metadata token, including when nullable. Query results with these types also receive primitive element metadata. It filters out values consumed by server-side handlers. It rejects different DTO constructors or cardinalities instead of choosing an arbitrary decoder; the diagnostic recommends one response DTO with an application-owned status field. This intentionally differs from Arc on .NET 22.23.0: .NET executes `OneOf<...>` and `Result<TSuccess, TError>` by unwrapping the selected value, but its generator picks a single response type and may misdecode another business branch. A .NET `Result` error DTO is returned as a **200 response**, not as a rejection. Do not rely on a client-visible union unless the client and generator both support its discriminant.

## Return more than one value

`tuple(first, second, ...)` returns several values from `handle()`. At most one of them may be the client response; every other value must be consumed on the server by a [response value handler](response-value-handlers.md), or the command fails. Ordinary arrays stay ordinary response values. A returned [command operation](operations/index.md) is one such server-side value.
`tuple(first, second, ...)` returns several **simultaneous** values from `handle()`; it does not express alternative outcomes. At most one of them may be the client response; every other value must be consumed on the server by a [response value handler](response-value-handlers.md), or the command fails. Ordinary arrays stay ordinary response values. A returned [command operation](operations/index.md) is one such server-side value. Each alternative path is checked independently: a tuple path may contain server-handled values and one response.

## Related

Expand Down
2 changes: 1 addition & 1 deletion Documentation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati
Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source.

:::caution[Source preview, no full parity]
No package is published to npm; the manifests are at version 0.38.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence.
No package is published to npm; the manifests are at version 0.39.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence.
:::

## What it looks like
Expand Down
3 changes: 2 additions & 1 deletion Documentation/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_<Su
| Calling from code | Supported | `executeCommand`, `execute(instance)`, `performQuery`, and `handle(request)`. See [Calling commands from code](../commands/calling-commands-from-code.md). | `Source/Core/for_ArcServer/when_executing_a_command`, `Source/Core/for_ArcApplicationBuilder/when_executing_an_instance` |
| Controller-based commands and queries | Not applicable | ASP.NET Core MVC only. | |

- **Several return values and response value handlers.** Ordinary arrays are not flattened. At generation time, Chronicle event types/arrays and integration wrappers plus Arc operations are omitted from command responses; branded tuple results select the sole unhandled value, and unions with a single visible type are supported. No general OneOf or Result union classification.
- **Several return values and response value handlers.** Ordinary arrays are not flattened. At generation time, Chronicle event types/arrays and integration wrappers plus Arc operations are omitted from command responses. Each alternative path through an `Outcome`, union, alias, promise, or tuple must have at most one client-visible value, and all paths must use the same client decoder and cardinality; otherwise generation fails with guidance to return one response DTO with an application-owned status field. There is no general client-visible response union or implicit branch discriminator. The paired .NET 22.23.0 fixture pins DTO, primitive, validation, denial, arbitrary error DTO (a 200 success), and tuple alternatives. TypeScript matches those HTTP envelopes, but unlike .NET's lossy proxy generator it rejects distinct DTO or cardinality alternatives rather than silently picking one decoder; see [Command outcomes](../commands/command-outcomes.md).
- **Command read models by key.** Drizzle command injection requires exactly one column-level `.primaryKey()` with an Arc `@field` on that key; without `@field` on an existing column-level key, the model can serve queries but injection fails at build (no resolver claims it). Table-level `primaryKey({ columns })` is not recognized, and `withDrizzle` rejects such a model at registration. Custom codec columns bind typed keys; plain columns bind primitives, with invalid GUID and integer keys rejected. Command injection is tested against SQLite, live MySQL 8.4, and live PostgreSQL 16 with node-postgres. PostgreSQL checks typed GUID and concept keys, tenant isolation, and missing required and optional rows. A type registered with both Chronicle and Drizzle fails at build when injected with `commandReadModel` (two resolver claims). Chronicle additionally supports `readModelForValidation(Type, { optional: true })` in model-bound command validators.

## Queries
Expand Down Expand Up @@ -153,6 +153,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_<Su

## Deliberate differences

- **Ambiguous command response alternatives.** Both runtimes serialize only the selected value; an arbitrary `Result<TSuccess, TError>` error DTO is a 200 success response, not a rejection. The TypeScript proxy generator rejects alternatives requiring different client decoders or cardinalities, while .NET 22.23.0 picks one response type and hydrates all alternatives through that decoder. Use one DTO with an application-owned status field for several client-visible business outcomes.
- **Malformed command input and filters.** TypeScript runs authorization filters with raw input when binding fails, allowing a denial to take precedence over a 400 response. .NET 22.23.0 returns 400 before those filters for malformed command input. The paired fixture checks both execute and `/validate` modes; authorized malformed input returns `malformedRequest` on both with different message text.
- **Global filter warnings and errors.** TypeScript applies severity filtering to each command and query fragment before short-circuiting; .NET short-circuits before filtering. Otherwise a filtered-out warning could skip later authorization filters. A thrown global command or query filter produces a 500 exception result, not .NET's 400 `IValidationFailure` result; ordinary per-definition validation failures still produce 400.
- **Severity 3 over HTTP.** On a command, `X-Allowed-Severity` accepts `0`, `1`, and `2`. A request that sends `3` is treated as `2` (Warning), so error-severity results still block with 400 and the handler does not run. Arc on .NET 22.23.0 accepts `3` and runs the command. Queries ignore the header. A trusted caller of `executeCommand` can still pass `Severity.Error`.
Expand Down
2 changes: 1 addition & 1 deletion Documentation/reference/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Packages
description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client.
---

Every package in this repository is at version 0.38.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways:
Every package in this repository is at version 0.39.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways:

- **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository's Yarn workspace.
- **In your own project.** Pack each package you need with `yarn workspace <package> pack --out <file>` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers.
Expand Down
Loading
Loading