Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Adapt to two contract changes master introduced: - The generated Remote face now wraps every business result in RemoteResult, folding carrier failures into an ok:false branch instead of rejecting. The controller reads that envelope at its three call sites and maps a carrier failure onto the same settled shape the controls already render; three specs cover the new branch. - Client packages split their tsconfig into host and client halves, and the host aggregate now compiles any test not named *.client.spec.*. Rename this package's specs to the client convention and drop the ../connection project reference, which pointed at a solution file that no longer carries the client sources. Keep master's mount loop with its rollback-on-failure in api-remotes and add messageFeedbackRemote to it.
This commit is contained in:
@@ -983,8 +983,8 @@ class FaceAnalyzer {
|
||||
}
|
||||
if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters')
|
||||
if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values')
|
||||
if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional')
|
||||
if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter')
|
||||
const optional = parameter.questionToken !== undefined
|
||||
const authoredType = this.requiredType(parameter, parameter.type, 'parameter')
|
||||
const cancellationName = parameter.name.text === 'signal'
|
||||
const cancellationType = this.isGlobalAbortSignal(authoredType)
|
||||
@@ -1002,6 +1002,7 @@ class FaceAnalyzer {
|
||||
const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol))
|
||||
let modeled: InvocationParameterModel
|
||||
if (lookup !== undefined) {
|
||||
if (optional) this.fail(parameter, `lookup parameter for ${lookup.key} cannot be optional`)
|
||||
if (parameter.name.text !== lookup.key) {
|
||||
this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`)
|
||||
}
|
||||
@@ -1025,10 +1026,13 @@ class FaceAnalyzer {
|
||||
name: parameter.name.text,
|
||||
wire: parameter.name.text,
|
||||
source: 'json',
|
||||
...optional ? { optional: true as const } : {},
|
||||
boundary: this.remoteBoundary(
|
||||
authoredType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`,
|
||||
false,
|
||||
'undefined',
|
||||
optional,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1095,6 +1099,7 @@ class FaceAnalyzer {
|
||||
resultType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:result`,
|
||||
false,
|
||||
'undefined-or-void',
|
||||
),
|
||||
location: this.location(method.name),
|
||||
}
|
||||
@@ -1336,9 +1341,18 @@ class FaceAnalyzer {
|
||||
authoredType: ts.TypeNode,
|
||||
fallbackTypeSymbol: string,
|
||||
requireNamed: boolean,
|
||||
topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void' = 'reject',
|
||||
optional = false,
|
||||
): RemoteBoundaryModel {
|
||||
const type = this.convertType(authoredType)
|
||||
const codecType = this.resolvedRemoteCodecType(authoredType)
|
||||
const declaredType = this.checker.getTypeFromTypeNode(authoredType)
|
||||
// An optional parameter's authored node carries no `undefined`; the codec
|
||||
// still has to accept the omitted wire field the consumer sends.
|
||||
const resolvedType = optional
|
||||
? this.checker.getNullableType(declaredType, ts.TypeFlags.Undefined)
|
||||
: declaredType
|
||||
const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence)
|
||||
const acceptsUndefined = topLevelAbsence !== 'reject' && this.includesRemoteAbsence(resolvedType)
|
||||
const rootSymbol = this.namedWorkspaceType(authoredType)
|
||||
const imports = new Map<SymbolId, RemoteTypeImportModel>()
|
||||
const visit = (node: ts.Node): void => {
|
||||
@@ -1365,6 +1379,7 @@ class FaceAnalyzer {
|
||||
return {
|
||||
type,
|
||||
codecType,
|
||||
acceptsUndefined,
|
||||
typeSymbol: `${imported.specifier}#${imported.name}`,
|
||||
imports: [...imports.values()].sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)),
|
||||
@@ -1374,6 +1389,7 @@ class FaceAnalyzer {
|
||||
return {
|
||||
type,
|
||||
codecType,
|
||||
acceptsUndefined,
|
||||
typeSymbol: fallbackTypeSymbol,
|
||||
imports: [...imports.values()].sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)),
|
||||
@@ -1387,9 +1403,18 @@ class FaceAnalyzer {
|
||||
* validated without teaching the compiler-independent emitter TypeScript's
|
||||
* type evaluator.
|
||||
*/
|
||||
private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId {
|
||||
const resolvedType = this.checker.getTypeFromTypeNode(authoredType)
|
||||
this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false)
|
||||
private resolvedRemoteCodecType(
|
||||
authoredType: ts.TypeNode,
|
||||
resolvedType: ts.Type,
|
||||
topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void',
|
||||
): TypeNodeId {
|
||||
this.assertRemoteJsonType(
|
||||
resolvedType,
|
||||
authoredType,
|
||||
new Set(),
|
||||
topLevelAbsence !== 'reject',
|
||||
topLevelAbsence === 'undefined-or-void',
|
||||
)
|
||||
const completed = new Map<ts.Type, TypeNodeId>()
|
||||
const active = new Map<ts.Type, TypeNodeId>()
|
||||
const recursiveDeclarations = new Map<ts.Type, SymbolId>()
|
||||
@@ -1554,9 +1579,11 @@ class FaceAnalyzer {
|
||||
site: ts.TypeNode,
|
||||
active: Set<ts.Type>,
|
||||
allowUndefined: boolean,
|
||||
allowVoid: boolean,
|
||||
): void {
|
||||
const flags = type.flags
|
||||
if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return
|
||||
if ((flags & ts.TypeFlags.Void) !== 0 && allowVoid) return
|
||||
if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) {
|
||||
this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`)
|
||||
}
|
||||
@@ -1569,13 +1596,15 @@ class FaceAnalyzer {
|
||||
| ts.TypeFlags.Null
|
||||
| ts.TypeFlags.Never)) !== 0) return
|
||||
if (type.isUnion()) {
|
||||
for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined)
|
||||
for (const member of type.types) {
|
||||
this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (type.isIntersection()) {
|
||||
const material = type.types.filter(member => !this.isRemotePhantomConstraint(member))
|
||||
if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object')
|
||||
for (const member of material) this.assertRemoteJsonType(member, site, active, false)
|
||||
for (const member of material) this.assertRemoteJsonType(member, site, active, false, false)
|
||||
return
|
||||
}
|
||||
if ((flags & ts.TypeFlags.TypeParameter) !== 0) {
|
||||
@@ -1606,6 +1635,7 @@ class FaceAnalyzer {
|
||||
site,
|
||||
active,
|
||||
(elementFlags & ts.ElementFlags.Optional) !== 0,
|
||||
false,
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -1613,7 +1643,7 @@ class FaceAnalyzer {
|
||||
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
|
||||
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
||||
if (element === undefined) this.fail(site, 'Remote boundary array has no element type')
|
||||
this.assertRemoteJsonType(element, site, active, false)
|
||||
this.assertRemoteJsonType(element, site, active, false, false)
|
||||
return
|
||||
}
|
||||
const properties = this.checker.getPropertiesOfType(type)
|
||||
@@ -1628,19 +1658,25 @@ class FaceAnalyzer {
|
||||
site,
|
||||
active,
|
||||
(property.flags & ts.SymbolFlags.Optional) !== 0,
|
||||
false,
|
||||
)
|
||||
}
|
||||
for (const info of this.checker.getIndexInfosOfType(type)) {
|
||||
if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) {
|
||||
this.fail(site, 'Remote boundary contains a symbol index signature')
|
||||
}
|
||||
this.assertRemoteJsonType(info.type, site, active, false)
|
||||
this.assertRemoteJsonType(info.type, site, active, false, false)
|
||||
}
|
||||
} finally {
|
||||
active.delete(type)
|
||||
}
|
||||
}
|
||||
|
||||
private includesRemoteAbsence(type: ts.Type): boolean {
|
||||
if ((type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) return true
|
||||
return type.isUnion() && type.types.some(member => this.includesRemoteAbsence(member))
|
||||
}
|
||||
|
||||
private isRemotePhantomConstraint(type: ts.Type): boolean {
|
||||
if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true
|
||||
if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false
|
||||
|
||||
@@ -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}>>`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface RemoteBoundaryModel {
|
||||
readonly type: TypeNodeId
|
||||
/** Checker-resolved projection used only to emit the runtime codec. */
|
||||
readonly codecType: TypeNodeId
|
||||
/** Whether the authored top-level boundary explicitly accepts `undefined`. */
|
||||
readonly acceptsUndefined: boolean
|
||||
readonly typeSymbol: string
|
||||
readonly imports: readonly RemoteTypeImportModel[]
|
||||
}
|
||||
@@ -117,6 +119,8 @@ export interface InvocationParameterModel {
|
||||
readonly wire: string
|
||||
readonly source: 'json' | 'lookup'
|
||||
readonly lookup?: string
|
||||
/** Authored as an optional parameter, so consumers may omit the wire field. */
|
||||
readonly optional?: true
|
||||
readonly boundary: RemoteBoundaryModel
|
||||
}
|
||||
|
||||
|
||||
@@ -98,10 +98,12 @@ export class WorkspaceTypertGenerator {
|
||||
const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object'
|
||||
? (manifest.exports as Record<string, unknown>)['./remote']
|
||||
: undefined
|
||||
// The declaration map is emitted beside these two but never published: it
|
||||
// serves editor navigation in the workspace, where the package link
|
||||
// resolves its source.
|
||||
const remoteFiles = [
|
||||
'lib/typert.remote-client.js',
|
||||
'lib/typert.remote-client.d.ts',
|
||||
'lib/typert.remote-client.d.ts.map',
|
||||
]
|
||||
if (artifact.remote === undefined) {
|
||||
if (remoteActual !== undefined || remoteFiles.some(file => files.includes(file))) {
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts",
|
||||
"lib/typert.remote-client.d.ts.map"
|
||||
"lib/typert.remote-client.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ declare module '@deepseek-ai/dsh-type-meta' {
|
||||
export interface TypeRTRemoteMap {}
|
||||
export interface TypeRTRemoteScopeMap {}
|
||||
|
||||
export interface RemoteFailure {
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
readonly details: object
|
||||
}
|
||||
|
||||
export type RemoteResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: RemoteFailure }
|
||||
|
||||
export type TypeRTRemoteNamespace<Namespace extends string> = {
|
||||
[Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
|
||||
? Method
|
||||
|
||||
@@ -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 }
|
||||
@@ -113,15 +114,15 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
|
||||
expect(artifact?.js).toContain('invocations: [')
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise<CreateGoalResult>",
|
||||
"'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise<RemoteResult<CreateGoalResult>>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:')
|
||||
expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73")
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise<CreateGoalResult>",
|
||||
"'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise<RemoteResult<CreateGoalResult>>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RenameGoalResult>",
|
||||
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RemoteResult<RenameGoalResult>>",
|
||||
)
|
||||
|
||||
const remoteJs = artifact?.remote?.js
|
||||
@@ -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<RemoteResult<string | undefined>>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise<RemoteResult<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<RemoteResult<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}
|
||||
@@ -204,7 +256,7 @@ export type GenericResult = {
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/dispatch': (request: GenericRequest) => Promise<GenericResult>",
|
||||
"'goals/dispatch': (request: GenericRequest) => Promise<RemoteResult<GenericResult>>",
|
||||
)
|
||||
const remoteJs = artifact?.remote?.js
|
||||
if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript')
|
||||
@@ -254,7 +306,7 @@ export interface BoxPayload {
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/)
|
||||
expect(artifact?.remote?.dts).toContain('box: (request: Box<BoxPayload>) => Promise<Box<BoxPayload>>')
|
||||
expect(artifact?.remote?.dts).toContain('box: (request: Box<BoxPayload>) => Promise<RemoteResult<Box<BoxPayload>>>')
|
||||
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root)
|
||||
})
|
||||
|
||||
@@ -274,7 +326,7 @@ export interface BoxPayload {
|
||||
))
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise<CreateGoalResult>")
|
||||
expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise<RemoteResult<CreateGoalResult>>")
|
||||
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
@@ -584,6 +636,7 @@ function assertRemoteConsumerTypechecks(
|
||||
const consumerSource = `
|
||||
import remote from '@fixture/remote/remote'
|
||||
import type {
|
||||
RemoteResult,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteScopeMap,
|
||||
TypeRTRemoteMap,
|
||||
@@ -595,12 +648,12 @@ const contribution: TypeRTRemoteContribution = remote
|
||||
declare const create: TypeRTRemoteMap['goals/create']
|
||||
declare const createScoped: TypeRTRemoteScopeMap['agent:goals/create']
|
||||
declare const rename: TypeRTRemoteScopeMap['agent:goals/rename']
|
||||
const created: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' })
|
||||
const cancellable: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' }, new AbortController().signal)
|
||||
const createdScoped: Promise<CreateGoalResult> = createScoped({ title: 'ship' })
|
||||
const renamed: Promise<RenameGoalResult> = rename({ ref: 'goal-1', title: 'land' })
|
||||
const created: Promise<RemoteResult<CreateGoalResult>> = create('agent-1', { title: 'ship' })
|
||||
const cancellable: Promise<RemoteResult<CreateGoalResult>> = create('agent-1', { title: 'ship' }, new AbortController().signal)
|
||||
const createdScoped: Promise<RemoteResult<CreateGoalResult>> = createScoped({ title: 'ship' })
|
||||
const renamed: Promise<RemoteResult<RenameGoalResult>> = rename({ ref: 'goal-1', title: 'land' })
|
||||
declare const ctx: { remote: TypeRTRemoteNamespaceMap }
|
||||
const navigated: Promise<CreateGoalResult> = ctx.remote.goals.create('agent-1', { title: 'navigate' })
|
||||
const navigated: Promise<RemoteResult<CreateGoalResult>> = ctx.remote.goals.create('agent-1', { title: 'navigate' })
|
||||
void contribution
|
||||
void created
|
||||
void cancellable
|
||||
|
||||
@@ -653,6 +653,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void {
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
if (parameter.source === 'lookup') {
|
||||
if (parameter.acceptsUndefined !== undefined) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" cannot accept undefined`)
|
||||
}
|
||||
if (parameter.lookup === undefined) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`)
|
||||
}
|
||||
|
||||
@@ -508,6 +508,17 @@ describe('TypertRegistry', () => {
|
||||
...invocation(),
|
||||
parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }],
|
||||
}, 'has no lookup key'],
|
||||
[{
|
||||
...invocation(),
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
acceptsUndefined: true,
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
}, 'cannot accept undefined'],
|
||||
[{
|
||||
...invocation(),
|
||||
parameters: [{
|
||||
|
||||
@@ -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