fix(api-gateway): harden remote lifecycle and recovery

This commit is contained in:
imccyu
2026-08-07 16:53:04 +08:00
parent e89d078819
commit 686ee5b3f6
21 changed files with 218 additions and 34 deletions

View File

@@ -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/typert/registry/README.md
README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48
README.zh.md: aea74b3753feccd88ee132363dc60ade02161498
README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1
README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93

View File

@@ -10,6 +10,7 @@ Package reflection is keyed by `<package>#<face>`. Schemas are keyed by `<packag
- `TypertRegistry` is the default plugin and provides `ctx.typert`.
- `ctx.typert.lookups.register()` registers the wire declaration and default resolver owned by the business package; `configure()` registers a resolver owned by Host composition that may run asynchronously. Their lifetimes are independent: configuration may precede the provider, and unloading the configuration restores the default policy.
- `ctx.typert.contexts.registerHost()` and `configureHost()` apply the same ownership split to scoped Context identity; `registerClient()` supplies the corresponding Client Context binder.
- `register(contribution)` rejects malformed identities and duplicate package-face or schema keys before committing anything, then returns the exact Cordis effect disposer.
- `get(key)`, `resolve(key)`, and `list(filter?)` query live schemas. `resolve()` distinguishes a malformed key, an absent package, and a package that contributes no schema under that name.
- `getPackage(packageName, face?)` and `listPackages(filter?)` query generated service, event, and object reflection; the default face is `host`.

View File

@@ -10,6 +10,7 @@
- `TypertRegistry` 是默认插件,并提供 `ctx.typert`
- `ctx.typert.lookups.register()` 注册业务包拥有的 wire 声明和默认 resolver`configure()` 注册 Host 组合拥有、可异步执行的 resolver。两者生命周期独立配置可以先于 provider卸载配置会恢复默认策略。
- `ctx.typert.contexts.registerHost()``configureHost()` 将同一所有权拆分应用于 scoped Context 身份;`registerClient()` 提供对应的 Client Context binder。
- `register(contribution)` 会在提交任何内容之前拒绝格式错误的标识,以及重复的包与 face 组合键或 schema 键,随后返回 Cordis effect 提供的同一资源释放函数。
- `get(key)``resolve(key)``list(filter?)` 查询当前有效的 schema。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包。
- `getPackage(packageName, face?)``listPackages(filter?)` 查询生成的服务、事件和对象反射信息;默认 face 为 `host`

View File

@@ -15,6 +15,7 @@ import type {
TypeRTContextWire,
TypeRTDisposer,
TypeRTHostContextProvider,
TypeRTHostContextResolver,
TypeRTLocalRegistry,
TypeRTLookupHost,
TypeRTLookupDefinition,
@@ -334,6 +335,7 @@ function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLooku
class ContextStore {
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
private readonly hostResolvers = new Map<string, ProviderEntry<HostContextResolverEntry>>()
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
private readonly changes: ChangeSource
@@ -347,16 +349,56 @@ class ContextStore {
key: K,
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.registerHost(ctx, key, provider),
configureHost: <K extends Extract<keyof TypeRTContextMap, string>>(
key: K,
resolver: TypeRTHostContextResolver<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.configureHost(ctx, key, resolver),
registerClient: <K extends Extract<keyof TypeRTContextMap, string>>(
key: K,
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.registerClient(ctx, key, binder),
getHost: key => this.hosts.get(key)?.provider,
getHost: key => this.getHost(key),
getClient: key => this.clients.get(key)?.provider,
subscribe: listener => this.changes.subscribe(ctx, listener),
}
}
private getHost(key: string): TypeRTHostContextProvider | undefined {
const provider = this.hosts.get(key)?.provider
if (provider === undefined) return undefined
const resolver = this.hostResolvers.get(key)?.provider
if (resolver === undefined) return provider
return {
wire: provider.wire,
wireTypeSymbol: provider.wireTypeSymbol,
resolve: id => resolver.resolve(id),
}
}
private configureHost<Wire>(
ctx: Context,
key: string,
resolver: TypeRTHostContextResolver<Wire>,
): TypeRTDisposer {
validateSegment('Context key', key)
if (this.hostResolvers.has(key)) throw new Error(`typert: host-context "${key}" resolver is already configured`)
const entry: ProviderEntry<HostContextResolverEntry> = {
provider: { resolve: async id => resolver(id as Wire) },
owner: {},
}
const { hostResolvers, changes } = this
return ctx.effect(function* () {
hostResolvers.set(key, entry)
changes.emit({ kind: 'host-context', key })
yield () => {
/* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */
if (hostResolvers.get(key) !== entry) return
hostResolvers.delete(key)
changes.emit({ kind: 'host-context', key })
}
}, `typert.contexts.configureHost(${JSON.stringify(key)})`)
}
private registerHost<Wire>(ctx: Context, key: string, provider: TypeRTHostContextProvider<Wire>): TypeRTDisposer {
validateSegment('Context key', key)
validateWireName('Context wire field', provider.wire)
@@ -392,6 +434,10 @@ class ContextStore {
}
}
interface HostContextResolverEntry {
resolve(id: unknown): Promise<Context | undefined>
}
/**
* Registry of generated schemas, package reflection, invocations, and Remote
* dependency providers.

View File

@@ -389,6 +389,36 @@ describe('TypertRegistry', () => {
await disposeReloadedProvider()
})
it('configures an asynchronous Host Context resolver independently of provider load order', async () => {
const ctx = await makeCtx()
const fallback = ctx.extend()
const configured = ctx.extend()
const disposeResolver = ctx.typert.contexts.configureHost('registryFixture', async id =>
id === 'configured' ? configured : undefined)
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', {
wire: 'agentId',
wireTypeSymbol: '@fixture/session#SessionId',
resolve: id => id === 'fallback' ? fallback : undefined,
})
await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured')
await disposeProvider()
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', {
wire: 'agentId',
wireTypeSymbol: '@fixture/session#SessionId',
resolve: id => id === 'fallback' ? fallback : undefined,
})
await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
await disposeResolver()
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('fallback')).toBe(fallback)
await disposeReloadedProvider()
})
it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => {
const ctx = await makeCtx()
const changes: string[] = []

View File

@@ -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/typert/type-meta/README.md
README.md: b394c843409e840b75bbb08b128614379e528001
README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5
README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c
README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec

View File

@@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the
Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API.
Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path.
Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path.
## Model Experience

View File

@@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用
业务包扩展 `TypeRTLookupMap``TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap``TypeRTRemoteContextMap``TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolverHost 组合可以另行配置同步或异步 resolver策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema`src-json` 编解码器标识约束更弱的源码启动路径。
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolverHost 组合可以另行配置同步或异步 resolver策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema`src-json` 编解码器标识约束更弱的源码启动路径。
## 模型体验

