Browse Source

refactor(web): keep Gateway argument handling unchanged

Yichen Jiang 1 month ago
parent
commit
ab6bb7b86f

+ 2 - 2
packages/api/gateway/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/gateway/README.md
-README.md: 4ac838d40cb3fab06efae510fe348d656b5fb147
-README.zh.md: cc0f5fedd741b84f7fed3ccac8f07f5fe4c6d47f
+README.md: 561081c3a5de716e563eaa9764f387e329365504
+README.zh.md: 9588b194efc64d14a17a69323a14d6118d0e7d7e

+ 1 - 1
packages/api/gateway/README.md

@@ -24,7 +24,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host
 <a id="host-service-typertgatewayservice-ctx-key-typertgateway"></a>
 ## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
 
-`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. On the Client, a call names every business argument in order, except that trailing parameters declared `T | undefined` — an optional parameter is one — may be left off, as the generated signature allows; an omitted parameter sends no wire field. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance.
+`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance.
 
 Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
 

+ 1 - 1
packages/api/gateway/README.zh.md

@@ -24,7 +24,7 @@ kind: "package-reference"
 <a id="host-service-typertgatewayservice-ctx-key-typertgateway"></a>
 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
 
-每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。在 Client 侧,调用按顺序给出每个业务参数,只有声明为 `T | undefined` 的尾随参数——可选参数就是这样一种——可以按生成的签名省略;省略的参数不发送 wire 字段。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。
+每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。
 
 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
 

+ 4 - 12
packages/api/gateway/src/client/index.ts

@@ -481,20 +481,12 @@ class ClientRemoteService extends Service implements ClientRemote {
     boundIdentity?: BoundContextIdentity,
   ): PreparedClientInvocation {
     const endpoint = endpointOf(descriptor)
-    const business = descriptor.parameters.filter((_parameter, index) => index !== projection?.parameterIndex)
-    const expected = business.length
-    // A trailing parameter declared `T | undefined` (an optional parameter is
-    // one) may be left off the call, as its generated signature allows; its
-    // wire field is then absent. A caller supplying the AbortSignal names
-    // every business argument first.
-    let required = expected
-    while (required > 0 && business[required - 1]?.acceptsUndefined === true) required -= 1
+    const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
     const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
-    if ((values.length < required || values.length > expected) && !hasCallerSignal) {
-      const count = required === expected ? String(expected) : `${String(required)} to ${String(expected)}`
+    if (values.length !== expected && !hasCallerSignal) {
       const contract = descriptor.cancellation === undefined
-        ? `${count} argument(s)`
-        : `${count} business argument(s) plus an optional AbortSignal`
+        ? `${String(expected)} argument(s)`
+        : `${String(expected)} business argument(s) plus an optional AbortSignal`
       throw new Error(
         `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
       )

+ 0 - 54
packages/api/gateway/tests/gateway.client.spec.ts

@@ -12,7 +12,6 @@ import {
   type ConnectionHandle,
 } from '@deepseek-ai/dsh-client-connection/client'
 import type {
-  InvocationParameterDescriptor,
   InvocationDescriptor,
   RemoteResult,
   TypertContextMap,
@@ -1162,59 +1161,6 @@ describe('Client Typert API', () => {
     })).rejects.toThrow('scope must select its only lookup parameter')
   })
 
-  it('lets a caller omit trailing parameters that accept undefined, as their generated signature allows', async () => {
-    const call = vi.fn<ConnectionHandle['rpc']['call']>()
-      .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
-    const ctx = await bench(call)
-    const optionalTail: InvocationDescriptor = {
-      id: '@fixture/probe#probe/describe',
-      service: 'probe',
-      namespace: 'probe',
-      method: 'describe',
-      invocation: { kind: 'direct' },
-      parameters: [{
-        name: 'request',
-        wire: 'request',
-        source: 'json',
-        codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
-      }, {
-        name: 'scope',
-        wire: 'scope',
-        source: 'json',
-        acceptsUndefined: true,
-        codec: { mode: 'strict', typeSymbol: '@fixture#Scope', schema: z.union([z.undefined(), z.string()]) },
-      }],
-      result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
-    }
-    // A leading parameter accepting undefined earns no omission: the required one after it still counts.
-    const optionalHead: InvocationDescriptor = {
-      ...optionalTail,
-      id: '@fixture/probe#probe/inspect',
-      method: 'inspect',
-      parameters: [
-        optionalTail.parameters[1] as InvocationParameterDescriptor,
-        optionalTail.parameters[0] as InvocationParameterDescriptor,
-      ],
-    }
-    await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [optionalTail, optionalHead] })
-    const probe = ctx.remote.probe as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>
-    const describeCall = probe.describe as (...args: unknown[]) => Promise<unknown>
-    const inspect = probe.inspect as (...args: unknown[]) => Promise<unknown>
-
-    await describeCall({ objective: 'ship' })
-    expect((call.mock.calls[0]?.[2] as { args: Record<string, unknown> }).args).toEqual({ request: { objective: 'ship' } })
-    await describeCall({ objective: 'ship' }, undefined)
-    expect((call.mock.calls[1]?.[2] as { args: Record<string, unknown> }).args).toEqual({ request: { objective: 'ship' } })
-    await describeCall({ objective: 'ship' }, 'preset/standard')
-    expect((call.mock.calls[2]?.[2] as { args: Record<string, unknown> }).args)
-      .toEqual({ request: { objective: 'ship' }, scope: 'preset/standard' })
-    await expect(describeCall()).rejects.toThrow('expected 1 to 2 argument(s), got 0')
-    await expect(describeCall({ objective: 'ship' }, 'preset/standard', 'extra')).rejects.toThrow('got 3')
-    await expect(inspect(undefined)).rejects.toThrow('expected 2 argument(s), got 1')
-    await inspect(undefined, { objective: 'ship' })
-    expect((call.mock.calls[3]?.[2] as { args: Record<string, unknown> }).args).toEqual({ request: { objective: 'ship' } })
-  })
-
   it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })