From bf157508ab4734dd0b6b6a8f5f6ff2346c8d07a4 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 14:59:32 +0200 Subject: [PATCH 1/7] test: characterize pinned OneOf and Result HTTP outcomes --- ContractTests/DotNET/MultiOutcomeCases.cs | 68 +++++++++++++++++++ ContractTests/Http/conformance.test.mjs | 18 +++++ ContractTests/Http/fixture.mjs | 2 + .../Http/modelBound/MultiOutcomeCases.ts | 39 +++++++++++ 4 files changed, 127 insertions(+) create mode 100644 ContractTests/DotNET/MultiOutcomeCases.cs create mode 100644 ContractTests/Http/modelBound/MultiOutcomeCases.ts 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..067422dc 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 [ + ['DTO response', 'outcome-dto', { response: { value: 'created' } }], + ['primitive response', 'outcome-primitive', { response: 42 }], + ['Result success DTO', 'outcome-error-case', { response: { value: 'created' } }], + ['tuple alternative response', 'outcome-tuple', { response: { value: 'created' } }] + ]) await parity(`OneOf ${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..345e8929 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, OutcomeErrorCase, 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, OutcomeErrorCase, 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..ce605b8e --- /dev/null +++ b/ContractTests/Http/modelBound/MultiOutcomeCases.ts @@ -0,0 +1,39 @@ +// 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() +export class OutcomeErrorCase { + @field(Boolean) fail!: boolean; + handle(): Outcome { + return this.fail ? response(new OutcomeError('already-exists')) : response(new OutcomeReply('created')); + } +} + +@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')); } +} From d93fead9f9fb7293b251850103a4a0eb3ade324a Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 15:09:36 +0200 Subject: [PATCH 2/7] feat: share bounded command response path analysis --- ContractTests/Http/fixture.mjs | 4 +- .../Http/modelBound/MultiOutcomeCases.ts | 6 +- .../ProxyGenerator/commandResponseType.ts | 109 ++++++++++++------ .../given/Features/Responses.ts | 29 +++++ .../given/Features/given/Invalid.ts | 8 +- .../with_equivalent_alternative_paths.ts | 57 +++++++++ .../with_incompatible_alternatives.ts | 36 ++++++ .../ProxyGenerator/renderArtifactMetadata.ts | 8 +- 8 files changed, 212 insertions(+), 45 deletions(-) create mode 100644 Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_equivalent_alternative_paths.ts create mode 100644 Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_incompatible_alternatives.ts diff --git a/ContractTests/Http/fixture.mjs b/ContractTests/Http/fixture.mjs index 345e8929..2be045a4 100644 --- a/ContractTests/Http/fixture.mjs +++ b/ContractTests/Http/fixture.mjs @@ -8,7 +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, OutcomeErrorCase, OutcomeTuple } from './modelBound/dist/MultiOutcomeCases.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'; @@ -195,7 +195,7 @@ builder.addCommandPipelineFilter(FilterParityOrdinaryCommandFilter) builder.add(FilterParityCommand, FilterParityCommandValidator, FilterParityAuthorizationFilter, FilterParityQueryAuthorizationFilter, ModelBoundCommand, ModelBoundCommandValidator, ModelBoundTitle, ModelBoundLookup, - OutcomeDto, OutcomePrimitive, OutcomeErrorCase, OutcomeTuple, + 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 index ce605b8e..33fd95a0 100644 --- a/ContractTests/Http/modelBound/MultiOutcomeCases.ts +++ b/ContractTests/Http/modelBound/MultiOutcomeCases.ts @@ -23,13 +23,17 @@ export class OutcomePrimitive { @command({ namespace: 'HttpFixture' }) @allowAnonymous() -export class OutcomeErrorCase { +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 { diff --git a/Source/Tools/ProxyGenerator/commandResponseType.ts b/Source/Tools/ProxyGenerator/commandResponseType.ts index 576c8c1e..35f1f294 100644 --- a/Source/Tools/ProxyGenerator/commandResponseType.ts +++ b/Source/Tools/ProxyGenerator/commandResponseType.ts @@ -4,48 +4,28 @@ import ts from 'typescript'; import { isPackageSymbol, 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,70 @@ 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 && !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 (symbol?.getName() === '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 [[]]; + } + if (candidate.isUnion()) 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'); + // Prefer the broad type when a literal and its primitive appear on separate paths. + const response = 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..5d7536fa 100644 --- a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts +++ b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts @@ -59,3 +59,32 @@ 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'; } +} 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..ff9b8748 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,12 @@ // 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'; 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 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..2823de57 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_equivalent_alternative_paths.ts @@ -0,0 +1,57 @@ +// 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; + beforeEach(() => { analysis = analyzeSource(resolve(root, 'tsconfig.json'), resolve(root, 'Features')); }); + + it('should retain one DTO for an alias and an unwrapped path', () => { + analysis.operations.find(item => item.name === 'AliasedResponse')!.result.text.should.equal('Plain'); + }); + it('should await a promise path before comparing decoders', () => { + analysis.operations.find(item => item.name === 'AwaitedAlternative')!.result.text.should.equal('Plain'); + }); + it('should distinguish tuple members from alternative paths', () => { + for (const name of ['TupleAlternatives', 'NestedTupleAlternative']) + analysis.operations.find(item => item.name === name)!.result.text.should.equal('Plain', name); + }); + it('should preserve array cardinality across paths', () => { + analysis.operations.find(item => item.name === 'ArrayAlternatives')!.result.text.should.equal('Plain[]'); + }); + it('should retain a primitive result across wrapped and unwrapped paths', () => { + analysis.operations.find(item => item.name === 'SamePrimitivePaths')!.result.text.should.equal('string'); + }); + it('should use the same selected decoder in generated runtime metadata', () => { + const metadata = renderGeneratedMetadata(resolve(root, 'tsconfig.json'), resolve(root, 'Features'), resolve(root, 'generatedMetadata.ts')); + metadata.should.match(/handleResult: \{ cardinality: 'many', 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_incompatible_alternatives.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_incompatible_alternatives.ts new file mode 100644 index 00000000..92338fe2 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_commandResponseType/when_selecting_a_client_response/with_incompatible_alternatives.ts @@ -0,0 +1,36 @@ +// 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 errorFor = (name: string): 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)!; + try { commandResponseType(checker.getReturnTypeOfSignature(checker.getSignatureFromDeclaration(method)!), checker, method); } + 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 unwrap a shape that only imitates an Outcome', () => { + 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 === 'FakeOutcome')! + .members.find(ts.isMethodDeclaration)!; + const selected = commandResponseType(checker.getReturnTypeOfSignature(checker.getSignatureFromDeclaration(method)!), checker, method)!; + checker.typeToString(selected).should.equal('{ kind: "response"; value: Created; }'); + }); +}); diff --git a/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts b/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts index 8ed8c91e..610518a2 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,13 @@ 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 response = handleReturn && describeCommandResponse(handleReturn, checker, handle!); + const responseType = response?.response; const responseShape = responseType ? metadataResult(responseType, checker, imports, handle!) : "{ 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: {` + From 61f81466034f126df936d65ff5ba4e993b1b9b81 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 15:09:55 +0200 Subject: [PATCH 3/7] docs: explain bounded command outcome alternatives --- Documentation/commands/command-outcomes.md | 31 +++++++++++++++++++++- Documentation/reference/capabilities.md | 3 ++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Documentation/commands/command-outcomes.md b/Documentation/commands/command-outcomes.md index 3891d061..f1e6b00c 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. 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. 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/reference/capabilities.md b/Documentation/reference/capabilities.md index eaaa77e5..99a2bbee 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`. From e191d2209bc26a2cabf134b27d26d075b273cebe Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 15:27:24 +0200 Subject: [PATCH 4/7] Preserve shared command response decoders and handled event arrays --- .../ProxyGenerator/commandResponseType.ts | 22 ++++++-- .../given/Features/Responses.ts | 17 ++++++- .../given/Features/given/Invalid.ts | 2 + .../with_equivalent_alternative_paths.ts | 51 +++++++++++++++---- .../with_handled_values.ts | 3 +- .../with_incompatible_alternatives.ts | 19 +++---- Source/Tools/ProxyGenerator/metadataResult.ts | 10 ++-- 7 files changed, 95 insertions(+), 29 deletions(-) diff --git a/Source/Tools/ProxyGenerator/commandResponseType.ts b/Source/Tools/ProxyGenerator/commandResponseType.ts index 35f1f294..d6edd00f 100644 --- a/Source/Tools/ProxyGenerator/commandResponseType.ts +++ b/Source/Tools/ProxyGenerator/commandResponseType.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 ts from 'typescript'; -import { isPackageSymbol, isTypeFrom } from './sourceSymbols.js'; +import { isPackageSymbol, isStandardType, isTypeFrom } from './sourceSymbols.js'; import { isOutcomeType } from './isOutcomeType.js'; /** A path is one possible execution; members of a path are returned together, not alternatives. */ @@ -33,7 +33,7 @@ export function describeCommandResponse(type: ts.Type, checker: ts.TypeChecker, 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 && !visible(element)) return false; + if (element && (element.isUnion() ? element.types.every(part => !visible(part)) : !visible(element))) return false; } return true; }; @@ -48,7 +48,7 @@ export function describeCommandResponse(type: ts.Type, checker: ts.TypeChecker, if (candidate.flags & ts.TypeFlags.NumberLike) return 'Number'; if (candidate.flags & ts.TypeFlags.BooleanLike) return 'Boolean'; const symbol = candidate.aliasSymbol ?? candidate.getSymbol(); - if (symbol?.getName() === 'Date') return 'Date'; + 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]; @@ -69,7 +69,13 @@ export function describeCommandResponse(type: ts.Type, checker: ts.TypeChecker, } if (kindType?.isStringLiteral() && (kindType.value === 'validation' || kindType.value === 'denied')) return [[]]; } - if (candidate.isUnion()) return candidate.types.flatMap(paths); + // A literal union has one decoder (the primitive or enum), not one alternative per literal. + if (candidate.isUnion()) { + if (candidate.types.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)) : [[]]; @@ -90,8 +96,14 @@ export function describeCommandResponse(type: ts.Type, checker: ts.TypeChecker, 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 = responses.find(part => part === first && !(part.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral))) ?? + 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 }; } diff --git a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts index 5d7536fa..0176542b 100644 --- a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts +++ b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts @@ -1,6 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { field } from '@cratis/fundamentals'; +import { ConceptAs, field } from '@cratis/fundamentals'; import { command, CommandOperation, CommandOperations, denied, rejected, response, tuple, validation } from '@cratis/arc.core'; import type { ArcTuple, Outcome } from '@cratis/arc.core'; import { eventType as chronicleEvent } from '@cratis/chronicle/events'; @@ -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() }); } } @@ -88,3 +93,13 @@ type AliasedOutcome = Outcome; @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 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'); } } +export class Date { @field(String) value = ''; } +@command() export class NamedDate { handle(): Date { return new Date(); } } 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 ff9b8748..b819f721 100644 --- a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/given/Invalid.ts +++ b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/given/Invalid.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. 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()]; } } @@ -9,4 +10,5 @@ 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 index 2823de57..91d66784 100644 --- 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 @@ -11,27 +11,60 @@ const root = resolve(import.meta.dirname, '../given'); describe('when analyzing alternative command paths', () => { let analysis: ReturnType; - beforeEach(() => { analysis = analyzeSource(resolve(root, 'tsconfig.json'), resolve(root, 'Features')); }); + 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) => { + 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: false, 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', () => { - analysis.operations.find(item => item.name === 'AliasedResponse')!.result.text.should.equal('Plain'); + verify('AliasedResponse', 'Plain', /handleResult: \{ cardinality: 'one', nullable: false, element: _arc\d+ \}/); }); it('should await a promise path before comparing decoders', () => { - analysis.operations.find(item => item.name === 'AwaitedAlternative')!.result.text.should.equal('Plain'); + 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']) - analysis.operations.find(item => item.name === name)!.result.text.should.equal('Plain', name); + verify(name, 'Plain', /handleResult: \{ cardinality: 'one', nullable: false, element: _arc\d+ \}/); }); it('should preserve array cardinality across paths', () => { - analysis.operations.find(item => item.name === 'ArrayAlternatives')!.result.text.should.equal('Plain[]'); + verify('ArrayAlternatives', 'Plain[]', /handleResult: \{ cardinality: 'many', nullable: false, element: _arc\d+ \}/); }); it('should retain a primitive result across wrapped and unwrapped paths', () => { - analysis.operations.find(item => item.name === 'SamePrimitivePaths')!.result.text.should.equal('string'); + 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 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 same selected decoder in generated runtime metadata', () => { - const metadata = renderGeneratedMetadata(resolve(root, 'tsconfig.json'), resolve(root, 'Features'), resolve(root, 'generatedMetadata.ts')); - metadata.should.match(/handleResult: \{ cardinality: 'many', nullable: false, 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+ \}/); }); }); 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 index 92338fe2..0b033a6a 100644 --- 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 @@ -8,13 +8,16 @@ import { sourceProgram } from '../../sourceProgram.js'; const root = resolve(import.meta.dirname, '../given'); describe('when comparing incompatible client alternatives', () => { - const errorFor = (name: string): string => { + 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)!; - try { commandResponseType(checker.getReturnTypeOfSignature(checker.getSignatureFromDeclaration(method)!), checker, method); } + 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 ''; }; @@ -24,13 +27,11 @@ describe('when comparing incompatible client alternatives', () => { 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 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 === 'FakeOutcome')! - .members.find(ts.isMethodDeclaration)!; - const selected = commandResponseType(checker.getReturnTypeOfSignature(checker.getSignatureFromDeclaration(method)!), checker, method)!; - checker.typeToString(selected).should.equal('{ kind: "response"; value: Created; }'); + const { selected, checker } = responseFor('FakeOutcome'); + checker.typeToString(selected!).should.equal('{ kind: "response"; value: Created; }'); }); }); diff --git a/Source/Tools/ProxyGenerator/metadataResult.ts b/Source/Tools/ProxyGenerator/metadataResult.ts index d6ff09b3..213c59e5 100644 --- a/Source/Tools/ProxyGenerator/metadataResult.ts +++ b/Source/Tools/ProxyGenerator/metadataResult.ts @@ -2,6 +2,7 @@ // 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 } 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, @@ -15,10 +16,11 @@ export function metadataResult(type: ts.Type, checker: ts.TypeChecker, imports: 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]; + if (members.every(part => !!(part.flags & ts.TypeFlags.StringLike))) token = 'String'; + else if (members.every(part => !!(part.flags & ts.TypeFlags.NumberLike))) token = 'Number'; + else if (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}`} }`; From e8ca51a881a3abafa19b0e59c17f676a29d3613c Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 15:27:31 +0200 Subject: [PATCH 5/7] Clarify Result response status and conformance case names --- ContractTests/Http/conformance.test.mjs | 8 ++++---- Documentation/commands/command-outcomes.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ContractTests/Http/conformance.test.mjs b/ContractTests/Http/conformance.test.mjs index 067422dc..fbbe3d70 100644 --- a/ContractTests/Http/conformance.test.mjs +++ b/ContractTests/Http/conformance.test.mjs @@ -372,11 +372,11 @@ test('published .NET and built TypeScript HTTP contract', async t => { } finally { if (adapter !== 'express') await host.stop(); } } for (const [name, path, expected] of [ - ['DTO response', 'outcome-dto', { response: { value: 'created' } }], - ['primitive response', 'outcome-primitive', { response: 42 }], + ['OneOf DTO response', 'outcome-dto', { response: { value: 'created' } }], + ['OneOf primitive response', 'outcome-primitive', { response: 42 }], ['Result success DTO', 'outcome-error-case', { response: { value: 'created' } }], - ['tuple alternative response', 'outcome-tuple', { response: { value: 'created' } }] - ]) await parity(`OneOf ${name}`, 'POST', `/api/${path}`, { fail: false }, { status: 200, body: command(200, expected) }); + ['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' }] }) }); diff --git a/Documentation/commands/command-outcomes.md b/Documentation/commands/command-outcomes.md index f1e6b00c..3232c05c 100644 --- a/Documentation/commands/command-outcomes.md +++ b/Documentation/commands/command-outcomes.md @@ -56,7 +56,7 @@ This illustration uses a fixture-like condition to select the status; replace it 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. 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. Do not rely on a client-visible union unless the client and generator both support its discriminant. +The proxy generator accepts alternative paths through aliases, promises, and outcomes only if each has the same client-visible representation. 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 From 5eaa71ca7c714ea3df69ce96b9e860c0e0cd7e82 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 15:39:17 +0200 Subject: [PATCH 6/7] Preserve nullable literal responses and primitive result metadata --- Documentation/commands/command-outcomes.md | 2 +- .../ProxyGenerator/SourceTypeResolver.ts | 6 ++-- .../ProxyGenerator/commandResponseType.ts | 4 ++- .../given/Features/Responses.ts | 10 ++++++- .../with_equivalent_alternative_paths.ts | 22 +++++++++++++-- .../with_boolean_return.ts | 17 +++++++++++ Source/Tools/ProxyGenerator/metadataResult.ts | 28 +++++++++++++------ .../ProxyGenerator/renderArtifactMetadata.ts | 3 +- 8 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 Source/Tools/ProxyGenerator/for_renderGeneratedMetadata/when_rendering_query_results/with_boolean_return.ts diff --git a/Documentation/commands/command-outcomes.md b/Documentation/commands/command-outcomes.md index 3232c05c..d8f6f5be 100644 --- a/Documentation/commands/command-outcomes.md +++ b/Documentation/commands/command-outcomes.md @@ -56,7 +56,7 @@ This illustration uses a fixture-like condition to select the status; replace it 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. 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. +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 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 d6edd00f..c70ffe1f 100644 --- a/Source/Tools/ProxyGenerator/commandResponseType.ts +++ b/Source/Tools/ProxyGenerator/commandResponseType.ts @@ -71,7 +71,9 @@ export function describeCommandResponse(type: ts.Type, checker: ts.TypeChecker, } // A literal union has one decoder (the primitive or enum), not one alternative per literal. if (candidate.isUnion()) { - if (candidate.types.every(part => !!(part.flags & + 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); diff --git a/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts b/Source/Tools/ProxyGenerator/for_commandResponseType/given/Features/Responses.ts index 0176542b..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 { ConceptAs, field } from '@cratis/fundamentals'; -import { command, CommandOperation, CommandOperations, denied, rejected, response, tuple, validation } from '@cratis/arc.core'; +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 { @@ -97,9 +97,17 @@ 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/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 index 91d66784..22575939 100644 --- 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 @@ -16,10 +16,10 @@ describe('when analyzing alternative command paths', () => { 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) => { + 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: false, element: ${shape} }`); + 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]; @@ -57,12 +57,30 @@ describe('when analyzing alternative command paths', () => { 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+ \}/); }); 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 213c59e5..1d8ab43d 100644 --- a/Source/Tools/ProxyGenerator/metadataResult.ts +++ b/Source/Tools/ProxyGenerator/metadataResult.ts @@ -2,24 +2,36 @@ // 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 } from './sourceSymbols.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} }`; - const members = element.isUnion() ? element.types : [element]; - if (members.every(part => !!(part.flags & ts.TypeFlags.StringLike))) token = 'String'; - else if (members.every(part => !!(part.flags & ts.TypeFlags.NumberLike))) token = 'Number'; - else if (members.every(part => !!(part.flags & ts.TypeFlags.BooleanLike))) token = 'Boolean'; + 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}` : ''}` + diff --git a/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts b/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts index 610518a2..e5a8d70b 100644 --- a/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts +++ b/Source/Tools/ProxyGenerator/renderArtifactMetadata.ts @@ -102,7 +102,8 @@ export function renderArtifactMetadata(declaration: ts.ClassDeclaration, checker checker.getReturnTypeOfSignature(handleSignature)); const response = handleReturn && describeCommandResponse(handleReturn, checker, handle!); const responseType = response?.response; - const responseShape = responseType ? metadataResult(responseType, checker, imports, handle!) : + 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)) From 4265cc1164442b360c7b1f4f3112f716f69eb6ed Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 15:41:15 +0200 Subject: [PATCH 7/7] Prepare the v0.39.0 source preview --- ContractTests/Client/package.json | 2 +- Documentation/index.md | 2 +- Documentation/reference/packages.md | 2 +- README.md | 2 +- Source/Chronicle/package.json | 6 +++--- Source/CodeAnalysis/package.json | 2 +- Source/Core/package.json | 2 +- Source/Cratis/package.json | 2 +- Source/Drizzle/package.json | 4 ++-- Source/Express/package.json | 2 +- Source/Fastify/package.json | 2 +- Source/Hono/package.json | 2 +- Source/MongoDB/package.json | 4 ++-- Source/Testing/package.json | 2 +- Source/Tools/ProxyGenerator/package.json | 2 +- yarn.lock | 8 ++++---- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index 4fb8dd28..697a5329 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.35.0", + "version": "0.39.0", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/index.md b/Documentation/index.md index ebf8bf28..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.35.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/packages.md b/Documentation/reference/packages.md index 7c723db5..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.35.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 7abc44c8..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.35.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 2583e308..8ea669eb 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.35.0", + "version": "0.39.0", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.35.0", - "@cratis/arc.testing": "^0.35.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 69202f3e..181db466 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.35.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 33427f88..08cc02fb 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.35.0", + "version": "0.39.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index c74a491d..57bdfe7b 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.35.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 73fd6945..99e30d02 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.35.0", + "version": "0.39.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.35.0", + "@cratis/arc.core": "^0.39.0", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0" }, diff --git a/Source/Express/package.json b/Source/Express/package.json index b7f26a1b..5e11c9d4 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.35.0", + "version": "0.39.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index 3552b843..69beefb6 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.35.0", + "version": "0.39.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/package.json b/Source/Hono/package.json index f88dd216..c7d63bae 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.35.0", + "version": "0.39.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index 29d79cf2..c5719d27 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.35.0", + "version": "0.39.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.35.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 9d368138..ded88e95 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.35.0", + "version": "0.39.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index 11f818f4..41c50fc5 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.35.0", + "version": "0.39.0", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index 7e68c056..aad2b54d 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.35.0 - "@cratis/arc.testing": ^0.35.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 @@ -150,7 +150,7 @@ __metadata: postgres: "npm:^3.4.9" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.35.0 + "@cratis/arc.core": ^0.39.0 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 languageName: unknown @@ -215,7 +215,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.35.0 + "@cratis/arc.core": ^0.39.0 "@cratis/fundamentals": ^7.19.6 "@opentelemetry/api": ^1.9.0 mongodb: ^6.21.0