View File

@@ -50,6 +50,7 @@ export type {
TypeRTContextWire,
TypeRTDisposer,
TypeRTHostContextProvider,
TypeRTHostContextResolver,
TypeRTLocalRegistry,
TypeRTLookup,
TypeRTLookupDefinition,

View File

@@ -238,9 +238,14 @@ export interface TypeRTHostContextProvider<Wire = unknown> {
* @param id - validated wire identity.
* @returns the scoped Context, or `undefined` when unavailable.
*/
resolve(id: Wire): Context | undefined
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
}
/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */
export type TypeRTHostContextResolver<Wire = unknown> = (
id: Wire,
) => Context | undefined | Promise<Context | undefined>
/** Client resolver for the identity carried by the calling scoped Context. */
export interface TypeRTClientContextBinder<Wire = unknown> {
/**
@@ -367,6 +372,17 @@ export interface TypeRTContextRegistry {
key: K,
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
): TypeRTDisposer
/**
* Override one Host Context key's identity policy for the calling fiber.
* Configuration may precede provider registration and restores the provider's default resolver on disposal.
* @param key - merge-declared Context key.
* @param resolver - composition-owned resolver used by every Host Context lookup of this key.
* @returns disposer restoring the provider's default resolver.
*/
configureHost<K extends StringKeyOf<TypeRTContextMap>>(
key: K,
resolver: TypeRTHostContextResolver<TypeRTContextWire<TypeRTContextMap[K]>>,
): TypeRTDisposer
/**
* Register a Client Context identity binder.
* @param key - merge-declared Context key.