feat(typert): deliver the carrier outcome from ctx.remote
Every generated Remote method now resolves to `RemoteResult<T>`: the Client face folds a carrier failure, a transport throw and a rejected result payload into one error branch, so no consumer wraps a call to recover them. Only assembly faults still reject — a wrong argument count, an unmounted method, a missing Context binder, an absent Connection. `RemoteFailure.code` stays an open string because the closed RPC code union lives in the carrier package, which already depends on type-meta; naming it here would invert that edge. The goal surface drops its own try/catch plus the structural probe that guessed whether a thrown cause was an RPC failure, and reads the result instead.
This commit is contained in:
@@ -308,6 +308,7 @@ export class FaceModelEmitter {
|
||||
lines.push(` wire: ${quote(parameter.wire)},`)
|
||||
lines.push(` source: ${quote(parameter.source)},`)
|
||||
if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`)
|
||||
if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,')
|
||||
lines.push(` codec: ${indent(strictCodec(
|
||||
parameter.boundary,
|
||||
schemas.boundary(parameterBoundaryKey(invocation, index)),
|
||||
@@ -342,6 +343,7 @@ export class FaceModelEmitter {
|
||||
const lines = [
|
||||
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
|
||||
'import type {',
|
||||
' RemoteResult,',
|
||||
' TypeRTRemoteContribution,',
|
||||
'} from \'@deepseek-ai/dsh-type-meta\'',
|
||||
]
|
||||
@@ -462,10 +464,13 @@ export class FaceModelEmitter {
|
||||
): string {
|
||||
const parameters = invocation.parameters.filter(parameter =>
|
||||
!scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter =>
|
||||
`${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
|
||||
`${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
|
||||
if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
|
||||
const result = this.renderer.renderType(invocation.result.type, referenceNames)
|
||||
return `(${parameters.join(', ')}) => Promise<${result}>`
|
||||
// The Client Remote face delivers the carrier's outcome, so every generated
|
||||
// consumer signature resolves to a result the caller reads instead of a
|
||||
// value it must guard with its own try/catch.
|
||||
return `(${parameters.join(', ')}) => Promise<RemoteResult<${result}>>`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ interface RuntimeDescriptor {
|
||||
readonly cancellation?: { readonly parameter: 'signal' }
|
||||
readonly parameters: readonly {
|
||||
readonly wire: string
|
||||
readonly acceptsUndefined?: true
|
||||
readonly codec: { readonly schema: RuntimeSchema }
|
||||
}[]
|
||||
readonly result: { readonly schema: RuntimeSchema }
|
||||
@@ -146,6 +147,57 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap)
|
||||
})
|
||||
|
||||
it('projects authored optionality and absence onto consumers and codecs', async () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source.replace(
|
||||
'\n}\n\nexport type {',
|
||||
`
|
||||
|
||||
@Remote
|
||||
maybe(value: string | undefined): string | undefined {
|
||||
return value
|
||||
}
|
||||
|
||||
@Remote
|
||||
labelled(id: string, label?: string): string {
|
||||
return label ?? id
|
||||
}
|
||||
|
||||
@Remote
|
||||
clear(): void {}
|
||||
}
|
||||
|
||||
export type {`,
|
||||
))
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/maybe': (value: string | undefined) => Promise<string | undefined>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise<void>")
|
||||
// An explicit `T | undefined` stays a required argument; only authored
|
||||
// optionality lets a consumer omit the field.
|
||||
expect(artifact?.remote?.dts).not.toContain('value?: string')
|
||||
expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise<string>")
|
||||
|
||||
const remoteJs = artifact?.remote?.js
|
||||
if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript')
|
||||
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
|
||||
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
|
||||
const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe'))
|
||||
const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear'))
|
||||
expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true)
|
||||
expect(maybe?.parameters[0]?.codec.schema.safeParse(undefined).success).toBe(true)
|
||||
expect(maybe?.result.schema.safeParse(undefined).success).toBe(true)
|
||||
expect(clear?.result.schema.safeParse(undefined).success).toBe(true)
|
||||
expect(clear?.result.schema.safeParse(null).success).toBe(false)
|
||||
const labelled = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/labelled'))
|
||||
expect(labelled?.parameters[0]?.acceptsUndefined).toBeUndefined()
|
||||
expect(labelled?.parameters[1]?.acceptsUndefined).toBe(true)
|
||||
expect(labelled?.parameters[1]?.codec.schema.safeParse(undefined).success).toBe(true)
|
||||
expect(labelled?.parameters[1]?.codec.schema.safeParse(7).success).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => `${source}
|
||||
@@ -436,9 +488,9 @@ export interface ClientMarker {
|
||||
message: 'Remote parameters cannot have default values',
|
||||
},
|
||||
{
|
||||
name: 'optional parameter',
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'),
|
||||
message: 'Remote parameters cannot be optional',
|
||||
name: 'optional lookup parameter',
|
||||
edit: (source: string) => source.replace('agent: Agent,', 'agent?: Agent,'),
|
||||
message: 'lookup parameter for agent cannot be optional',
|
||||
},
|
||||
{
|
||||
name: 'wrong cancellation type',
|
||||
|
||||
@@ -41,6 +41,8 @@ export type {
|
||||
InvocationDescriptor,
|
||||
InvocationParameterDescriptor,
|
||||
InvocationSourceLocation,
|
||||
RemoteFailure,
|
||||
RemoteResult,
|
||||
TypeRTClientRemote,
|
||||
TypeRTClientContextBinder,
|
||||
TypeRTCodec,
|
||||
|
||||
@@ -39,6 +39,28 @@ export interface TypeRTContextMap {}
|
||||
/** Merge-extensible direct Remote method signatures generated for consumers. */
|
||||
export interface TypeRTRemoteMap {}
|
||||
|
||||
/**
|
||||
* One Remote call's failure as the carrier reported it. `code` stays open here:
|
||||
* the closed RPC code union belongs to the carrier package, which already
|
||||
* depends on this one, so naming it would invert that edge.
|
||||
*/
|
||||
export interface RemoteFailure {
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
readonly details: object
|
||||
}
|
||||
|
||||
/**
|
||||
* What every generated Remote method resolves to. The Remote face itself folds
|
||||
* carrier failures into the error branch, so no consumer wraps a call to
|
||||
* recover one; only assembly faults (arity, an unmounted method, a missing
|
||||
* Context binder) still reject.
|
||||
* @template T - the Host method's business result.
|
||||
*/
|
||||
export type RemoteResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: RemoteFailure }
|
||||
|
||||
/** Merge-extensible scoped Remote method signatures generated for consumers. */
|
||||
export interface TypeRTRemoteScopeMap {}
|
||||
|
||||
@@ -136,6 +158,8 @@ export interface InvocationParameterDescriptor {
|
||||
readonly lookup?: string
|
||||
/** Boundary codec for the wire representation. */
|
||||
readonly codec: TypeRTCodec
|
||||
/** Missing wire fields decode to `undefined` only for an explicitly declared `T | undefined`. */
|
||||
readonly acceptsUndefined?: true
|
||||
}
|
||||
|
||||
/** Source position retained for diagnostics from generated definitions. */
|
||||
|
||||
Reference in New Issue
Block a user