feat: add TypeRT remote gateway infrastructure
This commit is contained in:
@@ -15,6 +15,10 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
@@ -22,14 +26,25 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
15
packages/typert/registry/src/client/index.ts
Normal file
15
packages/typert/registry/src/client/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Browser face of the shared TypeRT runtime registry. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { TypertRegistry } from '../service.ts'
|
||||
|
||||
/** Required services: none; this is the Client reflection root. */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Install the same registry implementation used by the Host face.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new TypertRegistry(ctx)
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Runtime registry for generated Typert contributions. It owns live Zod
|
||||
* instances and generated package reflection, but performs no TypeScript
|
||||
* analysis or schema generation.
|
||||
* @module @deepseek-ai/dsh-typert-registry
|
||||
*/
|
||||
/** Host entry for the shared TypeRT runtime registry. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import type { z } from 'zod'
|
||||
import type { TypeRTDisposer } from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
TypertContribution,
|
||||
TypertFace,
|
||||
@@ -16,204 +11,17 @@ import type {
|
||||
TypertSchemaRecord,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
TypertContribution,
|
||||
TypertDocTag,
|
||||
TypertDocumentation,
|
||||
TypertEventModel,
|
||||
TypertFace,
|
||||
TypertMemberModel,
|
||||
TypertObjectModel,
|
||||
TypertPackageFilter,
|
||||
TypertPackageModel,
|
||||
TypertPackageRecord,
|
||||
TypertSchema,
|
||||
TypertSchemaFilter,
|
||||
TypertSchemaRecord,
|
||||
TypertServiceModel,
|
||||
TypertTypeModel,
|
||||
} from './types.ts'
|
||||
export { default, TypertRegistry, typertEndpoint, typertKey, typertPackageKey } from './service.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
typert: TypertRegistry
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTService {
|
||||
register(contribution: TypertContribution): TypeRTDisposer
|
||||
get(key: string): TypertSchemaRecord | undefined
|
||||
resolve(key: string): TypertSchemaRecord
|
||||
list(filter?: TypertSchemaFilter): TypertSchemaRecord[]
|
||||
getPackage(packageName: string, face?: TypertFace): TypertPackageRecord | undefined
|
||||
listPackages(filter?: TypertPackageFilter): TypertPackageRecord[]
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the global key of one generated schema.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param name - schema export name.
|
||||
* @returns `<package>#<name>`.
|
||||
*/
|
||||
export function typertKey(packageName: string, name: string): string {
|
||||
return `${packageName}#${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the identity of one package-face model.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param face - independently compiled face.
|
||||
* @returns `<package>#<face>`.
|
||||
*/
|
||||
export function typertPackageKey(packageName: string, face: TypertFace): string {
|
||||
return `${packageName}#${face}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of generated schemas and package reflection.
|
||||
* @typert service
|
||||
*/
|
||||
export class TypertRegistry extends Service {
|
||||
private readonly schemas = new Map<string, TypertSchemaRecord>()
|
||||
private readonly packages = new Map<string, TypertPackageRecord>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typert')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one generated contribution atomically for the calling fiber.
|
||||
* Duplicate package-face identities or schema keys reject the whole batch.
|
||||
* @param contribution - generated schemas and package metadata.
|
||||
* @returns the exact effect disposer that removes this contribution.
|
||||
*/
|
||||
register(contribution: TypertContribution): () => void {
|
||||
const packageRecord = this.validatePackage(contribution)
|
||||
const schemaRecords = this.validateSchemas(contribution)
|
||||
const { schemas, packages } = this
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
packages.set(packageRecord.key, packageRecord)
|
||||
for (const record of schemaRecords) schemas.set(record.key, record)
|
||||
yield () => {
|
||||
packages.delete(packageRecord.key)
|
||||
for (const record of schemaRecords) schemas.delete(record.key)
|
||||
}
|
||||
}, 'typert.register()')
|
||||
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve Cordis disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up one schema by `<package>#<name>`.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypertSchemaRecord | undefined {
|
||||
return this.schemas.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one required schema.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record.
|
||||
* @throws when the key is malformed, the package face is absent, or the schema is not contributed.
|
||||
*/
|
||||
resolve(key: string): TypertSchemaRecord {
|
||||
const record = this.schemas.get(key)
|
||||
if (record !== undefined) return record
|
||||
const hash = key.indexOf('#')
|
||||
if (hash <= 0 || hash === key.length - 1) {
|
||||
throw new Error(`typert: invalid schema key "${key}" — expected "<package>#<name>"`)
|
||||
}
|
||||
const packageName = key.slice(0, hash)
|
||||
if ([...this.packages.values()].some(candidate => candidate.package === packageName)) {
|
||||
throw new Error(
|
||||
`typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`,
|
||||
)
|
||||
}
|
||||
throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate live schemas in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching schema records.
|
||||
*/
|
||||
list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] {
|
||||
return [...this.schemas.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up generated reflection for one package face.
|
||||
* @param packageName - exact npm package name.
|
||||
* @param face - face to query; defaults to the host runtime.
|
||||
* @returns the live package record, or `undefined` when absent.
|
||||
*/
|
||||
getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined {
|
||||
return this.packages.get(typertPackageKey(packageName, face))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate generated package reflection in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching package records.
|
||||
*/
|
||||
listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] {
|
||||
return [...this.packages.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a live Zod schema to JSON Schema without caching the result.
|
||||
* @param key - global schema key.
|
||||
* @param params - Zod projection parameters.
|
||||
* @returns a fresh JSON Schema document.
|
||||
*/
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema {
|
||||
return z.toJSONSchema(this.resolve(key).schema, params)
|
||||
}
|
||||
|
||||
private validatePackage(contribution: TypertContribution): TypertPackageRecord {
|
||||
validateSegment('package name', contribution.package)
|
||||
const face: unknown = contribution.face
|
||||
if (face !== 'host' && face !== 'client') {
|
||||
throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`)
|
||||
}
|
||||
const key = typertPackageKey(contribution.package, contribution.face)
|
||||
if (this.packages.has(key)) {
|
||||
throw new Error(`typert: package face "${key}" is already registered`)
|
||||
}
|
||||
return {
|
||||
package: contribution.package,
|
||||
face,
|
||||
key,
|
||||
model: contribution.model,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] {
|
||||
const records: TypertSchemaRecord[] = []
|
||||
const batch = new Set<string>()
|
||||
for (const schema of contribution.schemas) {
|
||||
validateSegment('schema name', schema.name)
|
||||
const key = typertKey(contribution.package, schema.name)
|
||||
if (batch.has(key) || this.schemas.has(key)) {
|
||||
throw new Error(`typert: schema "${key}" is already registered`)
|
||||
}
|
||||
batch.add(key)
|
||||
records.push({
|
||||
...schema,
|
||||
package: contribution.package,
|
||||
face: contribution.face,
|
||||
key,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
||||
function matches(
|
||||
record: { readonly package: string; readonly face: TypertFace },
|
||||
filter: { readonly package?: string; readonly face?: TypertFace },
|
||||
): boolean {
|
||||
return (filter.package === undefined || record.package === filter.package)
|
||||
&& (filter.face === undefined || record.face === filter.face)
|
||||
}
|
||||
|
||||
function validateSegment(subject: string, value: string): void {
|
||||
if (value.length === 0 || value.includes('#')) {
|
||||
throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`)
|
||||
}
|
||||
}
|
||||
|
||||
export default TypertRegistry
|
||||
|
||||
584
packages/typert/registry/src/service.ts
Normal file
584
packages/typert/registry/src/service.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Runtime registry for generated TypeRT reflection, Remote invocations, and
|
||||
* dependency-inverted lookup/Context providers. It performs no TypeScript
|
||||
* analysis or schema generation.
|
||||
* @module @deepseek-ai/dsh-typert-registry
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientContextBinder,
|
||||
TypeRTContextMap,
|
||||
TypeRTContextRegistry,
|
||||
TypeRTContextWire,
|
||||
TypeRTDisposer,
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
TypeRTLookupRegistry,
|
||||
TypeRTLookupWire,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteRegistry,
|
||||
TypeRTRegistryChange,
|
||||
TypeRTRegistryListener,
|
||||
TypeRTService,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
TypertContribution,
|
||||
TypertFace,
|
||||
TypertPackageFilter,
|
||||
TypertPackageRecord,
|
||||
TypertSchemaFilter,
|
||||
TypertSchemaRecord,
|
||||
} from './types.ts'
|
||||
|
||||
/**
|
||||
* Compose the global key of one generated schema.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param name - schema export name.
|
||||
* @returns `<package>#<name>`.
|
||||
*/
|
||||
export function typertKey(packageName: string, name: string): string {
|
||||
return `${packageName}#${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the identity of one package-face model.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param face - independently compiled face.
|
||||
* @returns `<package>#<face>`.
|
||||
*/
|
||||
export function typertPackageKey(packageName: string, face: TypertFace): string {
|
||||
return `${packageName}#${face}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the endpoint key used by local and Remote invocation registries.
|
||||
* @param descriptor - invocation whose namespace and method form the endpoint.
|
||||
* @returns `<namespace>/<method>`.
|
||||
*/
|
||||
export function typertEndpoint(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
}
|
||||
|
||||
interface DescriptorEntry {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly owner: object
|
||||
}
|
||||
|
||||
interface ProviderEntry<Provider> {
|
||||
readonly provider: Provider
|
||||
readonly owner: object
|
||||
}
|
||||
|
||||
type ReportObserverError = (change: TypeRTRegistryChange, error: unknown) => void
|
||||
|
||||
class ChangeSource {
|
||||
private readonly listeners = new Set<TypeRTRegistryListener>()
|
||||
|
||||
constructor(private readonly report: ReportObserverError) {}
|
||||
|
||||
subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer {
|
||||
const { listeners } = this
|
||||
return ctx.effect(function* () {
|
||||
listeners.add(listener)
|
||||
yield () => { listeners.delete(listener) }
|
||||
}, 'typert registry subscription')
|
||||
}
|
||||
|
||||
emit(change: TypeRTRegistryChange): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener(change)
|
||||
} catch (error) {
|
||||
this.report(change, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DescriptorStore {
|
||||
private readonly entries = new Map<string, DescriptorEntry>()
|
||||
private readonly ids = new Map<string, DescriptorEntry>()
|
||||
private readonly history = new Set<string>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(
|
||||
private readonly kind: 'local' | 'remote',
|
||||
report: ReportObserverError,
|
||||
) {
|
||||
this.changes = new ChangeSource(report)
|
||||
}
|
||||
|
||||
validate(descriptors: readonly InvocationDescriptor[]): void {
|
||||
const endpoints = new Set<string>()
|
||||
const ids = new Set<string>()
|
||||
for (const descriptor of descriptors) {
|
||||
validateInvocation(descriptor)
|
||||
const endpoint = typertEndpoint(descriptor)
|
||||
if (endpoints.has(endpoint) || this.entries.has(endpoint)) {
|
||||
throw new Error(`typert: ${this.kind} endpoint "${endpoint}" is already registered`)
|
||||
}
|
||||
if (ids.has(descriptor.id) || this.ids.has(descriptor.id)) {
|
||||
throw new Error(`typert: ${this.kind} invocation id "${descriptor.id}" is already registered`)
|
||||
}
|
||||
endpoints.add(endpoint)
|
||||
ids.add(descriptor.id)
|
||||
}
|
||||
}
|
||||
|
||||
commit(owner: object, descriptors: readonly InvocationDescriptor[]): void {
|
||||
for (const descriptor of descriptors) {
|
||||
const entry = { descriptor, owner }
|
||||
const endpoint = typertEndpoint(descriptor)
|
||||
this.entries.set(endpoint, entry)
|
||||
this.ids.set(descriptor.id, entry)
|
||||
this.history.add(endpoint)
|
||||
}
|
||||
for (const descriptor of descriptors) {
|
||||
this.changes.emit({ kind: this.kind, key: typertEndpoint(descriptor) })
|
||||
}
|
||||
}
|
||||
|
||||
withdraw(owner: object, descriptors: readonly InvocationDescriptor[]): void {
|
||||
const removed: string[] = []
|
||||
for (const descriptor of descriptors) {
|
||||
const endpoint = typertEndpoint(descriptor)
|
||||
const entry = this.entries.get(endpoint)
|
||||
if (entry?.owner !== owner) continue
|
||||
this.entries.delete(endpoint)
|
||||
if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id)
|
||||
removed.push(endpoint)
|
||||
}
|
||||
for (const endpoint of removed) this.changes.emit({ kind: this.kind, key: endpoint })
|
||||
}
|
||||
|
||||
get(endpoint: string): InvocationDescriptor | undefined {
|
||||
return this.entries.get(endpoint)?.descriptor
|
||||
}
|
||||
|
||||
hasSeen(endpoint: string): boolean {
|
||||
return this.history.has(endpoint)
|
||||
}
|
||||
|
||||
list(): readonly InvocationDescriptor[] {
|
||||
return [...this.entries.values()].map(entry => entry.descriptor)
|
||||
}
|
||||
|
||||
subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer {
|
||||
return this.changes.subscribe(ctx, listener)
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteStore {
|
||||
private readonly packages = new Map<string, object>()
|
||||
|
||||
constructor(private readonly descriptors: DescriptorStore) {}
|
||||
|
||||
view(ctx: Context): TypeRTRemoteRegistry {
|
||||
return {
|
||||
register: contribution => this.register(ctx, contribution),
|
||||
get: endpoint => this.descriptors.get(endpoint),
|
||||
list: () => this.descriptors.list(),
|
||||
subscribe: listener => this.descriptors.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private register(ctx: Context, contribution: TypeRTRemoteContribution): TypeRTDisposer {
|
||||
validateSegment('Remote package name', contribution.package)
|
||||
if (this.packages.has(contribution.package)) {
|
||||
throw new Error(`typert: Remote package "${contribution.package}" is already registered`)
|
||||
}
|
||||
this.descriptors.validate(contribution.descriptors)
|
||||
const owner = {}
|
||||
const { packages, descriptors } = this
|
||||
return ctx.effect(function* () {
|
||||
packages.set(contribution.package, owner)
|
||||
descriptors.commit(owner, contribution.descriptors)
|
||||
yield () => {
|
||||
if (packages.get(contribution.package) === owner) packages.delete(contribution.package)
|
||||
descriptors.withdraw(owner, contribution.descriptors)
|
||||
}
|
||||
}, `typert.remotes.register(${JSON.stringify(contribution.package)})`)
|
||||
}
|
||||
}
|
||||
|
||||
class LookupStore {
|
||||
private readonly providers = new Map<string, ProviderEntry<TypeRTLookupProvider>>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(report: ReportObserverError) {
|
||||
this.changes = new ChangeSource(report)
|
||||
}
|
||||
|
||||
view(ctx: Context): TypeRTLookupRegistry {
|
||||
return {
|
||||
register: <K extends Extract<keyof TypeRTLookupMap, string>>(
|
||||
key: K,
|
||||
provider: TypeRTLookupProvider<
|
||||
TypeRTLookupHost<TypeRTLookupMap[K]>,
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
) => this.register(ctx, key, provider),
|
||||
get: key => this.providers.get(key)?.provider,
|
||||
keys: () => [...this.providers.keys()],
|
||||
subscribe: listener => this.changes.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private register<Host, Wire>(ctx: Context, key: string, provider: TypeRTLookupProvider<Host, Wire>): TypeRTDisposer {
|
||||
validateSegment('lookup key', key)
|
||||
validateSegment('lookup parameter', provider.parameter)
|
||||
validateWireName('lookup wire field', provider.wire)
|
||||
validateNonempty('lookup Host type symbol', provider.hostTypeSymbol)
|
||||
validateNonempty('lookup wire type symbol', provider.wireTypeSymbol)
|
||||
if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`)
|
||||
const owner = {}
|
||||
const entry: ProviderEntry<TypeRTLookupProvider> = { provider, owner }
|
||||
const { providers, changes } = this
|
||||
return ctx.effect(function* () {
|
||||
providers.set(key, entry)
|
||||
changes.emit({ kind: 'lookup', key })
|
||||
yield () => {
|
||||
if (providers.get(key) !== entry) return
|
||||
providers.delete(key)
|
||||
changes.emit({ kind: 'lookup', key })
|
||||
}
|
||||
}, `typert.lookups.register(${JSON.stringify(key)})`)
|
||||
}
|
||||
}
|
||||
|
||||
class ContextStore {
|
||||
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
|
||||
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(report: ReportObserverError) {
|
||||
this.changes = new ChangeSource(report)
|
||||
}
|
||||
|
||||
view(ctx: Context): TypeRTContextRegistry {
|
||||
return {
|
||||
registerHost: <K extends Extract<keyof TypeRTContextMap, string>>(
|
||||
key: K,
|
||||
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
) => this.registerHost(ctx, key, provider),
|
||||
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,
|
||||
getClient: key => this.clients.get(key)?.provider,
|
||||
subscribe: listener => this.changes.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private registerHost<Wire>(ctx: Context, key: string, provider: TypeRTHostContextProvider<Wire>): TypeRTDisposer {
|
||||
validateSegment('Context key', key)
|
||||
validateWireName('Context wire field', provider.wire)
|
||||
validateNonempty('Context wire type symbol', provider.wireTypeSymbol)
|
||||
return this.registerProvider(ctx, this.hosts, 'host-context', key, provider)
|
||||
}
|
||||
|
||||
private registerClient<Wire>(ctx: Context, key: string, binder: TypeRTClientContextBinder<Wire>): TypeRTDisposer {
|
||||
validateSegment('Context key', key)
|
||||
return this.registerProvider(ctx, this.clients, 'client-context', key, binder)
|
||||
}
|
||||
|
||||
private registerProvider<Provider>(
|
||||
ctx: Context,
|
||||
table: Map<string, ProviderEntry<Provider>>,
|
||||
kind: 'host-context' | 'client-context',
|
||||
key: string,
|
||||
provider: Provider,
|
||||
): TypeRTDisposer {
|
||||
if (table.has(key)) throw new Error(`typert: ${kind} provider "${key}" is already registered`)
|
||||
const entry: ProviderEntry<Provider> = { provider, owner: {} }
|
||||
const { changes } = this
|
||||
return ctx.effect(function* () {
|
||||
table.set(key, entry)
|
||||
changes.emit({ kind, key })
|
||||
yield () => {
|
||||
if (table.get(key) !== entry) return
|
||||
table.delete(key)
|
||||
changes.emit({ kind, key })
|
||||
}
|
||||
}, `typert.contexts.register(${JSON.stringify(key)})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of generated schemas, package reflection, invocations, and Remote
|
||||
* dependency providers.
|
||||
* @typert service typert
|
||||
*/
|
||||
export class TypertRegistry extends Service implements TypeRTService {
|
||||
private readonly schemas = new Map<string, TypertSchemaRecord>()
|
||||
private readonly packages = new Map<string, TypertPackageRecord>()
|
||||
private readonly localStore: DescriptorStore
|
||||
private readonly remoteStore: RemoteStore
|
||||
private readonly lookupStore: LookupStore
|
||||
private readonly contextStore: ContextStore
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typert')
|
||||
const report: ReportObserverError = (change, error) => {
|
||||
ctx.logger.warn(`typert: ${change.kind} observer for "${change.key}" failed`)
|
||||
ctx.logger.warn(error)
|
||||
}
|
||||
this.localStore = new DescriptorStore('local', report)
|
||||
this.remoteStore = new RemoteStore(new DescriptorStore('remote', report))
|
||||
this.lookupStore = new LookupStore(report)
|
||||
this.contextStore = new ContextStore(report)
|
||||
}
|
||||
|
||||
/** Current-environment invocation definitions. */
|
||||
get local(): TypeRTLocalRegistry {
|
||||
const ctx = this.ctx
|
||||
return {
|
||||
get: endpoint => this.localStore.get(endpoint),
|
||||
hasSeen: endpoint => this.localStore.hasSeen(endpoint),
|
||||
list: () => this.localStore.list(),
|
||||
subscribe: listener => this.localStore.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
/** Consumer-selected Remote definitions. */
|
||||
get remotes(): TypeRTRemoteRegistry {
|
||||
return this.remoteStore.view(this.ctx)
|
||||
}
|
||||
|
||||
/** Host object lookup providers. */
|
||||
get lookups(): TypeRTLookupRegistry {
|
||||
return this.lookupStore.view(this.ctx)
|
||||
}
|
||||
|
||||
/** Host Context providers and Client Context binders. */
|
||||
get contexts(): TypeRTContextRegistry {
|
||||
return this.contextStore.view(this.ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one generated contribution atomically for the calling fiber.
|
||||
* Duplicate package-face identities, schemas, invocation ids, or endpoints
|
||||
* reject the whole batch.
|
||||
* @param contribution - generated schemas, reflection, and Host invocations.
|
||||
* @returns the exact effect disposer that removes this contribution.
|
||||
*/
|
||||
register(contribution: TypertContribution): TypeRTDisposer {
|
||||
const packageRecord = this.validatePackage(contribution)
|
||||
const schemaRecords = this.validateSchemas(contribution)
|
||||
const invocations = contribution.invocations ?? []
|
||||
this.localStore.validate(invocations)
|
||||
const owner = {}
|
||||
const { schemas, packages, localStore } = this
|
||||
return this.ctx.effect(function* () {
|
||||
packages.set(packageRecord.key, packageRecord)
|
||||
for (const record of schemaRecords) schemas.set(record.key, record)
|
||||
localStore.commit(owner, invocations)
|
||||
yield () => {
|
||||
if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key)
|
||||
for (const record of schemaRecords) {
|
||||
if (schemas.get(record.key) === record) schemas.delete(record.key)
|
||||
}
|
||||
localStore.withdraw(owner, invocations)
|
||||
}
|
||||
}, 'typert.register()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up one schema by `<package>#<name>`.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypertSchemaRecord | undefined {
|
||||
return this.schemas.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one required schema.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record.
|
||||
* @throws when the key is malformed, the package face is absent, or the schema is not contributed.
|
||||
*/
|
||||
resolve(key: string): TypertSchemaRecord {
|
||||
const record = this.schemas.get(key)
|
||||
if (record !== undefined) return record
|
||||
const hash = key.indexOf('#')
|
||||
if (hash <= 0 || hash === key.length - 1) {
|
||||
throw new Error(`typert: invalid schema key "${key}" — expected "<package>#<name>"`)
|
||||
}
|
||||
const packageName = key.slice(0, hash)
|
||||
if ([...this.packages.values()].some(candidate => candidate.package === packageName)) {
|
||||
throw new Error(
|
||||
`typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`,
|
||||
)
|
||||
}
|
||||
throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate live schemas in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching schema records.
|
||||
*/
|
||||
list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] {
|
||||
return [...this.schemas.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up generated reflection for one package face.
|
||||
* @param packageName - exact npm package name.
|
||||
* @param face - face to query; defaults to the host runtime.
|
||||
* @returns the live package record, or `undefined` when absent.
|
||||
*/
|
||||
getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined {
|
||||
return this.packages.get(typertPackageKey(packageName, face))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate generated package reflection in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching package records.
|
||||
*/
|
||||
listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] {
|
||||
return [...this.packages.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a live Zod schema to JSON Schema without caching the result.
|
||||
* @param key - global schema key.
|
||||
* @param params - Zod projection parameters.
|
||||
* @returns a fresh JSON Schema document.
|
||||
*/
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema {
|
||||
return z.toJSONSchema(this.resolve(key).schema, params)
|
||||
}
|
||||
|
||||
private validatePackage(contribution: TypertContribution): TypertPackageRecord {
|
||||
validateSegment('package name', contribution.package)
|
||||
const face: unknown = contribution.face
|
||||
if (face !== 'host' && face !== 'client') {
|
||||
throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`)
|
||||
}
|
||||
const key = typertPackageKey(contribution.package, contribution.face)
|
||||
if (this.packages.has(key)) {
|
||||
throw new Error(`typert: package face "${key}" is already registered`)
|
||||
}
|
||||
return {
|
||||
package: contribution.package,
|
||||
face,
|
||||
key,
|
||||
model: contribution.model,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] {
|
||||
const records: TypertSchemaRecord[] = []
|
||||
const batch = new Set<string>()
|
||||
for (const schema of contribution.schemas) {
|
||||
validateSegment('schema name', schema.name)
|
||||
const key = typertKey(contribution.package, schema.name)
|
||||
if (batch.has(key) || this.schemas.has(key)) {
|
||||
throw new Error(`typert: schema "${key}" is already registered`)
|
||||
}
|
||||
batch.add(key)
|
||||
records.push({
|
||||
...schema,
|
||||
package: contribution.package,
|
||||
face: contribution.face,
|
||||
key,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
||||
function matches(
|
||||
record: { readonly package: string; readonly face: TypertFace },
|
||||
filter: { readonly package?: string; readonly face?: TypertFace },
|
||||
): boolean {
|
||||
return (filter.package === undefined || record.package === filter.package)
|
||||
&& (filter.face === undefined || record.face === filter.face)
|
||||
}
|
||||
|
||||
function validateInvocation(descriptor: InvocationDescriptor): void {
|
||||
validateNonempty('invocation id', descriptor.id)
|
||||
validateSegment('invocation service key', descriptor.service)
|
||||
validateWireName('invocation namespace', descriptor.namespace)
|
||||
validateWireName('invocation method', descriptor.method)
|
||||
if (descriptor.implementation !== undefined) {
|
||||
validateWireName('invocation implementation method', descriptor.implementation)
|
||||
}
|
||||
validateCodec(descriptor.result, `${descriptor.id} result`)
|
||||
const wires = new Set<string>()
|
||||
for (const parameter of descriptor.parameters) {
|
||||
validateWireName('parameter name', parameter.name)
|
||||
validateWireName('parameter wire field', parameter.wire)
|
||||
if (wires.has(parameter.wire)) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${parameter.wire}"`)
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
if (parameter.source === 'lookup') {
|
||||
if (parameter.lookup === undefined) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`)
|
||||
}
|
||||
validateSegment('lookup key', parameter.lookup)
|
||||
} else if (parameter.lookup !== undefined) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" JSON parameter "${parameter.name}" declares a lookup key`)
|
||||
}
|
||||
validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`)
|
||||
}
|
||||
if (descriptor.scope !== undefined) {
|
||||
if (descriptor.invocation.kind !== 'direct') {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`)
|
||||
}
|
||||
validateSegment('scope Context key', descriptor.scope.context)
|
||||
validateWireName('scope wire field', descriptor.scope.wire)
|
||||
const lookups = descriptor.parameters.filter(candidate => candidate.source === 'lookup')
|
||||
const parameter = lookups.length === 1 ? lookups[0] : undefined
|
||||
if (parameter === undefined || parameter.wire !== descriptor.scope.wire
|
||||
|| parameter.lookup !== descriptor.scope.context) {
|
||||
throw new Error(
|
||||
`typert: invocation "${descriptor.id}" scope wire "${descriptor.scope.wire}" must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
validateSegment('Context key', descriptor.invocation.context)
|
||||
validateWireName('Context wire field', descriptor.invocation.wire)
|
||||
if (wires.has(descriptor.invocation.wire)) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${descriptor.invocation.wire}"`)
|
||||
}
|
||||
validateCodec(descriptor.invocation.codec, `${descriptor.id} Context`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateCodec(codec: InvocationDescriptor['result'], subject: string): void {
|
||||
if (codec.mode === 'src-json') return
|
||||
validateNonempty(`${subject} type symbol`, codec.typeSymbol)
|
||||
if (typeof codec.schema.parse !== 'function') {
|
||||
throw new Error(`typert: ${subject} strict codec has no parse() method`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateWireName(subject: string, value: string): void {
|
||||
validateSegment(subject, value)
|
||||
if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`)
|
||||
}
|
||||
|
||||
function validateSegment(subject: string, value: string): void {
|
||||
if (value.length === 0 || value.includes('#')) {
|
||||
throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateNonempty(subject: string, value: string): void {
|
||||
if (value.length === 0) throw new Error(`typert: invalid ${subject} — must be nonempty`)
|
||||
}
|
||||
|
||||
export default TypertRegistry
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { z } from 'zod'
|
||||
import type { InvocationDescriptor } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Independently compiled side that produced a contribution. */
|
||||
export type TypertFace = 'host' | 'client'
|
||||
@@ -82,6 +83,13 @@ export interface TypertContribution {
|
||||
readonly face: TypertFace
|
||||
readonly schemas: readonly TypertSchema[]
|
||||
readonly model: TypertPackageModel
|
||||
/** Host invocation definitions; absent on artifacts generated before Remote support. */
|
||||
readonly invocations?: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** Generated Host contribution with strict Remote invocation definitions. */
|
||||
export interface TypertLocalContribution extends TypertContribution {
|
||||
readonly invocations: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** A live schema plus its contribution identity. */
|
||||
|
||||
@@ -2,10 +2,27 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import TypertRegistry, {
|
||||
typertEndpoint,
|
||||
typertKey,
|
||||
typertPackageKey,
|
||||
type TypertContribution,
|
||||
} from '@deepseek-ai/dsh-typert-registry'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTContext,
|
||||
TypeRTLookup,
|
||||
TypeRTRemoteContribution,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
fixture: TypeRTLookup<{ readonly id: string }, string>
|
||||
}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
registryFixture: TypeRTContext<string>
|
||||
}
|
||||
}
|
||||
|
||||
async function makeCtx(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
@@ -42,6 +59,42 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })):
|
||||
}
|
||||
}
|
||||
|
||||
function invocation(id = '@fixture/remote#goals/create'): InvocationDescriptor {
|
||||
return {
|
||||
id,
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
function scopedInvocation(): InvocationDescriptor {
|
||||
return {
|
||||
...invocation('@fixture/remote#goals/create-scoped'),
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'src-json' },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
describe('TypertRegistry', () => {
|
||||
it('registers and queries generated schemas separately from package reflection', async () => {
|
||||
const ctx = await makeCtx()
|
||||
@@ -69,7 +122,7 @@ describe('TypertRegistry', () => {
|
||||
const dispose = ctx.typert.register(toolsContribution())
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined()
|
||||
|
||||
dispose()
|
||||
await dispose()
|
||||
|
||||
expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined()
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined()
|
||||
@@ -145,4 +198,133 @@ describe('TypertRegistry', () => {
|
||||
expect(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } })
|
||||
expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected)
|
||||
})
|
||||
|
||||
it('registers local invocations atomically with generated reflection', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const descriptor = invocation()
|
||||
const contribution = { ...toolsContribution(), invocations: [descriptor] }
|
||||
const changes: string[] = []
|
||||
ctx.typert.local.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) })
|
||||
|
||||
expect(ctx.typert.local.hasSeen('goals/create')).toBe(false)
|
||||
const dispose = ctx.typert.register(contribution)
|
||||
|
||||
expect(typertEndpoint(descriptor)).toBe('goals/create')
|
||||
expect(ctx.typert.local.get('goals/create')).toBe(descriptor)
|
||||
expect(ctx.typert.local.hasSeen('goals/create')).toBe(true)
|
||||
expect(ctx.typert.local.list()).toEqual([descriptor])
|
||||
expect(changes).toEqual(['local:goals/create'])
|
||||
|
||||
await dispose()
|
||||
expect(ctx.typert.local.list()).toEqual([])
|
||||
expect(ctx.typert.local.hasSeen('goals/create')).toBe(true)
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined()
|
||||
expect(changes).toEqual(['local:goals/create', 'local:goals/create'])
|
||||
})
|
||||
|
||||
it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const descriptor = invocation()
|
||||
const contribution: TypeRTRemoteContribution = {
|
||||
package: '@fixture/remote',
|
||||
descriptors: [descriptor],
|
||||
}
|
||||
const changes: string[] = []
|
||||
ctx.typert.remotes.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) })
|
||||
const fiber = ctx.plugin(Object.assign(
|
||||
(child: Context) => { child.typert.remotes.register(contribution) },
|
||||
{ inject: ['typert'] },
|
||||
))
|
||||
await fiber
|
||||
|
||||
expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor)
|
||||
expect(() => ctx.typert.remotes.register(contribution)).toThrow('Remote package')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
expect(changes).toEqual(['remote:goals/create', 'remote:goals/create'])
|
||||
})
|
||||
|
||||
it('accepts only a direct scope selecting its unique lookup parameter', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const descriptor = scopedInvocation()
|
||||
const dispose = ctx.typert.remotes.register({ package: '@fixture/scoped', descriptors: [descriptor] })
|
||||
expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor)
|
||||
await dispose()
|
||||
|
||||
const cases: readonly [InvocationDescriptor, string][] = [
|
||||
[{
|
||||
...descriptor,
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'scopeId',
|
||||
codec: { mode: 'src-json' },
|
||||
},
|
||||
}, 'Context receiver cannot declare a direct scope projection'],
|
||||
[{ ...descriptor, scope: { context: 'fixture', wire: 'missingId' } }, 'must select its only lookup parameter'],
|
||||
[{
|
||||
...descriptor,
|
||||
parameters: [...descriptor.parameters, {
|
||||
name: 'other',
|
||||
wire: 'otherId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
}, 'must select its only lookup parameter'],
|
||||
[{ ...descriptor, scope: { context: 'other', wire: 'agentId' } }, 'must select its only lookup parameter'],
|
||||
]
|
||||
for (const [index, [candidate, message]] of cases.entries()) {
|
||||
expect(() => ctx.typert.remotes.register({
|
||||
package: `@fixture/rejected-${String(index)}`,
|
||||
descriptors: [candidate],
|
||||
})).toThrow(message)
|
||||
}
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('registers lookup and Context providers without domain branches', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const object = { id: 'agent-1' }
|
||||
const scoped = ctx.extend()
|
||||
const disposeLookup = ctx.typert.lookups.register('fixture', {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@fixture/agent#Agent',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
resolve: id => id === object.id ? object : undefined,
|
||||
})
|
||||
const disposeHost = ctx.typert.contexts.registerHost('registryFixture', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
resolve: id => id === object.id ? scoped : undefined,
|
||||
})
|
||||
const disposeClient = ctx.typert.contexts.registerClient('registryFixture', {
|
||||
identity: candidate => candidate === scoped ? object.id : undefined,
|
||||
})
|
||||
|
||||
expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
|
||||
|
||||
await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
|
||||
expect(ctx.typert.lookups.keys()).toEqual([])
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains change-listener failures and still notifies later listeners', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const warnings: unknown[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(message) }) as typeof ctx.logger.warn
|
||||
let observed = 0
|
||||
ctx.typert.remotes.subscribe(() => { throw new Error('observer failed') })
|
||||
ctx.typert.remotes.subscribe(() => { observed += 1 })
|
||||
|
||||
ctx.typert.remotes.register({ package: '@fixture/remote', descriptors: [invocation()] })
|
||||
|
||||
expect(observed).toBe(1)
|
||||
expect(warnings.map(String)).toContain('Error: observer failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
/** Build the registry and its invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
||||
Reference in New Issue
Block a user