diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json
index 39dee106..697a5329 100644
--- a/ContractTests/Client/package.json
+++ b/ContractTests/Client/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.core-client-contract",
- "version": "0.38.0",
+ "version": "0.39.0",
"private": true,
"type": "module",
"dependencies": {
diff --git a/ContractTests/DotNET/MultiOutcomeCases.cs b/ContractTests/DotNET/MultiOutcomeCases.cs
new file mode 100644
index 00000000..a0fdb767
--- /dev/null
+++ b/ContractTests/DotNET/MultiOutcomeCases.cs
@@ -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;
+
+///
+/// A business failure is still an ordinary response unless a value handler consumes it.
+///
+/// The failure code.
+public record OutcomeError(string Code);
+
+///
+/// Selects either a DTO response or a validation failure.
+///
+[Command]
+[AllowAnonymous]
+public record OutcomeDto(bool Fail)
+{
+ /// Returns the selected branch.
+ public OneOf Handle() => Fail
+ ? OneOf.FromT1(ValidationResult.Error("Outcome rejected", ["fail"]))
+ : OneOf.FromT0(new EchoReply("created"));
+}
+
+///
+/// Selects either a primitive response or an authorization denial.
+///
+[Command]
+[AllowAnonymous]
+public record OutcomePrimitive(bool Fail)
+{
+ /// Returns the selected branch.
+ public OneOf Handle() => Fail
+ ? OneOf.FromT1(AuthorizationResult.Failure("Outcome denied"))
+ : OneOf.FromT0(42);
+}
+
+///
+/// A Result error DTO is not a validation or authorization failure.
+///
+[Command]
+[AllowAnonymous]
+public record OutcomeErrorCase(bool Fail)
+{
+ /// Returns the selected branch.
+ public Result Handle() => Fail
+ ? Result.Failed(new OutcomeError("already-exists"))
+ : Result.Success(new EchoReply("created"));
+}
+
+///
+/// OneOf alternatives can contain simultaneous tuple values.
+///
+[Command]
+[AllowAnonymous]
+public record OutcomeTuple(bool Fail)
+{
+ /// Returns the selected branch.
+ public OneOf Handle() => Fail
+ ? OneOf.FromT1((new EchoReply("ignored"), ValidationResult.Error("Tuple rejected", ["fail"])))
+ : OneOf.FromT0(new EchoReply("created"));
+}
diff --git a/ContractTests/Http/conformance.test.mjs b/ContractTests/Http/conformance.test.mjs
index 03f978ce..fbbe3d70 100644
--- a/ContractTests/Http/conformance.test.mjs
+++ b/ContractTests/Http/conformance.test.mjs
@@ -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', '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/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', '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/api/outcome-primitive', { fail: true }, {
+ status: 403, body: command(403, { authorizationFailureReason: 'Outcome denied' })
+ });
+ await parity('Result arbitrary error DTO is a successful response', 'POST', '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/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', '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/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', '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/api/model-bound-command', { title: 'readable' }, {
status: 200, body: command(200, { response: 'readable' })
});
diff --git a/ContractTests/Http/fixture.mjs b/ContractTests/Http/fixture.mjs
index ecae09ae..2be045a4 100644
--- a/ContractTests/Http/fixture.mjs
+++ b/ContractTests/Http/fixture.mjs
@@ -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';
@@ -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'));
diff --git a/ContractTests/Http/modelBound/MultiOutcomeCases.ts b/ContractTests/Http/modelBound/MultiOutcomeCases.ts
new file mode 100644
index 00000000..33fd95a0
--- /dev/null
+++ b/ContractTests/Http/modelBound/MultiOutcomeCases.ts
@@ -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 { 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 { return this.fail ? denied('Outcome denied') : response(42); }
+}
+
+@command({ namespace: 'HttpFixture' })
+@allowAnonymous()
+class OutcomeErrorCase {
+ @field(Boolean) fail!: boolean;
+ handle(): Outcome {
+ 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')); }
+}
diff --git a/Documentation/commands/command-outcomes.md b/Documentation/commands/command-outcomes.md
index 3891d061..d8f6f5be 100644
--- a/Documentation/commands/command-outcomes.md
+++ b/Documentation/commands/command-outcomes.md
@@ -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 {
+ 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` 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` 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
diff --git a/Documentation/index.md b/Documentation/index.md
index 47062ec5..e24d033c 100644
--- a/Documentation/index.md
+++ b/Documentation/index.md
@@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati
Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source.
:::caution[Source preview, no full parity]
-No package is published to npm; the manifests are at version 0.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
diff --git a/Documentation/reference/capabilities.md b/Documentation/reference/capabilities.md
index f4d752e1..847be9f4 100644
--- a/Documentation/reference/capabilities.md
+++ b/Documentation/reference/capabilities.md
@@ -38,7 +38,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_` 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`.
diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md
index c80c1856..b7e4582e 100644
--- a/Documentation/reference/packages.md
+++ b/Documentation/reference/packages.md
@@ -3,7 +3,7 @@ title: Packages
description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client.
---
-Every package in this repository is at version 0.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 pack --out ` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers.
diff --git a/README.md b/README.md
index 19766c47..5ea9b60e 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ export class TaskItem {
| `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.14.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. |
| `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. |
-Every package manifest is at version 0.38.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended.
+Every package manifest is at version 0.39.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended.
## Try it
diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json
index d07b1cdf..8ea669eb 100644
--- a/Source/Chronicle/package.json
+++ b/Source/Chronicle/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.chronicle",
- "version": "0.38.0",
+ "version": "0.39.0",
"publishConfig": {
"access": "public"
},
@@ -34,8 +34,8 @@
"README.md"
],
"peerDependencies": {
- "@cratis/arc.core": "^0.38.0",
- "@cratis/arc.testing": "^0.38.0",
+ "@cratis/arc.core": "^0.39.0",
+ "@cratis/arc.testing": "^0.39.0",
"@cratis/chronicle": "^6.7.0",
"@cratis/fundamentals": "^7.19.6",
"rxjs": "^7.8.2",
diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json
index 640f1c6b..181db466 100644
--- a/Source/CodeAnalysis/package.json
+++ b/Source/CodeAnalysis/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/eslint-plugin-arc-core",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"description": "ESLint diagnostics for Arc for TypeScript server artifacts",
diff --git a/Source/Core/package.json b/Source/Core/package.json
index 677ae79b..08cc02fb 100644
--- a/Source/Core/package.json
+++ b/Source/Core/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.core",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"publishConfig": {
diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json
index 9fe67377..57bdfe7b 100644
--- a/Source/Cratis/package.json
+++ b/Source/Cratis/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/cratis",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"description": "Arc and experimental Chronicle composition for Node.js",
diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json
index 5a1fa237..934863fb 100644
--- a/Source/Drizzle/package.json
+++ b/Source/Drizzle/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.drizzle",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"publishConfig": {
@@ -29,7 +29,7 @@
"README.md"
],
"peerDependencies": {
- "@cratis/arc.core": "^0.38.0",
+ "@cratis/arc.core": "^0.39.0",
"@cratis/fundamentals": "^7.19.6",
"drizzle-orm": "^0.45.0",
"rxjs": "^7.8.2"
diff --git a/Source/Express/package.json b/Source/Express/package.json
index 07299cf3..5e11c9d4 100644
--- a/Source/Express/package.json
+++ b/Source/Express/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.express",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"publishConfig": {
diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json
index df951dbf..69beefb6 100644
--- a/Source/Fastify/package.json
+++ b/Source/Fastify/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.fastify",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"publishConfig": {
diff --git a/Source/Hono/package.json b/Source/Hono/package.json
index 7b431484..c7d63bae 100644
--- a/Source/Hono/package.json
+++ b/Source/Hono/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.hono",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"publishConfig": {
diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json
index c902491c..c5719d27 100644
--- a/Source/MongoDB/package.json
+++ b/Source/MongoDB/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.mongodb",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"publishConfig": {
@@ -29,7 +29,7 @@
"README.md"
],
"peerDependencies": {
- "@cratis/arc.core": "^0.38.0",
+ "@cratis/arc.core": "^0.39.0",
"@cratis/fundamentals": "^7.19.6",
"@opentelemetry/api": "^1.9.0",
"mongodb": "^6.21.0",
diff --git a/Source/Testing/package.json b/Source/Testing/package.json
index aa0007d4..ded88e95 100644
--- a/Source/Testing/package.json
+++ b/Source/Testing/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.testing",
- "version": "0.38.0",
+ "version": "0.39.0",
"type": "module",
"license": "MIT",
"publishConfig": {
diff --git a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts
index 9679591c..ed6dd654 100644
--- a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts
+++ b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts
@@ -21,8 +21,10 @@ export class SourceTypeResolver {
}
resolve(type: ts.Type, location: ts.Node, optional = false): SourceType {
const parts = type.isUnion() ? type.types : [type];
- const nullable = optional || parts.some(part => !!(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)));
- const defined = parts.filter(part => !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)));
+ const nullable = optional || parts.some(part => !!(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) ||
+ parts.length > 1 && !!(part.flags & ts.TypeFlags.Void));
+ const defined = parts.filter(part => !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) &&
+ !(parts.length > 1 && part.flags & ts.TypeFlags.Void));
if (defined.length === 1 && defined[0] !== type) return { ...this.resolve(defined[0]!, location), nullable };
if (defined.length > 1 && defined.every(part => !!(part.flags & ts.TypeFlags.BooleanLiteral)))
return { ...primitive('boolean', 'Boolean'), nullable };
diff --git a/Source/Tools/ProxyGenerator/commandResponseType.ts b/Source/Tools/ProxyGenerator/commandResponseType.ts
index 576c8c1e..c70ffe1f 100644
--- a/Source/Tools/ProxyGenerator/commandResponseType.ts
+++ b/Source/Tools/ProxyGenerator/commandResponseType.ts
@@ -1,51 +1,31 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
import ts from 'typescript';
-import { isPackageSymbol, isTypeFrom } from './sourceSymbols.js';
+import { isPackageSymbol, isStandardType, isTypeFrom } from './sourceSymbols.js';
import { isOutcomeType } from './isOutcomeType.js';
-/** Select the one value that survives command response handlers, without resolving handled server types as client models. */
-export function commandResponseType(type: ts.Type, checker: ts.TypeChecker, location: ts.Node): ts.Type | undefined {
+/** A path is one possible execution; members of a path are returned together, not alternatives. */
+export type CommandResponsePath = { readonly members: readonly ts.Type[]; readonly response?: ts.Type };
+export type CommandResponseDescriptor = { readonly paths: readonly CommandResponsePath[]; readonly response?: ts.Type };
+
+/** Analyze all command return paths before choosing the one client decoder shared by them. */
+export function describeCommandResponse(type: ts.Type, checker: ts.TypeChecker, location: ts.Node): CommandResponseDescriptor {
const fail = (message: string): never => {
const position = location.getSourceFile().getLineAndCharacterOfPosition(location.getStart());
throw new Error(`${location.getSourceFile().fileName}:${position.line + 1}:${position.character + 1}: ${message}`);
};
- const select = (candidate: ts.Type): ts.Type | undefined => {
- const awaited = checker.getAwaitedType(candidate) ?? candidate;
- if (awaited !== candidate) return select(awaited);
- // The compiler expands Outcome in mixed unions, so recognize its branded branches rather than only its alias.
- if (isOutcomeType(candidate, checker)) {
- const kind = candidate.getProperty('kind');
- const kindType = kind && checker.getTypeOfSymbolAtLocation(kind, location);
- if (kindType?.isStringLiteral() && kindType.value === 'response') {
- const value = candidate.getProperty('value');
- return value && select(checker.getTypeOfSymbolAtLocation(value, location));
- }
- if (kindType?.isStringLiteral() && (kindType.value === 'validation' || kindType.value === 'denied')) return undefined;
- }
- if (candidate.isUnion()) {
- const visible = candidate.types.map(select).filter((part): part is ts.Type => part !== undefined);
- if (!visible.length) return undefined;
- const first = visible[0]!;
- if (visible.some(part => !checker.isTypeAssignableTo(part, first) || !checker.isTypeAssignableTo(first, part)))
- return fail('Multiple unhandled command response types');
- return first;
- }
- if (candidate.flags & (ts.TypeFlags.Void | ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) return undefined;
- if (isTypeFrom(checker, candidate, 'EventSourceIdResponse', '@cratis/arc.chronicle')) {
- const value = candidate.getProperty('value');
- return value && select(checker.getTypeOfSymbolAtLocation(value, location));
- }
+ const visible = (candidate: ts.Type): boolean => {
+ if (candidate.flags & (ts.TypeFlags.Void | ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) return false;
if (['EventsWithConcurrencyScopes', 'AggregateRootCommitResult', 'RoutedEvent'].some(name =>
- isTypeFrom(checker, candidate, name, '@cratis/arc.chronicle'))) return undefined;
+ isTypeFrom(checker, candidate, name, '@cratis/arc.chronicle'))) return false;
if (['CommandOperation', 'CommandOperations'].some(name => isTypeFrom(checker, candidate, name, '@cratis/arc.core')) ||
- candidate.getBaseTypes()?.some(base => isTypeFrom(checker, base, 'CommandOperation', '@cratis/arc.core'))) return undefined;
+ candidate.getBaseTypes()?.some(base => isTypeFrom(checker, base, 'CommandOperation', '@cratis/arc.core'))) return false;
const declaration = candidate.getSymbol()?.declarations?.find(ts.isClassDeclaration);
if (declaration && (ts.getDecorators(declaration) ?? []).some(decorator => {
const expression = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression;
return isPackageSymbol(checker, ts.isPropertyAccessExpression(expression) ? expression.name : expression,
'eventType', '@cratis/chronicle');
- })) return undefined;
+ })) return false;
if (checker.isArrayType(candidate)) {
const element = checker.getTypeArguments(candidate as ts.TypeReference)[0];
if (element && (isTypeFrom(checker, element, 'CommandOperation', '@cratis/arc.core') ||
@@ -53,17 +33,84 @@ export function commandResponseType(type: ts.Type, checker: ts.TypeChecker, loca
return fail('Use CommandOperations instead of returning an ordinary collection of operation declarations');
if (element && (element.isUnion() ? element.types : [element]).some(part => isOutcomeType(part, checker)))
return fail('Return an Outcome for the entire command response, not an array of Outcome values');
- if (element && select(element) === undefined) return undefined;
+ if (element && (element.isUnion() ? element.types.every(part => !visible(part)) : !visible(element))) return false;
+ }
+ return true;
+ };
+ // A type's client representation is its decoder and cardinality, not its structural assignability.
+ // In particular, two unrelated DTO classes with the same fields require different constructors.
+ const representation = (candidate: ts.Type): string => {
+ if (checker.isArrayType(candidate)) {
+ const element = checker.getTypeArguments(candidate as ts.TypeReference)[0];
+ return `array:${element ? representation(element) : '?'}`;
+ }
+ if (candidate.flags & ts.TypeFlags.StringLike) return 'String';
+ if (candidate.flags & ts.TypeFlags.NumberLike) return 'Number';
+ if (candidate.flags & ts.TypeFlags.BooleanLike) return 'Boolean';
+ const symbol = candidate.aliasSymbol ?? candidate.getSymbol();
+ if (isStandardType(candidate, 'Date')) return 'Date';
+ const base = candidate.getBaseTypes()?.find(part => isTypeFrom(checker, part, 'ConceptAs', '@cratis/fundamentals'));
+ if (base) {
+ const value = checker.getTypeArguments(base as ts.TypeReference)[0];
+ if (value) return representation(value);
+ }
+ return `type:${symbol ? checker.getFullyQualifiedName(symbol) : checker.typeToString(candidate)}`;
+ };
+ const paths = (candidate: ts.Type): ts.Type[][] => {
+ const awaited = checker.getAwaitedType(candidate) ?? candidate;
+ if (awaited !== candidate) return paths(awaited);
+ // TypeScript expands Outcome in mixed unions; the package-owned brand identifies its branches.
+ if (isOutcomeType(candidate, checker)) {
+ const kind = candidate.getProperty('kind');
+ const kindType = kind && checker.getTypeOfSymbolAtLocation(kind, location);
+ if (kindType?.isStringLiteral() && kindType.value === 'response') {
+ const value = candidate.getProperty('value');
+ return value ? paths(checker.getTypeOfSymbolAtLocation(value, location)) : [[]];
+ }
+ if (kindType?.isStringLiteral() && (kindType.value === 'validation' || kindType.value === 'denied')) return [[]];
+ }
+ // A literal union has one decoder (the primitive or enum), not one alternative per literal.
+ if (candidate.isUnion()) {
+ const values = candidate.types.filter(part => !(part.flags &
+ (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)));
+ if (values.length && values.every(part => !!(part.flags &
+ (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral))))
+ return visible(candidate) ? [[candidate]] : [[]];
+ return candidate.types.flatMap(paths);
+ }
+ if (isTypeFrom(checker, candidate, 'EventSourceIdResponse', '@cratis/arc.chronicle')) {
+ const value = candidate.getProperty('value');
+ return value ? paths(checker.getTypeOfSymbolAtLocation(value, location)) : [[]];
}
if (isTypeFrom(checker, candidate, 'ArcTuple', '@cratis/arc.core')) {
const values = checker.getTypeArguments(candidate as ts.TypeReference)[0];
if (!values || !checker.isTupleType(values)) return fail('Unsupported command response tuple');
- const visible = checker.getTypeArguments(values as ts.TypeReference).map(select)
- .filter((part): part is ts.Type => part !== undefined);
- if (visible.length > 1) return fail('Multiple unhandled command response values');
- return visible[0];
+ return checker.getTypeArguments(values as ts.TypeReference).reduce((groups, member) =>
+ groups.flatMap(group => paths(member).map(path => [...group, ...path])), [[]]);
}
- return candidate;
+ return visible(candidate) ? [[candidate]] : [[]];
};
- return select(type);
+ const alternatives = paths(type).map(members => {
+ if (members.length > 1) return fail('Multiple unhandled command response values');
+ return { members, response: members[0] };
+ });
+ const responses = alternatives.flatMap(path => path.response ? [path.response] : []);
+ const first = responses[0];
+ if (first && responses.some(part => representation(part) !== representation(first)))
+ return fail('Multiple unhandled command response types; return one response DTO with an application-owned status field');
+ // Distinct concepts sharing a wire representation have no single concept token for metadata.
+ const concepts = responses.flatMap(part => part.getBaseTypes()?.filter(base =>
+ isTypeFrom(checker, base, 'ConceptAs', '@cratis/fundamentals')) ?? []);
+ const primitive = responses.some(part => part !== first) && concepts.length ?
+ checker.getTypeArguments(concepts[0] as ts.TypeReference)[0] : undefined;
+ // Prefer the broad type when a literal and its primitive appear on separate paths.
+ const response = primitive ?? responses.find(part => part === first && !(part.flags &
+ (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral))) ??
+ responses.find(part => !(part.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral))) ?? first;
+ return { paths: alternatives, response };
+}
+
+/** Select the single client-visible return type shared by every execution path. */
+export function commandResponseType(type: ts.Type, checker: ts.TypeChecker, location: ts.Node): ts.Type | undefined {
+ return describeCommandResponse(type, checker, location).response;
}
diff --git a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts
index d858551a..184e029a 100644
--- a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts
+++ b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts
@@ -1,7 +1,7 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-import { field } from '@cratis/fundamentals';
-import { command, CommandOperation, CommandOperations, denied, rejected, response, tuple, validation } from '@cratis/arc.core';
+import { ConceptAs, field } from '@cratis/fundamentals';
+import { command, CommandOperation, CommandOperations, denied, query, readModel, rejected, response, tuple, validation } from '@cratis/arc.core';
import type { ArcTuple, Outcome } from '@cratis/arc.core';
import { eventType as chronicleEvent } from '@cratis/chronicle/events';
import {
@@ -11,10 +11,15 @@ import type { RoutedEvent } from '../../../../../Chronicle/eventForEventSourceId
function eventType() { return (target: unknown, context: ClassDecoratorContext) => { void target; void context; }; }
@chronicleEvent() class Registered { name = ''; }
+@chronicleEvent() class Removed { name = ''; }
@eventType() export class Plain { name = ''; }
class Save extends CommandOperation { execute(): void {} }
@command() export class JustEvent { handle(): Registered { return new Registered(); } }
@command() export class AsyncEvents { async handle(): Promise { return [new Registered()]; } }
+@command() export class MixedEvents { handle(): (Registered | Removed)[] { return [new Registered(), new Removed()]; } }
+@command() export class MixedEventsAndOperation {
+ handle() { return tuple([new Registered(), new Removed()] as (Registered | Removed)[], new Save()); }
+}
@command() export class JustOperation { handle(): CommandOperations { return new CommandOperations([new Save()]); } }
@command() export class Operation { handle(): Save { return new Save(); } }
@command() export class Routed { handle(): RoutedEvent { return eventForEventSourceId({ eventSourceId: 'id', event: new Registered() }); } }
@@ -59,3 +64,50 @@ class Save extends CommandOperation { execute(): void {} }
@command() export class EventArrayOrRejection {
handle(): Registered[] | Outcome { return rejected(validation('Invalid')); }
}
+
+type AliasedOutcome = Outcome;
+@command() export class AliasedResponse {
+ @field(Boolean) wrapped = false;
+ handle(): AliasedOutcome | Plain { return this.wrapped ? response(new Plain()) : new Plain(); }
+}
+@command() export class AwaitedAlternative {
+ @field(Boolean) later = false;
+ handle(): Plain | Promise { return this.later ? Promise.resolve(new Plain()) : new Plain(); }
+}
+@command() export class TupleAlternatives {
+ @field(Boolean) wrapped = false;
+ handle(): Outcome> | Plain {
+ return this.wrapped ? response(tuple(new Registered(), new Plain())) : new Plain();
+ }
+}
+@command() export class NestedTupleAlternative {
+ handle(): ArcTuple]> | Outcome {
+ return response(new Plain());
+ }
+}
+@command() export class ArrayAlternatives {
+ @field(Boolean) wrapped = false;
+ handle(): Plain[] | Outcome { return this.wrapped ? response([new Plain()]) : [new Plain()]; }
+}
+@command() export class SamePrimitivePaths {
+ @field(Boolean) wrapped = false;
+ handle(): Outcome | string { return this.wrapped ? response('visible') : 'visible'; }
+}
+export enum Color { Red = 1, Blue = 2 }
+@command() export class BooleanResult { handle(): boolean { return true; } }
+@command() export class ColorResult { handle(): Color { return Color.Blue; } }
+@command() export class LiteralResult { handle(): 'created' | 'existing' { return 'created'; } }
+@command() export class OptionalBooleanResult { handle(): boolean | undefined { return undefined; } }
+@command() export class VoidBooleanResult { handle(): boolean | void { return undefined; } }
+@command() export class NullableColorResult { handle(): Color | null { return null; } }
+@command() export class OptionalLiteralResult { handle(): 'created' | 'existing' | undefined { return undefined; } }
+@command() export class WrappedBooleanResult { handle(): Outcome { return response(true); } }
+export class TaskId extends ConceptAs { static readonly valueType = String; }
+export class UserId extends ConceptAs { static readonly valueType = String; }
+@command() export class DistinctConcepts { handle(): TaskId | UserId { return new TaskId('task'); } }
+@command() export class DistinctConceptArrays { handle(): TaskId[] | UserId[] { return [new TaskId('task')]; } }
+export class Date { @field(String) value = ''; }
+@command() export class NamedDate { handle(): Date { return new Date(); } }
+@readModel() export class LiteralQueries {
+ @query() static exists(): boolean { return true; }
+}
diff --git a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/given/Invalid.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/given/Invalid.ts
index 01ee7b5a..b819f721 100644
--- a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/given/Invalid.ts
+++ b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/given/Invalid.ts
@@ -1,6 +1,14 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-import { CommandOperation, tuple } from '@cratis/arc.core';
+import { CommandOperation, response, tuple } from '@cratis/arc.core';
+import type { Outcome } from '@cratis/arc.core';
+import { Date as UserDate } from '../Responses.js';
class Save extends CommandOperation { execute(): void {} }
export class TooMany { handle() { return tuple('first', 2); } }
export class BareOperations { handle(): Save[] { return [new Save()]; } }
+class Created { value = ''; }
+class Existing { value = ''; }
+export class DifferentDecoders { handle(): Outcome { return response(new Created()); } }
+export class DifferentCardinality { handle(): Created | Created[] { return new Created(); } }
+export class DifferentDates { handle(): Date | UserDate { return new UserDate(); } }
+export class FakeOutcome { handle() { return { kind: 'response' as const, value: new Created() }; } }
diff --git a/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_equivalent_alternative_paths.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_equivalent_alternative_paths.ts
new file mode 100644
index 00000000..22575939
--- /dev/null
+++ b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_equivalent_alternative_paths.ts
@@ -0,0 +1,108 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+import { resolve } from 'node:path';
+import ts from 'typescript';
+import { analyzeSource } from '../../analyzeSource.js';
+import { describeCommandResponse } from '../../commandResponseType.js';
+import { renderGeneratedMetadata } from '../../renderGeneratedMetadata.js';
+import { sourceProgram } from '../../sourceProgram.js';
+
+const root = resolve(import.meta.dirname, '../given');
+
+describe('when analyzing alternative command paths', () => {
+ let analysis: ReturnType;
+ let metadata: string;
+ beforeEach(() => {
+ analysis = analyzeSource(resolve(root, 'tsconfig.json'), resolve(root, 'Features'));
+ metadata = renderGeneratedMetadata(resolve(root, 'tsconfig.json'), resolve(root, 'Features'), resolve(root, 'generatedMetadata.ts'));
+ });
+ const verify = (name: string, proxy: string, shape: string | RegExp, nullable = false) => {
+ analysis.operations.find(item => item.name === name)!.result.text.should.equal(proxy, name);
+ const entry = metadata.split('\n').find(line => line.includes(`\\"name\\":\\"${name}\\"`))!;
+ if (typeof shape === 'string') entry.should.contain(`handleResult: { cardinality: 'one', nullable: ${nullable}, element: ${shape} }`);
+ else {
+ entry.should.match(shape);
+ const alias = /handleResult: \{[^}]*element: (_arc\d+) \}/.exec(entry)![1];
+ const model = proxy.replace(/\[\]$/, '');
+ metadata.should.contain(`import { ${model} as ${alias} }`);
+ }
+ return entry;
+ };
+
+ it('should retain one DTO for an alias and an unwrapped path', () => {
+ verify('AliasedResponse', 'Plain', /handleResult: \{ cardinality: 'one', nullable: false, element: _arc\d+ \}/);
+ });
+ it('should await a promise path before comparing decoders', () => {
+ verify('AwaitedAlternative', 'Plain', /handleResult: \{ cardinality: 'one', nullable: false, element: _arc\d+ \}/);
+ });
+ it('should distinguish tuple members from alternative paths', () => {
+ for (const name of ['TupleAlternatives', 'NestedTupleAlternative'])
+ verify(name, 'Plain', /handleResult: \{ cardinality: 'one', nullable: false, element: _arc\d+ \}/);
+ });
+ it('should preserve array cardinality across paths', () => {
+ verify('ArrayAlternatives', 'Plain[]', /handleResult: \{ cardinality: 'many', nullable: false, element: _arc\d+ \}/);
+ });
+ it('should retain a primitive result across wrapped and unwrapped paths', () => {
+ verify('SamePrimitivePaths', 'string', 'String');
+ });
+ it('should retain boolean as a full union rather than the first literal', () => {
+ const entry = verify('BooleanResult', 'boolean', 'Boolean');
+ entry.should.not.contain('handleValueResult:');
+ });
+ it('should retain an enum as a full union', () => {
+ const entry = verify('ColorResult', 'Color', 'Number');
+ entry.should.not.contain('handleValueResult:');
+ });
+ it('should retain a string literal union', () => {
+ const entry = verify('LiteralResult', '"created" | "existing"', 'String');
+ entry.should.not.contain('handleValueResult:');
+ });
+ it('should retain an optional boolean and its nullability', () => {
+ verify('OptionalBooleanResult', 'boolean', 'Boolean', true);
+ });
+ it('should retain a boolean union with void as nullable', () => {
+ verify('VoidBooleanResult', 'boolean', 'Boolean', true);
+ });
+ it('should retain a nullable enum and its nullability', () => {
+ verify('NullableColorResult', 'Color', 'Number', true);
+ });
+ it('should retain an optional string literal union and its nullability', () => {
+ verify('OptionalLiteralResult', '"created" | "existing"', 'String', true);
+ });
+ it('should retain the full boolean in an Outcome', () => {
+ verify('WrappedBooleanResult', 'boolean', 'Boolean').should.contain("handleValueResult: { cardinality: 'void', nullable: true }");
+ });
+ it('should use the shared primitive for distinct concepts', () => {
+ verify('DistinctConcepts', 'string', 'String').should.not.match(/handleResult: \{[^}]*element: _arc\d+/);
+ });
+ it('should use the shared primitive for arrays of distinct concepts', () => {
+ analysis.operations.find(item => item.name === 'DistinctConceptArrays')!.result.text.should.equal('string[]');
+ const entry = metadata.split('\n').find(line => line.includes('\\"name\\":\\"DistinctConceptArrays\\"'))!;
+ entry.should.contain("handleResult: { cardinality: 'many', nullable: false, element: String }");
+ entry.should.not.match(/handleResult: \{[^}]*element: _arc\d+/);
+ });
+ it('should not treat a user DTO named Date as the standard Date', () => {
+ verify('NamedDate', 'Date', /handleResult: \{ cardinality: 'one', nullable: false, element: _arc\d+ \}/);
+ });
+});
+
+describe('when inspecting alternative command paths', () => {
+ const examine = (name: string) => {
+ const program = sourceProgram(resolve(root, 'tsconfig.json'));
+ const checker = program.getTypeChecker();
+ const file = program.getSourceFile(resolve(root, 'Features/Responses.ts'))!;
+ const method = file.statements.filter(ts.isClassDeclaration).find(owner => owner.name?.text === name)!
+ .members.find(ts.isMethodDeclaration)!;
+ return describeCommandResponse(checker.getReturnTypeOfSignature(checker.getSignatureFromDeclaration(method)!), checker, method);
+ };
+ it('should keep distinct tuple alternatives with one simultaneous response each', () => {
+ const descriptor = examine('TupleAlternatives');
+ descriptor.paths.filter(path => path.response).should.have.lengthOf(2);
+ descriptor.paths.every(path => path.members.length <= 1).should.equal(true);
+ });
+ it('should not treat a nested tuple as alternative simultaneous client responses', () => {
+ const descriptor = examine('NestedTupleAlternative');
+ descriptor.paths.filter(path => path.response).should.have.lengthOf(2);
+ descriptor.paths.every(path => path.members.length <= 1).should.equal(true);
+ });
+});
diff --git a/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_handled_values.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_handled_values.ts
index 4d3cfb5d..03fb725b 100644
--- a/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_handled_values.ts
+++ b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_handled_values.ts
@@ -14,8 +14,9 @@ describe('when selecting a command client response with handled values', given(r
let analysis: ReturnType;
beforeEach(() => { analysis = analyzeSource(context.project, context.artifacts); });
it('should omit handled values without creating client event models', () => {
- for (const name of ['JustEvent', 'AsyncEvents', 'JustOperation', 'Operation', 'Routed', 'Scoped', 'Committed', 'EventOrNothing'])
+ for (const name of ['JustEvent', 'AsyncEvents', 'MixedEvents', 'MixedEventsAndOperation', 'JustOperation', 'Operation', 'Routed', 'Scoped', 'Committed', 'EventOrNothing'])
analysis.operations.find(item => item.name === name)!.result.void.should.equal(true, name);
analysis.models.map(item => item.name).should.not.include('Registered');
+ analysis.models.map(item => item.name).should.not.include('Removed');
});
}));
diff --git a/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_incompatible_alternatives.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_incompatible_alternatives.ts
new file mode 100644
index 00000000..0b033a6a
--- /dev/null
+++ b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_incompatible_alternatives.ts
@@ -0,0 +1,37 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+import { resolve } from 'node:path';
+import ts from 'typescript';
+import { commandResponseType } from '../../commandResponseType.js';
+import { sourceProgram } from '../../sourceProgram.js';
+
+const root = resolve(import.meta.dirname, '../given');
+
+describe('when comparing incompatible client alternatives', () => {
+ const responseFor = (name: string) => {
+ const program = sourceProgram(resolve(root, 'tsconfig.json'));
+ const checker = program.getTypeChecker();
+ const file = program.getSourceFile(resolve(root, 'Features/given/Invalid.ts'))!;
+ const method = file.statements.filter(ts.isClassDeclaration).find(owner => owner.name?.text === name)!
+ .members.find(ts.isMethodDeclaration)!;
+ return { checker, selected: commandResponseType(checker.getReturnTypeOfSignature(checker.getSignatureFromDeclaration(method)!), checker, method) };
+ };
+ const errorFor = (name: string): string => {
+ try { responseFor(name); }
+ catch (error) { return (error as Error).message; }
+ return '';
+ };
+ it('should reject different DTO constructors even if their properties match', () => {
+ errorFor('DifferentDecoders').should.contain('one response DTO with an application-owned status field');
+ });
+ it('should reject different array cardinalities', () => {
+ errorFor('DifferentCardinality').should.contain('one response DTO with an application-owned status field');
+ });
+ it('should not treat a user class named Date as the standard Date', () => {
+ errorFor('DifferentDates').should.contain('one response DTO with an application-owned status field');
+ });
+ it('should not unwrap a shape that only imitates an Outcome', () => {
+ const { selected, checker } = responseFor('FakeOutcome');
+ checker.typeToString(selected!).should.equal('{ kind: "response"; value: Created; }');
+ });
+});
diff --git a/Source/Tools/ProxyGenerator/for_renderGeneratedMetadata/when_rendering_query_results/with_boolean_return.ts b/Source/Tools/ProxyGenerator/for_renderGeneratedMetadata/when_rendering_query_results/with_boolean_return.ts
new file mode 100644
index 00000000..57ebc9b0
--- /dev/null
+++ b/Source/Tools/ProxyGenerator/for_renderGeneratedMetadata/when_rendering_query_results/with_boolean_return.ts
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+import { resolve } from 'node:path';
+import { renderGeneratedMetadata } from '../../renderGeneratedMetadata.js';
+
+const root = resolve(import.meta.dirname, '../../for_commandResponseType/given');
+
+describe('when rendering a query with a boolean return', () => {
+ let metadata: string;
+ beforeEach(() => {
+ metadata = renderGeneratedMetadata(resolve(root, 'tsconfig.json'), resolve(root, 'Features'), resolve(root, 'generatedMetadata.ts'));
+ });
+ it('should include the boolean element token in its result metadata', () => {
+ const entry = metadata.split('\n').find(line => line.includes('\\"name\\":\\"LiteralQueries\\"'))!;
+ entry.should.contain("result: { cardinality: 'one', nullable: false, element: Boolean, observable: false }");
+ });
+});
diff --git a/Source/Tools/ProxyGenerator/metadataResult.ts b/Source/Tools/ProxyGenerator/metadataResult.ts
index d6ff09b3..1d8ab43d 100644
--- a/Source/Tools/ProxyGenerator/metadataResult.ts
+++ b/Source/Tools/ProxyGenerator/metadataResult.ts
@@ -2,23 +2,37 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
import ts from 'typescript';
import { MetadataImports } from './MetadataImports.js';
+import { isStandardType, isTypeFrom } from './sourceSymbols.js';
/** Describe result cardinality and element type without executing the method. */
export function metadataResult(type: ts.Type, checker: ts.TypeChecker, imports: MetadataImports, location: ts.Node,
paged = false, observable?: boolean, includeElement = true, selectedParts?: readonly ts.Type[]): string {
const parts = selectedParts ?? (type.isUnion() ? type.types : [type]);
if (!parts.length) return "{ cardinality: 'void', nullable: true }";
- const nullable = parts.some(part => !!(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)));
- const value = parts.find(part => !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))) ?? type;
+ const nullable = parts.some(part => !!(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) ||
+ parts.length > 1 && !!(part.flags & ts.TypeFlags.Void));
+ const value = parts.find(part => !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) &&
+ !(parts.length > 1 && part.flags & ts.TypeFlags.Void)) ?? type;
const many = checker.isArrayType(value);
- const element = many ? checker.getTypeArguments(value as ts.TypeReference)[0] ?? value : value;
+ let element = many ? checker.getTypeArguments(value as ts.TypeReference)[0] ?? value : value;
+ if (many && parts.length > 1 && parts.every(part => checker.isArrayType(part))) {
+ const elements = parts.map(part => checker.getTypeArguments(part as ts.TypeReference)[0]);
+ const concepts = elements.map(part => part?.getBaseTypes()?.find(base =>
+ isTypeFrom(checker, base, 'ConceptAs', '@cratis/fundamentals')));
+ if (elements.some(part => part !== elements[0]) && concepts.every((concept): concept is ts.BaseType => !!concept)) {
+ const primitives = concepts.map(concept => checker.getTypeArguments(concept as ts.TypeReference)[0]);
+ if (primitives[0] && primitives.every(primitive => primitive === primitives[0])) element = primitives[0];
+ }
+ }
const cardinality = paged ? 'paged' : many ? 'many' : value.flags & ts.TypeFlags.Void ? 'void' : 'one';
let token: string | undefined;
if (!includeElement) return `{ cardinality: '${cardinality}', nullable: ${nullable} }`;
- if (element.flags & ts.TypeFlags.StringLike) token = 'String';
- else if (element.flags & ts.TypeFlags.NumberLike) token = 'Number';
- else if (element.flags & ts.TypeFlags.BooleanLike) token = 'Boolean';
- else if (element.getSymbol()?.getName() === 'Date') token = 'Date';
+ const members = (element.isUnion() ? element.types : [element]).filter(part =>
+ !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)));
+ if (members.length && members.every(part => !!(part.flags & ts.TypeFlags.StringLike))) token = 'String';
+ else if (members.length && members.every(part => !!(part.flags & ts.TypeFlags.NumberLike))) token = 'Number';
+ else if (members.length && members.every(part => !!(part.flags & ts.TypeFlags.BooleanLike))) token = 'Boolean';
+ else if (isStandardType(element, 'Date')) token = 'Date';
else if (element.getSymbol()?.declarations?.some(ts.isClassDeclaration)) token = imports.classToken(element, location);
return `{ cardinality: '${cardinality}', nullable: ${nullable}${token ? `, element: ${token}` : ''}` +
`${observable === undefined ? '' : `, observable: ${observable}`} }`;
diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json
index 36672dc2..41c50fc5 100644
--- a/Source/Tools/ProxyGenerator/package.json
+++ b/Source/Tools/ProxyGenerator/package.json
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.proxygenerator",
- "version": "0.38.0",
+ "version": "0.39.0",
"description": "TypeScript source analyzer and deterministic Arc client proxy generator",
"repository": {
"type": "git",
diff --git a/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts b/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts
index 8ed8c91e..e5a8d70b 100644
--- a/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts
+++ b/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts
@@ -8,7 +8,7 @@ import { MetadataImports } from './MetadataImports.js';
import { metadataParameter } from './metadataParameter.js';
import { metadataResult } from './metadataResult.js';
import { queryResult } from './queryResult.js';
-import { commandResponseType } from './commandResponseType.js';
+import { describeCommandResponse } from './commandResponseType.js';
import { isOutcomeType } from './isOutcomeType.js';
const callArguments = (expression: ts.Expression | undefined): readonly ts.Expression[] =>
@@ -100,11 +100,14 @@ export function renderArtifactMetadata(declaration: ts.ClassDeclaration, checker
const handleSignature = handle && checker.getSignatureFromDeclaration(handle);
const handleReturn = handleSignature && (checker.getAwaitedType(checker.getReturnTypeOfSignature(handleSignature)) ??
checker.getReturnTypeOfSignature(handleSignature));
- const responseType = handleReturn && commandResponseType(handleReturn, checker, handle!);
- const responseShape = responseType ? metadataResult(responseType, checker, imports, handle!) :
+ const response = handleReturn && describeCommandResponse(handleReturn, checker, handle!);
+ const responseType = response?.response;
+ const responseShape = responseType ? metadataResult(responseType, checker, imports, handle!, false, undefined, true,
+ checker.isArrayType(responseType) ? response.paths.flatMap(path => path.response ? [path.response] : []) : undefined) :
"{ cardinality: 'void', nullable: false }";
const valueParts = handleReturn && (handleReturn.isUnion() ? handleReturn.types : [handleReturn])
- .filter(part => !isOutcomeType(part, checker));
+ .filter(part => !isOutcomeType(part, checker))
+ .map(part => checker.getAwaitedType(part) ?? part);
const valueShape = handleReturn && (responseType === handleReturn ? responseShape :
metadataResult(handleReturn, checker, imports, handle!, false, undefined, false, valueParts));
return `{ type: ${type}, signature: ${JSON.stringify(signature)}, metadata: {` +
diff --git a/yarn.lock b/yarn.lock
index ab6ca7fe..51fb1db5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -45,8 +45,8 @@ __metadata:
rxjs: "npm:^7.8.2"
zod: "npm:^4.1.0"
peerDependencies:
- "@cratis/arc.core": ^0.38.0
- "@cratis/arc.testing": ^0.38.0
+ "@cratis/arc.core": ^0.39.0
+ "@cratis/arc.testing": ^0.39.0
"@cratis/chronicle": ^6.7.0
"@cratis/fundamentals": ^7.19.6
rxjs: ^7.8.2
@@ -151,7 +151,7 @@ __metadata:
rxjs: "npm:^7.8.2"
sql.js: "npm:^1.14.2"
peerDependencies:
- "@cratis/arc.core": ^0.38.0
+ "@cratis/arc.core": ^0.39.0
"@cratis/fundamentals": ^7.19.6
drizzle-orm: ^0.45.0
rxjs: ^7.8.2
@@ -217,7 +217,7 @@ __metadata:
mongodb: "npm:^6.21.0"
rxjs: "npm:^7.8.2"
peerDependencies:
- "@cratis/arc.core": ^0.38.0
+ "@cratis/arc.core": ^0.39.0
"@cratis/fundamentals": ^7.19.6
"@opentelemetry/api": ^1.9.0
mongodb: ^6.21.0