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'],
|
||||
|
||||
@@ -61,7 +61,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
|
||||
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
|
||||
z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
@@ -91,6 +90,9 @@ export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.
|
||||
}
|
||||
|
||||
// ---- The four wire full-form schemas (payload/result.value slots stay wide — business layer does the second parse) ----
|
||||
// The wide value slot is optional: a void business result serializes with no
|
||||
// `value` field at all. Each endpoint's own second parse still requires its
|
||||
// declared value, so absence never passes for a method that returns data.
|
||||
|
||||
/** ClientRequest full form (payload stays wide — the business layer runs the second parse). */
|
||||
export const clientRequestSchema = z.object({
|
||||
@@ -104,7 +106,7 @@ export const clientRequestSchema = z.object({
|
||||
export const serverResponseSchema = z.object({
|
||||
type: z.literal('server-response'),
|
||||
rpcId: rpcIdSchema,
|
||||
result: rpcResultSchema(z.unknown()),
|
||||
result: rpcResultSchema(z.unknown().optional()),
|
||||
}) as unknown as z.ZodType<ServerResponse>
|
||||
|
||||
/** ServerRequest full form (payload stays wide). */
|
||||
@@ -119,7 +121,7 @@ export const serverRequestSchema = z.object({
|
||||
export const clientResponseSchema = z.object({
|
||||
type: z.literal('client-response'),
|
||||
rpcId: rpcIdSchema,
|
||||
result: rpcResultSchema(z.unknown()),
|
||||
result: rpcResultSchema(z.unknown().optional()),
|
||||
}) as unknown as z.ZodType<ClientResponse>
|
||||
|
||||
/** Wire full-form union (discriminated by type). */
|
||||
|
||||
@@ -20,17 +20,10 @@ import {
|
||||
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
|
||||
} from '../src/api/host.schema.ts'
|
||||
import {
|
||||
workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema,
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
} from '../src/api/workspace.schema.ts'
|
||||
import {
|
||||
commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
|
||||
commandListRequestSchema, commandListValueSchema,
|
||||
} from '../src/api/commands.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
@@ -115,9 +108,14 @@ describe('wire full-form schemas', () => {
|
||||
expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a quadrant missing its members', () => {
|
||||
it('rejects a quadrant missing its members but accepts a valueless success result', () => {
|
||||
expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow()
|
||||
// A void business result carries no value field; the endpoint's own second
|
||||
// parse is what requires a value for methods that return data.
|
||||
expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId)
|
||||
.toBe('r1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -346,22 +344,11 @@ describe('workspace domain schemas', () => {
|
||||
createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
|
||||
}
|
||||
|
||||
it('validates ids, the view row, and list request/value', () => {
|
||||
it('validates ids and the view row', () => {
|
||||
expect(workspaceIdSchema.parse('w1')).toBe('w1')
|
||||
expect(() => workspaceIdSchema.parse('')).toThrow()
|
||||
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
|
||||
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
|
||||
expect(workspaceListRequestSchema.parse({})).toEqual({})
|
||||
expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1)
|
||||
expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow()
|
||||
})
|
||||
|
||||
it('archiveSession request/value carry the id and the full updated set', () => {
|
||||
expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow()
|
||||
expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds)
|
||||
.toEqual(['s1', 's2'])
|
||||
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
|
||||
})
|
||||
|
||||
it('create requires a path', () => {
|
||||
@@ -384,45 +371,6 @@ describe('workspace domain schemas', () => {
|
||||
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
|
||||
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
||||
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands domain schemas', () => {
|
||||
it('validates the catalog request/value pair', () => {
|
||||
expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => commandListRequestSchema.parse({})).toThrow()
|
||||
expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([])
|
||||
const value = commandListValueSchema.parse({ commands: [
|
||||
{ name: 'plan', description: 'Toggle plan mode' },
|
||||
{ name: 'goal', description: 'Set the goal', input: { hint: '<goal>' } },
|
||||
] })
|
||||
expect(value.commands[1]?.input?.hint).toBe('<goal>')
|
||||
expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined()
|
||||
expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow()
|
||||
})
|
||||
|
||||
it('validates the execute request/value pair with both matched branches', () => {
|
||||
expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off')
|
||||
// Both members are mandatory: dropping either fails the parse.
|
||||
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
|
||||
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
|
||||
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
|
||||
// Pure admission: matched plus the optional lifecycle pairing id
|
||||
// (outcomes ride the logged lifecycle events, never this response).
|
||||
expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' }))
|
||||
.toEqual({ matched: true, commandId: 'cmd-1' })
|
||||
expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
|
||||
expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow()
|
||||
expect(() => commandExecuteValueSchema.parse({})).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills domain schemas', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user