fix(api-gateway): harden remote lifecycle and recovery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
Reference in New Issue
Block a user