feat(typert): carry Remote absence without a second result envelope
Absence crosses the wire as a missing field: an omitted argument and a void or undefined result both arrive as an absent JSON member, and the wide RPC result slot accepts a success response without a value. Parameters declared optional stay optional in the generated consumer declaration, so a business signature is never widened to `T | undefined` to suit the wire. The weak SRC descriptor reads parameter names from a JavaScript signature and cannot see optionality, so a source-launched Host accepts an absent field and the strict LIB pass owns rejecting a genuinely missing required parameter.
This commit is contained in:
@@ -70,6 +70,18 @@ export class TypertGatewayError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Business invocation lost its carrier cancellation race. */
|
||||
class RemoteInvocationCancelled extends Error {
|
||||
/**
|
||||
* @param endpoint - canonical Remote endpoint.
|
||||
* @param cause - business rejection observed after carrier cancellation.
|
||||
*/
|
||||
constructor(endpoint: string, cause: unknown) {
|
||||
super(`Remote invocation "${endpoint}" was aborted`, { cause })
|
||||
this.name = 'RemoteInvocationCancelled'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve strict generated definitions or conservative SRC markers against
|
||||
* current Cordis Services and TypeRT providers.
|
||||
@@ -157,7 +169,13 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
)
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(method, receiver, args) as unknown
|
||||
let result: unknown
|
||||
try {
|
||||
result = await Reflect.apply(method, receiver, args) as unknown
|
||||
} catch (error) {
|
||||
if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)
|
||||
throw error
|
||||
}
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
@@ -190,6 +208,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
// A void or explicitly absent business result carries no `value` field;
|
||||
// JSON has no `undefined`, and the envelope's optional slot is the one
|
||||
// representation of absence that both args and results already use.
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
@@ -439,6 +460,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof RemoteInvocationCancelled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: error.message, details: {} },
|
||||
}
|
||||
}
|
||||
if (error instanceof TypeRTLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
@@ -559,7 +586,15 @@ function assertExactArguments(
|
||||
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
|
||||
const actual = Reflect.ownKeys(args)
|
||||
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
|
||||
// A JSON field may be omitted when the strict descriptor declares absence,
|
||||
// and always under SRC: a weak descriptor reads parameter names from the
|
||||
// JavaScript signature and cannot see which are optional, so LIB is where an
|
||||
// omitted required argument is caught. Lookup ids are never omissible.
|
||||
const acceptsMissing = new Set(descriptor.parameters
|
||||
.filter(parameter => parameter.source === 'json'
|
||||
&& (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json'))
|
||||
.map(parameter => parameter.wire))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key))
|
||||
if (extra.length === 0 && missing.length === 0) return
|
||||
const clauses: string[] = []
|
||||
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
|
||||
@@ -575,7 +610,10 @@ function decode(
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') value = codec.schema.parse(value)
|
||||
if (codec.mode === 'strict') {
|
||||
value = codec.schema.parse(value)
|
||||
if (value === undefined) return value
|
||||
}
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
|
||||
@@ -77,6 +77,12 @@ class GoalService extends Service {
|
||||
return this.nextResult === undefined ? value : this.nextResult
|
||||
}
|
||||
|
||||
@Remote
|
||||
maybe(value: string | null | undefined): string | null | undefined {
|
||||
this.calls.push('maybe')
|
||||
return value
|
||||
}
|
||||
|
||||
@Remote
|
||||
fail(request: unknown): never {
|
||||
void request
|
||||
@@ -945,7 +951,7 @@ describe('TypertGatewayService', () => {
|
||||
expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' })
|
||||
|
||||
registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
registerStrict(ctx, [createDescriptor(), maybeDescriptor()])
|
||||
expect(connection.matches?.('goals/create')).toBe(true)
|
||||
expect(connection.matches?.('goals/passthrough')).toBe(true)
|
||||
expect(connection.matches?.('goals')).toBe(false)
|
||||
@@ -973,6 +979,15 @@ describe('TypertGatewayService', () => {
|
||||
if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
|
||||
expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
|
||||
|
||||
await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
})
|
||||
await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: null,
|
||||
})
|
||||
|
||||
for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) {
|
||||
const result = await handler(endpoint, { args: {} }, signal)
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
@@ -987,7 +1002,11 @@ describe('TypertGatewayService', () => {
|
||||
}
|
||||
|
||||
service.businessError = 'non-error failure' as unknown as Error
|
||||
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
|
||||
await expect(handler(
|
||||
'goals/fail',
|
||||
{ args: { request: null } },
|
||||
new AbortController().signal,
|
||||
)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'non-error failure', details: {} },
|
||||
})
|
||||
@@ -1302,6 +1321,28 @@ function strictOnlyDescriptor(): InvocationDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
function maybeDescriptor(): InvocationDescriptor {
|
||||
const value = strictCodec(
|
||||
'@fixture/gateway#MaybeValue',
|
||||
z.union([z.string(), z.null(), z.undefined()]),
|
||||
)
|
||||
return {
|
||||
id: '@fixture/gateway#goals/maybe',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'maybe',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: 'value',
|
||||
source: 'json',
|
||||
acceptsUndefined: true,
|
||||
codec: value,
|
||||
}],
|
||||
result: value,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCode(
|
||||
promise: Promise<unknown>,
|
||||
code: TypertGatewayError['code'],
|
||||
|
||||
Reference in New Issue
Block a user